Skip to content

Commit 05e0074

Browse files
authored
fix(html): emit lengths the core parser can read (#289)
Two parsers for the same syntax have drifted. Executed: `style="padding: 24px 48px; margin: 0 auto; grid-template-columns: repeat(3, 1fr)"` transpiles to `"padding":"24px 48px"`, `"margin":"0 auto"`, `"grid-template-columns":["repeat(3,","1fr)"]`. All three deserialize successfully — `Edges::Uniform(LengthPercentage::String)` and `GridTrack::Length(LengthPercentage::String)` are untagged string variants — and then core's `parse_length` (rustmotion-core/src/css/units.rs:125) only understands a single number+unit token, so `parse_length_or_warn` (units.rs:262) falls back to `Px(0.0)`: zero padding, zero margin, two zero-width grid tracks. The only signal is an `eprintln!` warning; `validate` exits 0 and the render is wrong. Per-side padding is entirely unexpressible from HTML (`padding-top:32` is rejected by `CssStyle`'s `deny_unknown_fields`, `padding:32px 48px` silently becomes 0), and the skill rule html-css-mental-model.md:97 only documents the JSON object form, which inline `style=""` cannot produce. Refs #220
1 parent 7298fd8 commit 05e0074

3 files changed

Lines changed: 298 additions & 20 deletions

File tree

