From 653076fe1f1312d1dd6d10ce305f685891e3e095 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 22:25:01 +0200 Subject: [PATCH] feat(html): animated backgrounds, parameterized transitions and fonts in the dialect - and : JSON object/array parsed and inserted structurally, plain strings kept as-is; invalid JSON is an explicit InvalidBackgroundJson error - maps to the full Transition object; params without a transition type are a hard error, never silently dropped - children of aggregate into the scenario fonts array; html5ever nests siblings inside (HTML formatting element) so the scene walker recurses through font subtrees --- crates/rustmotion-html/src/lib.rs | 150 ++++++++++++++++++++++++++-- crates/rustmotion-html/src/scene.rs | 97 +++++++++++++++++- examples/html/showcase.html | 39 ++++++++ 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 examples/html/showcase.html diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index dc8272b..5b6fe15 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -22,6 +22,12 @@ pub enum HtmlError { NoScenes, #[error(" requires a duration attribute")] MissingDuration, + #[error("background attribute contains invalid JSON: {0}")] + InvalidBackgroundJson(String), + #[error("transition-duration and transition-easing require a transition attribute")] + TransitionParamsWithoutTransition, + #[error(" requires both family and path (or src) attributes")] + MissingFontAttributes, } /// Transpile an HTML-dialect document into the scenario `serde_json::Value` that @@ -43,20 +49,72 @@ pub fn html_to_scenario_value(html: &str) -> Result { video.insert("fps".into(), style::coerce_value(&fps)); } if let Some(bg) = get("background") { - video.insert("background".into(), Value::from(bg)); + video.insert("background".into(), parse_background_attr(&bg)?); } let mut scenes = Vec::new(); - for child in root.children.borrow().iter() { - if tag_name(child).as_deref() == Some("scene") { - scenes.push(scene::scene_to_value(child)?); - } - } + let mut fonts = Vec::new(); + collect_scenes_and_fonts(&root, &mut scenes, &mut fonts)?; if scenes.is_empty() { return Err(HtmlError::NoScenes); } - Ok(serde_json::json!({ "video": Value::Object(video), "scenes": scenes })) + let mut scenario = Map::new(); + scenario.insert("video".into(), Value::Object(video)); + scenario.insert("scenes".into(), Value::Array(scenes)); + if !fonts.is_empty() { + scenario.insert("fonts".into(), Value::Array(fonts)); + } + Ok(Value::Object(scenario)) +} + +/// Parse a `background` attribute value: if it starts with `{` or `[`, treat it as +/// JSON and parse it; otherwise return it as a plain string value. +pub(crate) fn parse_background_attr(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + serde_json::from_str(trimmed).map_err(|e| HtmlError::InvalidBackgroundJson(e.to_string())) + } else { + Ok(Value::from(raw)) + } +} + +/// Map a `` element to a `{"family": ..., "path": ...}` JSON object. +fn font_to_value(handle: &Handle) -> Result { + let attrs = element_attrs(handle); + let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + + let family = get("family").ok_or(HtmlError::MissingFontAttributes)?; + // Accept both `path` and `src` attribute names. + let path = get("path") + .or_else(|| get("src")) + .ok_or(HtmlError::MissingFontAttributes)?; + + Ok(serde_json::json!({ "family": family, "path": path })) +} + +/// Walk the immediate children of `parent`, collecting `` and `` +/// elements. Because html5ever's HTML5 parser treats `` as a formatting +/// element and nests subsequent siblings inside it, we recurse into `` +/// children so that `` elements placed after `` declarations are +/// still found at any depth. +fn collect_scenes_and_fonts( + parent: &Handle, + scenes: &mut Vec, + fonts: &mut Vec, +) -> Result<(), HtmlError> { + for child in parent.children.borrow().iter() { + match tag_name(child).as_deref() { + Some("scene") => scenes.push(scene::scene_to_value(child)?), + Some("font") => { + fonts.push(font_to_value(child)?); + // Recurse: html5ever may nest siblings inside the element. + collect_scenes_and_fonts(child, scenes, fonts)?; + } + _ => {} + } + } + Ok(()) } /// Parse an HTML fragment into an RcDom (browserless; html5ever). @@ -318,4 +376,82 @@ mod lib_tests { fn missing_root_is_an_error() { assert!(crate::html_to_scenario_value("
no root
").is_err()); } + + // --- font tests --- + + #[test] + fn fonts_are_collected_from_font_elements() { + let html = r##" + + +

hi

+
"##; + let v = crate::html_to_scenario_value(html).unwrap(); + let fonts = &v["fonts"]; + assert_eq!(fonts[0]["family"], json!("Inter")); + assert_eq!(fonts[0]["path"], json!("fonts/Inter.ttf")); + assert_eq!(fonts[1]["family"], json!("JetBrainsMono")); + assert_eq!(fonts[1]["path"], json!("fonts/JetBrainsMono.ttf")); + } + + #[test] + fn font_without_family_is_error() { + let html = r##" + +

hi

+
"##; + let err = crate::html_to_scenario_value(html).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::MissingFontAttributes), + "expected MissingFontAttributes, got: {err:?}" + ); + } + + #[test] + fn font_without_path_is_error() { + let html = r##" + +

hi

+
"##; + let err = crate::html_to_scenario_value(html).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::MissingFontAttributes), + "expected MissingFontAttributes, got: {err:?}" + ); + } + + #[test] + fn no_fonts_means_no_fonts_key() { + let html = r##" +

hi

+
"##; + let v = crate::html_to_scenario_value(html).unwrap(); + assert!( + v.get("fonts").is_none(), + "fonts key should be absent when no fonts declared" + ); + } + + // --- root-level background JSON --- + + #[test] + fn root_background_json_object_is_parsed() { + let html = r##" +

hi

+
"##; + let v = crate::html_to_scenario_value(html).unwrap(); + assert_eq!(v["video"]["background"]["gradient"], json!("linear")); + } + + #[test] + fn root_background_invalid_json_is_error() { + let html = r##" +

hi

+
"##; + let err = crate::html_to_scenario_value(html).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::InvalidBackgroundJson(_)), + "expected InvalidBackgroundJson, got: {err:?}" + ); + } } diff --git a/crates/rustmotion-html/src/scene.rs b/crates/rustmotion-html/src/scene.rs index 5ab4858..2e9bd27 100644 --- a/crates/rustmotion-html/src/scene.rs +++ b/crates/rustmotion-html/src/scene.rs @@ -3,6 +3,7 @@ use serde_json::{Map, Value}; use crate::element::children_to_values; use crate::element_attrs; +use crate::parse_background_attr; use crate::style::coerce_value; use crate::HtmlError; @@ -36,8 +37,26 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result { } obj.insert("layout".into(), Value::Object(layout)); + // background: string or inline JSON object/array + if let Some(bg) = get("background") { + obj.insert("background".into(), parse_background_attr(&bg)?); + } + + // transition: type is required; duration and easing are optional extras + let has_transition_params = + get("transition-duration").is_some() || get("transition-easing").is_some(); if let Some(t) = get("transition") { - obj.insert("transition".into(), serde_json::json!({ "type": t })); + let mut tr = Map::new(); + tr.insert("type".into(), Value::from(t)); + if let Some(dur) = get("transition-duration") { + tr.insert("duration".into(), coerce_value(&dur)); + } + if let Some(eas) = get("transition-easing") { + tr.insert("easing".into(), Value::from(eas)); + } + obj.insert("transition".into(), Value::Object(tr)); + } else if has_transition_params { + return Err(HtmlError::TransitionParamsWithoutTransition); } obj.insert("children".into(), Value::Array(children_to_values(handle))); @@ -69,4 +88,80 @@ mod tests { let v = scene_to_value(&el).unwrap(); assert_eq!(v["transition"]["type"], json!("fade")); } + + // --- background --- + + #[test] + fn scene_background_string_color() { + let dom = + crate::parse_fragment_dom(r##""##); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert_eq!(v["background"], json!("#1e293b")); + } + + #[test] + fn scene_background_json_object() { + let dom = crate::parse_fragment_dom( + r##""##, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert_eq!(v["background"]["preset"], json!("halo")); + assert_eq!( + v["background"]["halo"]["zones"][0]["color"], + json!("#7c3aed") + ); + } + + #[test] + fn scene_background_invalid_json_is_error() { + let dom = crate::parse_fragment_dom( + r#""#, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let err = scene_to_value(&el).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::InvalidBackgroundJson(_)), + "expected InvalidBackgroundJson, got: {err:?}" + ); + } + + // --- transition with duration and easing --- + + #[test] + fn scene_transition_full_params() { + let dom = crate::parse_fragment_dom( + r#""#, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert_eq!(v["transition"]["type"], json!("fade")); + assert_eq!(v["transition"]["duration"], json!(0.8)); + assert_eq!(v["transition"]["easing"], json!("ease_in_out")); + } + + #[test] + fn scene_transition_duration_without_transition_is_error() { + let dom = + crate::parse_fragment_dom(r#""#); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let err = scene_to_value(&el).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::TransitionParamsWithoutTransition), + "expected TransitionParamsWithoutTransition, got: {err:?}" + ); + } + + #[test] + fn scene_transition_easing_without_transition_is_error() { + let dom = + crate::parse_fragment_dom(r#""#); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let err = scene_to_value(&el).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::TransitionParamsWithoutTransition), + "expected TransitionParamsWithoutTransition, got: {err:?}" + ); + } } diff --git a/examples/html/showcase.html b/examples/html/showcase.html new file mode 100644 index 0000000..a068c6b --- /dev/null +++ b/examples/html/showcase.html @@ -0,0 +1,39 @@ + + + + + + + +
+

+ HTML Dialect +

+

+ Backgrounds · Transitions · Fonts +

+
+
+ + + +
+

+ Animated Background +

+

+ Inline JSON in the background attribute +

+
+
+ + + +
+

+ Grid Dots Preset +

+ +
+
+