From 9b0b138fb937b249396f95f6abb6906617c6a9b7 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:33:23 +0000 Subject: [PATCH] render: resolve contextual gaps and empty block lines --- crates/obscura-render/src/dom.rs | 79 +++++++++++++++++++ crates/obscura-render/src/lib.rs | 6 ++ crates/obscura-render/src/style.rs | 41 +++++++++- .../block-whitespace-margin-collapse.html | 25 ++++++ render-repros/checks.json | 10 +++ render-repros/contextual-grid-gap.html | 29 +++++++ 6 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 render-repros/block-whitespace-margin-collapse.html create mode 100644 render-repros/contextual-grid-gap.html diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index b7ec2a6..baaabab 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -593,6 +593,26 @@ pub fn layout_dom_with_images( // em in non-font-size properties is relative to this element's // OWN computed font-size; resolve every relative length now. let em_px = style.font_size.unwrap_or(parent_fs); + if let Some(expression) = style.row_gap_expression.as_deref() { + style.row_gap = crate::style::resolve_contextual_length( + expression, + em_px, + root_fs, + vw, + vh, + inh.cb_width, + ); + } + if let Some(expression) = style.column_gap_expression.as_deref() { + style.column_gap = crate::style::resolve_contextual_length( + expression, + em_px, + root_fs, + vw, + vh, + inh.cb_width, + ); + } if let Some(expression) = style.line_height_expression.as_deref() { if let Some(resolved) = crate::style::resolve_contextual_length( expression, @@ -1646,6 +1666,8 @@ fn propagate_border_spacing(tree: &DomTree, styles: &mut HashMap` logo pattern) belongs to the // ancestor block formatting context. Gecko reparents that out-of-flow @@ -4694,6 +4729,50 @@ mod tests { assert_eq!(size("inherited-border").0, 100.0); } + #[test] + fn root_percentage_font_size_controls_rem_lengths() { + let tree = parse_html( + r#" +
"#, + ); + let laid = layout_dom(&tree, (1280.0, 720.0)); + let html = tree.query_selector("html").unwrap().unwrap(); + let box_id = tree.query_selector("#box").unwrap().unwrap(); + assert_eq!(laid.styles.get(&html).unwrap().font_size, Some(10.0)); + assert_eq!(laid.styles.get(&box_id).unwrap().row_gap, Some(40.0)); + assert_eq!(laid.styles.get(&box_id).unwrap().column_gap, Some(35.6)); + assert_eq!(laid.rects.get(&box_id).unwrap().width, 40.0); + assert_eq!(laid.rects.get(&box_id).unwrap().height, 80.0); + assert_eq!(laid.rects.get(&box_id).unwrap().y, 30.0); + } + + #[test] + fn block_and_grid_sibling_margins_collapse() { + let tree = parse_html( + r#" +
+
"#, + ); + let laid = layout_dom(&tree, (1280.0, 720.0)); + let b = tree.query_selector("#b").unwrap().unwrap(); + assert_eq!(laid.rects.get(&b).unwrap().y, 50.0); + } + #[test] fn table_cell_content_box_width_includes_padding_and_border_in_track() { let tree = parse_html( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 9204cfb..67ef0d4 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -356,6 +356,12 @@ pub struct LayoutStyle { pub grid_row_raw: Option, pub column_gap: Option, pub row_gap: Option, + /// Deferred gap values. Font- and viewport-relative units cannot be + /// converted until the element's computed font-size and the live viewport + /// are known; eagerly treating `rem` as 16px breaks pages that customize + /// the root font-size. + pub column_gap_expression: Option, + pub row_gap_expression: Option, /// `border-spacing: ?` (or the `cellspacing` /// attribute). Only meaningful on a ``; taffy has no native table diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index f7b841f..aac4dbf 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -763,11 +763,14 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.underline = Some(underline && !none); } "gap" | "grid-gap" => { - let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); - if let Some(&r) = dims.first() { style.row_gap = Some(r); style.column_gap = Some(*dims.get(1).unwrap_or(&r)); } + let values = split_ws_paren(value); + if let Some(row) = values.first() { + apply_gap_value(style, true, row); + apply_gap_value(style, false, values.get(1).copied().unwrap_or(row)); + } } - "row-gap" | "grid-row-gap" => style.row_gap = px(value), - "column-gap" | "grid-column-gap" => style.column_gap = px(value), + "row-gap" | "grid-row-gap" => apply_gap_value(style, true, value), + "column-gap" | "grid-column-gap" => apply_gap_value(style, false, value), "border-spacing" => { let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); if let Some(&h) = dims.first() { @@ -2421,6 +2424,36 @@ fn apply_font_size(style: &mut LayoutStyle, value: &str) { } } +fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) { + let value = value.trim(); + let lower = value.to_ascii_lowercase(); + let contextual = lower.contains('(') + || lower.ends_with('%') + || ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"] + .iter() + .any(|unit| lower.ends_with(unit)); + let expression = if value.eq_ignore_ascii_case("normal") + || value.is_empty() + || !contextual + { + None + } else { + Some(value.to_string()) + }; + let immediate = if value.eq_ignore_ascii_case("normal") || value.is_empty() { + None + } else { + px(value) + }; + if row { + style.row_gap = immediate; + style.row_gap_expression = expression; + } else { + style.column_gap = immediate; + style.column_gap_expression = expression; + } +} + pub(crate) fn line_height_expression_is_length(value: &str) -> bool { let lower = value.to_ascii_lowercase(); lower.contains('%') diff --git a/render-repros/block-whitespace-margin-collapse.html b/render-repros/block-whitespace-margin-collapse.html new file mode 100644 index 0000000..33a53dd --- /dev/null +++ b/render-repros/block-whitespace-margin-collapse.html @@ -0,0 +1,25 @@ + + + + + Block whitespace margin collapse + + + +
+
+ + diff --git a/render-repros/checks.json b/render-repros/checks.json index 60ea25c..9a89966 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -211,6 +211,16 @@ {"name": "vmin margin resolves against smaller viewport axis", "color": "#f08c00", "x": 90, "y": 440, "width": 100, "height": 40}, {"name": "vmax margin resolves against larger viewport axis", "color": "#1098ad", "x": 100, "y": 520, "width": 100, "height": 40} ], + "contextual-grid-gap": [ + {"name": "first cell starts at the grid origin", "color": "#e03131", "x": 0, "y": 0, "width": 10, "height": 20}, + {"name": "functional column gap uses root and viewport context", "color": "#2f9e44", "x": 38, "y": 0, "width": 10, "height": 20}, + {"name": "rem row gap uses the computed root font size", "color": "#1971c2", "x": 0, "y": 60, "width": 10, "height": 20}, + {"name": "both contextual gaps place the final cell", "color": "#f08c00", "x": 38, "y": 60, "width": 10, "height": 20} + ], + "block-whitespace-margin-collapse": [ + {"name": "first block keeps its declared geometry", "color": "#087f5b", "x": 0, "y": 0, "width": 900, "height": 20}, + {"name": "formatting whitespace does not interrupt sibling margin collapse", "color": "#1971c2", "x": 0, "y": 60, "width": 900, "height": 20} + ], "replaced-grid-order": [ {"name": "order-modified description occupies the first grid cell", "color": "#e8590c", "x": 0, "y": 0, "width": 300, "height": 200}, {"name": "media wrapper shares the first row", "color": "#1971c2", "x": 300, "y": 0, "width": 300, "height": 300}, diff --git a/render-repros/contextual-grid-gap.html b/render-repros/contextual-grid-gap.html new file mode 100644 index 0000000..a8105ea --- /dev/null +++ b/render-repros/contextual-grid-gap.html @@ -0,0 +1,29 @@ + + + + + Contextual grid gap + + + +
+
+
+
+
+
+ +