‎crates/rustmotion-html/src/element.rs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,10 @@ fn collect_text(handle: &Handle, out: &mut String) {
5858
/// [`crate::style::parse_anim_attr`]) lands in `style.animation` — inline CSS
5959
/// cannot express animation arrays, so `anim` is the only writer of that key.
6060
fn style_object(attrs: &[(String, String)]) -> Result<Option<Value>, HtmlError> {
61-
let mut map = attrs
62-
.iter()
63-
.find(|(k, _)| k == "style")
64-
.map(|(_, raw)| parse_inline_style(raw))
65-
.unwrap_or_default();
61+
let mut map = match attrs.iter().find(|(k, _)| k == "style") {
62+
Some((_, raw)) => parse_inline_style(raw)?,
63+
None => Map::new(),
64+
};
6665
if let Some((_, anim)) = attrs.iter().find(|(k, _)| k == "anim") {
6766
map.insert("animation".into(), parse_anim_attr(anim)?);
6867
}

‎crates/rustmotion-html/src/style.rs‎

Lines changed: 202 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,16 @@ pub fn coerce_value(raw: &str) -> Value {
2828
Value::from(t.to_string())
2929
}
3030

31-
/// Parse an inline `style="a:b; c:d"` declaration list into a JSON style object.
32-
/// `grid-template-columns`/`-rows` are split into string arrays; all other
33-
/// properties pass through their kebab-case name with a coerced value.
34-
pub fn parse_inline_style(decls: &str) -> Map<String, Value> {
31+
/// Parse an inline `style="a:b; c:d"` declaration list into a JSON style
32+
/// object. `padding`/`margin`/`border-radius` accept the CSS 1/2/3/4-value
33+
/// box shorthand, expanded into the `{top,right,bottom,left}` /
34+
/// `{top-left,top-right,bottom-right,bottom-left}` object the core CSS
35+
/// engine's `Edges`/`BorderRadius` types deserialize. `grid-template-columns`/
36+
/// `-rows` accept a track list, `repeat()`/`minmax()` included. Any other
37+
/// property whose value is more than one top-level (paren-aware) token is
38+
/// refused rather than passed through as an opaque string the core length
39+
/// parser cannot read (see [`HtmlError::UnsupportedStyleShorthand`]).
40+
pub fn parse_inline_style(decls: &str) -> Result<Map<String, Value>, HtmlError> {
3541
let mut map = Map::new();
3642
for decl in decls.split(';') {
3743
let decl = decl.trim();
@@ -43,17 +49,198 @@ pub fn parse_inline_style(decls: &str) -> Map<String, Value> {
4349
};
4450
let prop = prop.trim().to_string();
4551
let value = value.trim();
46-
if prop == "grid-template-columns" || prop == "grid-template-rows" {
47-
let arr: Vec<Value> = value
48-
.split_whitespace()
49-
.map(|t| Value::from(t.to_string()))
50-
.collect();
51-
map.insert(prop, Value::Array(arr));
52-
} else {
53-
map.insert(prop, coerce_value(value));
52+
match prop.as_str() {
53+
"grid-template-columns" | "grid-template-rows" => {
54+
map.insert(
55+
prop.clone(),
56+
Value::Array(parse_grid_template(&prop, value)?),
57+
);
58+
}
59+
"padding" | "margin" => {
60+
let tokens = split_top_level_tokens(value);
61+
match tokens.len() {
62+
1 => {
63+
map.insert(prop, coerce_value(value));
64+
}
65+
2..=4 => {
66+
map.insert(prop, expand_box_edges(&tokens));
67+
}
68+
_ => {
69+
return Err(HtmlError::UnsupportedStyleShorthand {
70+
prop,
71+
value: value.to_string(),
72+
})
73+
}
74+
}
75+
}
76+
"border-radius" => {
77+
let tokens = split_top_level_tokens(value);
78+
match tokens.len() {
79+
1 => {
80+
map.insert(prop, coerce_value(value));
81+
}
82+
2..=4 => {
83+
map.insert(prop, expand_border_radius_corners(&tokens));
84+
}
85+
_ => {
86+
return Err(HtmlError::UnsupportedStyleShorthand {
87+
prop,
88+
value: value.to_string(),
89+
})
90+
}
91+
}
92+
}
93+
_ => {
94+
if split_top_level_tokens(value).len() > 1 {
95+
return Err(HtmlError::UnsupportedStyleShorthand {
96+
prop,
97+
value: value.to_string(),
98+
});
99+
}
100+
map.insert(prop, coerce_value(value));
101+
}
102+
}
103+
}
104+
Ok(map)
105+
}
106+
107+
/// Split a CSS value on top-level whitespace: whitespace inside a `(...)`
108+
/// span (e.g. the argument list of `rgba(0, 0, 0, 0.5)` or `repeat(3, 1fr)`)
109+
/// does not count as a separator, so a single functional-notation value
110+
/// stays one token while a genuine multi-value shorthand (`24px 48px`)
111+
/// splits into its parts.
112+
fn split_top_level_tokens(s: &str) -> Vec<&str> {
113+
let mut tokens = Vec::new();
114+
let mut depth = 0i32;
115+
let mut token_start: Option<usize> = None;
116+
for (i, c) in s.char_indices() {
117+
match c {
118+
'(' => depth += 1,
119+
')' => depth = depth.saturating_sub(1),
120+
_ => {}
121+
}
122+
if c.is_whitespace() && depth == 0 {
123+
if let Some(start) = token_start.take() {
124+
tokens.push(&s[start..i]);
125+
}
126+
} else if token_start.is_none() {
127+
token_start = Some(i);
128+
}
129+
}
130+
if let Some(start) = token_start {
131+
tokens.push(&s[start..]);
132+
}
133+
tokens
134+
}
135+
136+
/// Expand a 2/3/4-value `padding`/`margin` shorthand into the
137+
/// `{top,right,bottom,left}` object `Edges::Sides` deserializes, following
138+
/// the standard CSS clockwise-from-top expansion rule.
139+
fn expand_box_edges(tokens: &[&str]) -> Value {
140+
let (top, right, bottom, left) = match tokens {
141+
[a, b] => (*a, *b, *a, *b),
142+
[a, b, c] => (*a, *b, *c, *b),
143+
[a, b, c, d] => (*a, *b, *c, *d),
144+
_ => unreachable!("caller only passes 2..=4 tokens"),
145+
};
146+
let mut edges = Map::new();
147+
edges.insert("top".into(), coerce_value(top));
148+
edges.insert("right".into(), coerce_value(right));
149+
edges.insert("bottom".into(), coerce_value(bottom));
150+
edges.insert("left".into(), coerce_value(left));
151+
Value::Object(edges)
152+
}
153+
154+
/// Expand a 2/3/4-value `border-radius` shorthand into the
155+
/// `{top-left,top-right,bottom-right,bottom-left}` object
156+
/// `BorderRadius::Corners` deserializes, following the standard CSS
157+
/// clockwise-from-top-left expansion rule (a different starting corner than
158+
/// [`expand_box_edges`], per the CSS box-shorthand spec).
159+
fn expand_border_radius_corners(tokens: &[&str]) -> Value {
160+
let (top_left, top_right, bottom_right, bottom_left) = match tokens {
161+
[a, b] => (*a, *b, *a, *b),
162+
[a, b, c] => (*a, *b, *c, *b),
163+
[a, b, c, d] => (*a, *b, *c, *d),
164+
_ => unreachable!("caller only passes 2..=4 tokens"),
165+
};
166+
let mut corners = Map::new();
167+
corners.insert("top-left".into(), coerce_value(top_left));
168+
corners.insert("top-right".into(), coerce_value(top_right));
169+
corners.insert("bottom-right".into(), coerce_value(bottom_right));
170+
corners.insert("bottom-left".into(), coerce_value(bottom_left));
171+
Value::Object(corners)
172+
}
173+
174+
/// Parse a `grid-template-columns`/`-rows` track list into the flat
175+
/// `Vec<GridTrack>` JSON the core CSS engine expects: `repeat(n, track)`
176+
/// expands into `n` copies of `track`, `minmax(min, max)` becomes
177+
/// `{"min":..,"max":..}`, and every other token passes through
178+
/// [`coerce_value`] unchanged (a bare number for `fr`, a keyword string, or
179+
/// an explicit length).
180+
fn parse_grid_template(prop: &str, value: &str) -> Result<Vec<Value>, HtmlError> {
181+
let mut out = Vec::new();
182+
for token in split_top_level_tokens(value) {
183+
push_grid_track(prop, token, &mut out)?;
184+
}
185+
Ok(out)
186+
}
187+
188+
fn push_grid_track(prop: &str, token: &str, out: &mut Vec<Value>) -> Result<(), HtmlError> {
189+
if let Some(inner) = token
190+
.strip_prefix("repeat(")
191+
.and_then(|s| s.strip_suffix(')'))
192+
{
193+
let (count_str, pattern) =
194+
inner
195+
.split_once(',')
196+
.ok_or_else(|| HtmlError::UnsupportedStyleShorthand {
197+
prop: prop.to_string(),
198+
value: token.to_string(),
199+
})?;
200+
let count: usize =
201+
count_str
202+
.trim()
203+
.parse()
204+
.map_err(|_| HtmlError::UnsupportedStyleShorthand {
205+
prop: prop.to_string(),
206+
value: token.to_string(),
207+
})?;
208+
let pattern_tokens = split_top_level_tokens(pattern.trim());
209+
if pattern_tokens.is_empty() {
210+
return Err(HtmlError::UnsupportedStyleShorthand {
211+
prop: prop.to_string(),
212+
value: token.to_string(),
213+
});
54214
}
215+
for _ in 0..count {
216+
for t in &pattern_tokens {
217+
out.push(parse_single_grid_track(prop, t)?);
218+
}
219+
}
220+
return Ok(());
221+
}
222+
out.push(parse_single_grid_track(prop, token)?);
223+
Ok(())
224+
}
225+
226+
fn parse_single_grid_track(prop: &str, token: &str) -> Result<Value, HtmlError> {
227+
if let Some(inner) = token
228+
.strip_prefix("minmax(")
229+
.and_then(|s| s.strip_suffix(')'))
230+
{
231+
let (min_s, max_s) =
232+
inner
233+
.split_once(',')
234+
.ok_or_else(|| HtmlError::UnsupportedStyleShorthand {
235+
prop: prop.to_string(),
236+
value: token.to_string(),
237+
})?;
238+
let mut minmax = Map::new();
239+
minmax.insert("min".into(), coerce_value(min_s.trim()));
240+
minmax.insert("max".into(), coerce_value(max_s.trim()));
241+
return Ok(Value::Object(minmax));
55242
}
56-
map
243+
Ok(coerce_value(token))
57244
}
58245

59246
/// Parse an `anim` attribute into the `style.animation` JSON array.
@@ -187,15 +374,15 @@ mod tests {
187374

188375
#[test]
189376
fn parses_declarations_into_style_object() {
190-
let m = parse_inline_style("font-size:96px; color:#fff; text-align:center");
377+
let m = parse_inline_style("font-size:96px; color:#fff; text-align:center").unwrap();
191378
assert_eq!(m.get("font-size"), Some(&json!(96)));
192379
assert_eq!(m.get("color"), Some(&json!("#fff")));
193380
assert_eq!(m.get("text-align"), Some(&json!("center")));
194381
}
195382

196383
#[test]
197384
fn grid_template_becomes_string_array() {
198-
let m = parse_inline_style("grid-template-columns: 1fr 1fr");
385+
let m = parse_inline_style("grid-template-columns: 1fr 1fr").unwrap();
199386
assert_eq!(m.get("grid-template-columns"), Some(&json!(["1fr", "1fr"])));
200387
}
201388

‎crates/rustmotion-html/tests/audit_ws_f.rs‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,95 @@ fn inert_attributes_stay_inert_on_container_and_text() {
9595
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div class="wrapper" id="hero" data-testid="x"><p class="lead" data-x="1">Hi</p></div></scene></rustmotion>"##;
9696
html_to_scenario_value(html).expect("class/id/data-* must remain inert, not flagged");
9797
}
98+
99+
// ---------------------------------------------------------------------------
100+
// CSS shorthand values transpile to strings the core length parser
101+
// cannot read, silently resolving to 0px.
102+
// ---------------------------------------------------------------------------
103+
104+
#[test]
105+
fn padding_two_value_shorthand_expands_to_edges_object() {
106+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="padding: 24px 48px"></div></scene></rustmotion>"##;
107+
let v = html_to_scenario_value(html).expect("2-value padding must transpile");
108+
let padding = &v["scenes"][0]["children"][0]["style"]["padding"];
109+
assert_eq!(padding["top"], json!(24));
110+
assert_eq!(padding["bottom"], json!(24));
111+
assert_eq!(padding["right"], json!(48));
112+
assert_eq!(padding["left"], json!(48));
113+
}
114+
115+
#[test]
116+
fn margin_four_value_shorthand_expands_to_edges_object() {
117+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="margin: 4px 8px 12px 16px"></div></scene></rustmotion>"##;
118+
let v = html_to_scenario_value(html).expect("4-value margin must transpile");
119+
let margin = &v["scenes"][0]["children"][0]["style"]["margin"];
120+
assert_eq!(margin["top"], json!(4));
121+
assert_eq!(margin["right"], json!(8));
122+
assert_eq!(margin["bottom"], json!(12));
123+
assert_eq!(margin["left"], json!(16));
124+
}
125+
126+
#[test]
127+
fn border_radius_four_value_shorthand_expands_to_corners_object() {
128+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="border-radius: 2px 4px 6px 8px"></div></scene></rustmotion>"##;
129+
let v = html_to_scenario_value(html).expect("4-value border-radius must transpile");
130+
let radius = &v["scenes"][0]["children"][0]["style"]["border-radius"];
131+
assert_eq!(radius["top-left"], json!(2));
132+
assert_eq!(radius["top-right"], json!(4));
133+
assert_eq!(radius["bottom-right"], json!(6));
134+
assert_eq!(radius["bottom-left"], json!(8));
135+
}
136+
137+
#[test]
138+
fn grid_template_columns_repeat_expands_to_flat_track_list() {
139+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="grid-template-columns: repeat(3, 1fr)"></div></scene></rustmotion>"##;
140+
let v = html_to_scenario_value(html).expect("repeat() must transpile");
141+
let tracks = v["scenes"][0]["children"][0]["style"]["grid-template-columns"]
142+
.as_array()
143+
.expect("array of tracks");
144+
assert_eq!(tracks, &vec![json!("1fr"), json!("1fr"), json!("1fr")]);
145+
}
146+
147+
#[test]
148+
fn grid_template_columns_minmax_expands_to_min_max_object() {
149+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="grid-template-columns: minmax(100px, 1fr) auto"></div></scene></rustmotion>"##;
150+
let v = html_to_scenario_value(html).expect("minmax() must transpile");
151+
let tracks = v["scenes"][0]["children"][0]["style"]["grid-template-columns"]
152+
.as_array()
153+
.expect("array of tracks");
154+
assert_eq!(tracks.len(), 2);
155+
assert_eq!(tracks[0]["min"], json!(100));
156+
assert_eq!(tracks[0]["max"], json!("1fr"));
157+
assert_eq!(tracks[1], json!("auto"));
158+
}
159+
160+
#[test]
161+
fn unhandled_multi_token_style_value_is_refused_not_an_opaque_string() {
162+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="gap: 8px 16px"></div></scene></rustmotion>"##;
163+
let err = html_to_scenario_value(html)
164+
.expect_err("an unsupported multi-token style value must be refused, not silently zeroed");
165+
match err {
166+
HtmlError::UnsupportedStyleShorthand { prop, value } => {
167+
assert_eq!(prop, "gap");
168+
assert_eq!(value, "8px 16px");
169+
}
170+
other => panic!("expected UnsupportedStyleShorthand, got: {other:?}"),
171+
}
172+
}
173+
174+
#[test]
175+
fn single_token_padding_still_transpiles_to_a_plain_value() {
176+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="padding: 32px"></div></scene></rustmotion>"##;
177+
let v = html_to_scenario_value(html).expect("uniform padding must still transpile");
178+
assert_eq!(v["scenes"][0]["children"][0]["style"]["padding"], json!(32));
179+
}
180+
181+
#[test]
182+
fn rgba_color_functional_notation_is_not_treated_as_multi_token() {
183+
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div style="background: rgba(0, 0, 0, 0.5)"></div></scene></rustmotion>"##;
184+
let v = html_to_scenario_value(html).expect("rgba(...) must not be flagged as multi-token");
185+
assert_eq!(
186+
v["scenes"][0]["children"][0]["style"]["background"],
187+
json!("rgba(0, 0, 0, 0.5)")
188+
);
189+
}

0 commit comments

Comments
 (0)