Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions crates/rustmotion-html/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,48 @@ fn tag_kind(tag: &str) -> TagKind {
}
}

/// Concatenated text of an element and all its descendants.
pub(crate) fn inner_text(handle: &Handle) -> String {
/// Concatenated text of an element and all its descendants. Nested inline
/// formatting tags (`strong`/`em`/`label`/…) flatten in, matching real HTML;
/// `<script>`/`<title>`/`<noscript>`/`<template>`/`<head>` are skipped
/// (never visually render either); `<style>` and any element with real,
/// non-flattenable content (`<img>`, `<svg>`, `<video>`, `<rm-*>`, a nested
/// container) are refused rather than having their source painted or their
/// content silently vanish — see [`HtmlError::TextContentUnsupportedChild`].
pub(crate) fn inner_text(handle: &Handle) -> Result<String, HtmlError> {
let mut out = String::new();
collect_text(handle, &mut out);
out.trim().to_string()
collect_text(handle, &mut out)?;
Ok(out.trim().to_string())
}

fn collect_text(handle: &Handle, out: &mut String) {
fn collect_text(handle: &Handle, out: &mut String) -> Result<(), HtmlError> {
if let NodeData::Text { contents } = &handle.data {
out.push_str(&contents.borrow());
return Ok(());
}
if matches!(handle.data, NodeData::Element { .. }) {
if let Some(tag) = tag_name(handle) {
if tag == "style" {
return Err(HtmlError::StyleElementUnsupported);
}
match tag_kind(&tag) {
TagKind::Ignored => return Ok(()),
TagKind::UnsupportedNative(suggestion) => {
return Err(HtmlError::UnsupportedNativeElement {
tag,
suggestion: suggestion.to_string(),
})
}
TagKind::Container | TagKind::Custom(_) => {
return Err(HtmlError::TextContentUnsupportedChild { tag })
}
TagKind::Text => {}
}
}
}
for child in handle.children.borrow().iter() {
collect_text(child, out);
collect_text(child, out)?;
}
Ok(())
}

/// Pull `style="..."` and `anim="..."` from an element's attributes into one
Expand Down Expand Up @@ -94,7 +122,7 @@ pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, HtmlErr
check_known_attrs(&tag, &attrs, KNOWN_NATIVE_ATTRS)?;
let mut obj = Map::new();
obj.insert("type".into(), Value::from("text"));
obj.insert("content".into(), Value::from(inner_text(handle)));
obj.insert("content".into(), Value::from(inner_text(handle)?));
if let Some(style) = style_object(&attrs)? {
obj.insert("style".into(), style);
}
Expand Down
76 changes: 76 additions & 0 deletions crates/rustmotion-html/tests/audit_ws_f.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,79 @@ fn rgba_color_functional_notation_is_not_treated_as_multi_token() {
json!("rgba(0, 0, 0, 0.5)")
);
}

// ---------------------------------------------------------------------------
// text-tag children bypass every guard — <script>/<style> source gets
// painted, <img>/<svg>/<rm-*> vanish silently.
// ---------------------------------------------------------------------------

#[test]
fn script_nested_inside_paragraph_is_not_painted() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><p>Real <script>var secret = 1; alert(2);</script></p></scene></rustmotion>"##;
let v = html_to_scenario_value(html).expect("<script> inside <p> must not block transpilation");
assert_eq!(
v["scenes"][0]["children"][0]["content"],
json!("Real"),
"the script source must never be painted as text: {v}"
);
}

#[test]
fn style_nested_inside_heading_is_refused_not_painted() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><h1>Title<style>h1{color:#0f0}</style></h1></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("<style> inside <h1> must be refused, not painted as text");
assert!(
matches!(err, HtmlError::StyleElementUnsupported),
"expected StyleElementUnsupported, got: {err:?}"
);
}

#[test]
fn img_nested_inside_paragraph_is_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><p>cap<img src="hero.png"></p></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("<img> inside <p> must be refused, not silently dropped");
match err {
HtmlError::UnsupportedNativeElement { tag, suggestion } => {
assert_eq!(tag, "img");
assert_eq!(suggestion, "rm-image");
}
other => panic!("expected UnsupportedNativeElement, got: {other:?}"),
}
}

#[test]
fn svg_nested_inside_heading_is_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><h1>t<svg viewBox="0 0 10 10"><circle r="4"></circle></svg></h1></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("<svg> inside <h1> must be refused, not silently dropped");
match err {
HtmlError::UnsupportedNativeElement { tag, suggestion } => {
assert_eq!(tag, "svg");
assert_eq!(suggestion, "rm-svg");
}
other => panic!("expected UnsupportedNativeElement, got: {other:?}"),
}
}

#[test]
fn rm_counter_nested_inside_span_is_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><span>n=<rm-counter from="0" to="100"></rm-counter></span></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("<rm-counter> inside <span> must be refused, not silently dropped");
match err {
HtmlError::TextContentUnsupportedChild { tag } => assert_eq!(tag, "rm-counter"),
other => panic!("expected TextContentUnsupportedChild, got: {other:?}"),
}
}

#[test]
fn inline_formatting_tags_still_flatten_into_the_parent_text() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><p>Real <strong>bold</strong> text</p></scene></rustmotion>"##;
let v = html_to_scenario_value(html).expect("nested inline formatting must still flatten");
assert_eq!(
v["scenes"][0]["children"][0]["content"],
json!("Real bold text")
);
}
Loading