From 61d61feb50ea63e456b6ebad02bbff50fca78289 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:30:58 +0000 Subject: [PATCH] Fix ordinary inline used sizing --- crates/obscura-js/src/runtime.rs | 92 +++++++++++ crates/obscura-render/src/dom.rs | 172 +++++++++++++++++++-- crates/obscura-render/src/inline.rs | 2 +- crates/obscura-render/src/lib.rs | 30 ++++ crates/obscura-render/src/paint.rs | 8 +- crates/obscura-render/tests/layout_test.rs | 43 ++++++ 6 files changed, 335 insertions(+), 12 deletions(-) diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 770a655..4ec70b6 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2214,6 +2214,98 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn ordinary_inline_keeps_computed_sizes_but_uses_content_geometry() { + let dom = parse_html( + r#" +

A token after

+ + +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(400.0, 240.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const token = document.getElementById("token"); + const after = document.getElementById("after"); + const computed = getComputedStyle(token); + const rect = token.getBoundingClientRect(); + const afterRect = after.getBoundingClientRect(); + const atomic = document.getElementById("atomic").getBoundingClientRect(); + const replaced = document.getElementById("replaced").getBoundingClientRect(); + const item = document.getElementById("item").getBoundingClientRect(); + return { + computed: [ + computed.width, computed.height, + computed.minWidth, computed.minHeight, + computed.maxWidth, computed.maxHeight + ], + rect: [rect.x, rect.y, rect.width, rect.height], + after: [afterRect.x, afterRect.y], + client: [token.clientWidth, token.clientHeight], + atomic: [atomic.width, atomic.height], + replaced: [replaced.width, replaced.height], + item: [item.width, item.height, getComputedStyle(item).display] + }; + "#, + ) + .unwrap(); + + assert_eq!( + result["computed"], + serde_json::json!(["100%", "100px", "100%", "100px", "100%", "100px"]) + ); + let rect = result["rect"].as_array().unwrap(); + let token_x = rect[0].as_f64().unwrap(); + let token_y = rect[1].as_f64().unwrap(); + let token_width = rect[2].as_f64().unwrap(); + let token_height = rect[3].as_f64().unwrap(); + assert!( + token_width > 20.0 && token_width < 100.0, + "ordinary inline should hug text and padding: {rect:?}" + ); + assert!(token_height < 40.0, "ignored block size leaked into geometry"); + assert_eq!(result["client"], serde_json::json!([0, 0])); + let after = result["after"].as_array().unwrap(); + assert!(after[0].as_f64().unwrap() >= token_x + token_width - 0.01); + assert!((after[1].as_f64().unwrap() - token_y).abs() < 0.01); + assert_eq!(result["atomic"], serde_json::json!([80, 30])); + assert_eq!(result["replaced"], serde_json::json!([80, 30])); + assert_eq!(result["item"], serde_json::json!([90, 25, "block"])); + } + #[cfg(feature = "render")] #[test] fn computed_style_uses_renderer_stylesheet_cascade_and_invalidates() { diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 8df5e62..90771f0 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -894,6 +894,7 @@ fn resolve_atomic_percentage_heights( .is_some_and(|node| node.parent == Some(parent_id)); let child_percent = styles.get(&dom_id).and_then(|style| { (style.size_expressions[1].is_none() + && !style.ignores_used_box_sizes() && !matches!(style.position, Some(taffy::Position::Absolute))) .then_some(style.height) .and_then(|height| match height { @@ -1381,6 +1382,7 @@ fn cascade_walk( .filter(|value| value.is_finite() && *value >= 0.0); } let mut style = crate::style::ua_style(elem.local.as_ref()); + style.is_replaced_box = crate::inline::is_replaced(elem.local.as_ref()); style.color_scheme_dark = inherited_color_scheme_dark; if matches!(elem.local.as_ref(), "td" | "th") { if let Some(padding) = inherited_cell_padding { @@ -1480,7 +1482,8 @@ fn cascade_walk( this_props = std::rc::Rc::new(m); } custom_properties.insert(id, this_props.clone()); - let (before_pseudo, after_pseudo) = + style.is_replaced_box |= style.content_image.is_some(); + let (mut before_pseudo, mut after_pseudo) = if let Some(evaluator) = container_evaluator.as_deref_mut() { sheet.pseudo_styles_with_container_queries( tree, @@ -1493,6 +1496,12 @@ fn cascade_walk( } else { sheet.pseudo_styles(tree, matcher, id, &this_props, &style) }; + for pseudo in [&mut before_pseudo, &mut after_pseudo] + .into_iter() + .flatten() + { + pseudo.is_replaced_box = pseudo.content_image.is_some(); + } style.before_content = before_pseudo .as_ref() .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) @@ -2073,6 +2082,7 @@ fn layout_dom_once( opacity_product: f32, list_style: crate::ListStyle, line_height: crate::LineHeight, + white_space: crate::WhiteSpace, text_wrap_style: crate::TextWrapStyle, text_transform: crate::TextTransform, italic: bool, @@ -2121,6 +2131,7 @@ fn layout_dom_once( // CSS initial value of list-style-type. list_style: crate::ListStyle::Disc, line_height: crate::LineHeight::Normal, + white_space: crate::WhiteSpace::Normal, text_wrap_style: crate::TextWrapStyle::Auto, text_transform: crate::TextTransform::None, italic: false, @@ -2501,6 +2512,7 @@ fn layout_dom_once( inh.visibility_hidden || inh.opacity_product <= 0.0; match style.list_style { Some(v) => inh.list_style = v, None => style.list_style = Some(inh.list_style) } match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) } + match style.white_space { Some(v) => inh.white_space = v, None => style.white_space = Some(inh.white_space) } match style.text_wrap_style { Some(v) => inh.text_wrap_style = v, None => style.text_wrap_style = Some(inh.text_wrap_style) } match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) } match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) } @@ -2621,6 +2633,7 @@ fn layout_dom_once( let host_optical_sizing = style.font_optical_sizing; let host_variation_settings = style.font_variation_settings.clone(); let host_line_height = style.line_height; + let host_white_space = style.white_space; let host_text_wrap_style = style.text_wrap_style; let host_transform = style.text_transform; let host_italic = style.font_style_italic; @@ -2835,6 +2848,9 @@ fn layout_dom_once( } else if pseudo.line_height.is_none() { pseudo.line_height = host_line_height; } + if pseudo.white_space.is_none() { + pseudo.white_space = host_white_space; + } if pseudo.text_wrap_style.is_none() { pseudo.text_wrap_style = host_text_wrap_style; } @@ -3210,6 +3226,7 @@ fn layout_dom_once( crate::blockify_outer_display(style); } } + blockify_generated_pseudos(&mut styles); // The initial containing block is modelled by the root taffy node's // definite viewport height (set at build), which is what `html,body{ @@ -5671,6 +5688,30 @@ fn blockify_layout_children( } } +/// Generated `::before`/`::after` boxes participate in the same display +/// fixups as real elements. They are stored inside their host style rather +/// than in the DOM map, so the normal root/item/float/positioned loops above +/// cannot reach them. +fn blockify_generated_pseudos(styles: &mut HashMap) { + for host in styles.values_mut() { + let host_is_item_container = matches!( + host.display, + crate::Display::Flex | crate::Display::Grid + ) && !host.internal_flex_container; + for pseudo in [host.before_pseudo.as_mut(), host.after_pseudo.as_mut()] + .into_iter() + .flatten() + { + if host_is_item_container + || matches!(pseudo.position, Some(taffy::Position::Absolute)) + || pseudo.float.is_some() + { + crate::blockify_outer_display(pseudo); + } + } + } +} + /// Build whichever of an element or a text node `id` is, returning every /// taffy node it produced (usually one, but a text node fans out to one leaf /// per word; see `build_text_words`). Callers collecting a container's @@ -5914,8 +5955,6 @@ fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap Vec { let candidates: Vec<(NodeId, usize, String)> = styles .iter() + .filter(|(_, style)| !style.ignores_used_box_sizes()) .flat_map(|(&id, style)| { [0usize, 2, 4].into_iter().filter_map(move |slot| { style.size_expressions[slot] @@ -10833,6 +10867,124 @@ mod tests { assert_eq!(width("border-cell"), 100.0); } + #[test] + fn white_space_inherits_into_a_block_descendant_inline_context() { + let tree = parse_html( + r#" +

+                alpha beta gamma delta
+                alpha beta gamma delta
+            
"#, + ); + let laid = layout_dom(&tree, (400.0, 200.0)); + let inherited = tree.get_element_by_id("inherited").unwrap(); + let normal = tree.get_element_by_id("normal").unwrap(); + + assert_eq!( + laid.styles[&inherited].white_space, + Some(crate::WhiteSpace::Pre), + "a block descendant must inherit the preformatted wrapping mode" + ); + assert_eq!( + laid.styles[&normal].white_space, + Some(crate::WhiteSpace::Normal), + "an explicit descendant value must override inherited pre" + ); + assert_eq!( + laid.styles[&inherited] + .before_pseudo + .as_ref() + .unwrap() + .white_space, + Some(crate::WhiteSpace::Pre), + "a pseudo-element must inherit the originating element's computed wrapping mode" + ); + assert_eq!( + laid.rects[&inherited].height, 20.0, + "the inherited pre line must overflow horizontally without wrapping" + ); + assert!( + laid.rects[&normal].height > 20.0, + "the explicit normal line should still wrap at the containing width" + ); + } + + #[test] + fn generated_inline_boxes_apply_sizes_only_after_display_blockification() { + let tree = parse_html( + r#" +
+
+
"#, + ); + let laid = layout_dom(&tree, (500.0, 300.0)); + let flex = tree.get_element_by_id("flex").unwrap(); + let float = tree.get_element_by_id("float").unwrap(); + let relative = tree.get_element_by_id("relative").unwrap(); + + let pseudo = |host| { + laid.generated_boxes + .iter() + .find(|generated| { + generated.host == host && generated.kind == GeneratedBoxKind::Before + }) + .unwrap() + .rect + }; + assert_eq!( + laid.styles[&flex].before_pseudo.as_ref().unwrap().display, + crate::Display::Block, + "a generated flex item must be blockified" + ); + assert_eq!( + laid.styles[&float].before_pseudo.as_ref().unwrap().display, + crate::Display::Block, + "a floated generated box must be blockified" + ); + assert_eq!( + laid.styles[&relative] + .before_pseudo + .as_ref() + .unwrap() + .display, + crate::Display::Inline, + "relative positioning does not make an ordinary inline atomic" + ); + assert_eq!(pseudo(flex).width, 250.0); + assert_eq!(pseudo(flex).height, 120.0); + assert_eq!(pseudo(float).width, 80.0); + assert_eq!(pseudo(float).height, 40.0); + assert!( + pseudo(relative).width < 80.0 && pseudo(relative).height < 40.0, + "an ordinary inline pseudo must ignore authored box sizes" + ); + } + #[test] fn positioned_pseudo_inherits_the_hosts_computed_font_metrics() { let tree = parse_html( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 33cc536..7362679 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -1812,7 +1812,7 @@ fn is_pure_text_ifc( /// contains one is never a pure-text IFC (it keeps the general build path, /// where the element gets a real taffy box). This is by tag, not display, so a /// stylesheet setting `img{display:inline}` cannot trick us into folding it. -fn is_replaced(local: &str) -> bool { +pub(crate) fn is_replaced(local: &str) -> bool { matches!( local, "img" | "svg" | "canvas" | "video" | "audio" | "iframe" | "embed" diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 47de304..e05109d 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -512,6 +512,14 @@ pub struct LayoutStyle { /// (`` or its selected ``), rather than authored /// CSS. A decoded image's natural ratio replaces this provisional ratio. pub aspect_ratio_is_mapped: bool, + /// Whether this element generates a replaced box. + /// + /// Replaced elements with `display:inline` are atomic and therefore keep + /// authored width/height/min/max sizing. Ordinary inline boxes compute + /// those properties but ignore them for used layout. This provenance + /// cannot be inferred from intrinsic dimensions because an unloaded or + /// broken image remains replaced. + pub(crate) is_replaced_box: bool, pub margin: Edges, /// Which margin sides are `auto` (top, right, bottom, left). `margin: 0 /// auto` / `margin-inline: auto` centering needs a real Auto margin, which @@ -994,6 +1002,13 @@ pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6; pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7; impl LayoutStyle { + /// Whether CSS box sizes compute normally but do not apply to this box's + /// used geometry. The display value here is post-blockification, so roots, + /// flex/grid items, floats, and out-of-flow boxes retain applicable sizes. + pub(crate) fn ignores_used_box_sizes(&self) -> bool { + self.display == Display::Inline && !self.is_inline_block && !self.is_replaced_box + } + pub(crate) fn establishes_positioning_containing_block(&self) -> bool { self.containing_block_triggers != 0 } @@ -1347,6 +1362,21 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { s.aspect_ratio = Some(ar); } } + if style.ignores_used_box_sizes() { + s.size = taffy::Size { + width: taffy::Dimension::auto(), + height: taffy::Dimension::auto(), + }; + s.min_size = taffy::Size { + width: taffy::Dimension::auto(), + height: taffy::Dimension::auto(), + }; + s.max_size = taffy::Size { + width: taffy::Dimension::auto(), + height: taffy::Dimension::auto(), + }; + s.aspect_ratio = None; + } if style.display != Display::Block { if let Some(ai) = style.align_items { s.align_items = Some(ai); diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index fd1ba33..91ac45a 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -391,6 +391,9 @@ impl PreparedRender { ) -> Option<(f32, f32)> { let rect = self.layout.rects.get(&id)?; let style = self.layout.styles.get(&id)?; + if style.ignores_used_box_sizes() { + return Some((0.0, 0.0)); + } Some(( (rect.width - style.border.left - style.border.right).max(0.0), (rect.height - style.border.top - style.border.bottom).max(0.0), @@ -482,7 +485,10 @@ impl PreparedRender { }, ); - if let Some(rect) = rect { + if style.ignores_used_box_sizes() { + out.insert("width", dimension_css(style.width, "auto")); + out.insert("height", dimension_css(style.height, "auto")); + } else if let Some(rect) = rect { let horizontal_non_content = style.border.left + style.border.right + style.padding.left + style.padding.right; let vertical_non_content = diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 282e88f..e2c3c1e 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -1272,6 +1272,49 @@ fn text_alignment_does_not_shrink_block_children_or_wrap_inline_block_rows() { ); } +#[test] +fn non_replaced_inline_ignores_used_width_height_and_min_max_sizes() { + let tree = parse_html( + r#" +

A token after

"#, + ); + let laid = layout_dom(&tree, (400.0, 200.0)); + let token_id = tree.get_element_by_id("token").unwrap(); + let after_id = tree.get_element_by_id("after").unwrap(); + let token = laid.rects[&token_id]; + let after = laid.rects[&after_id]; + + assert_eq!( + laid.styles[&token_id].width, + obscura_render::Dimension::Percent(1.0), + "computed style must retain the authored percentage" + ); + assert!( + token.width > 20.0 && token.width < 100.0, + "a non-replaced inline's used width must hug its text and padding: {token:?}" + ); + assert!( + token.height < 40.0, + "height/min-height/max-height do not apply to a non-replaced inline: {token:?}" + ); + assert!( + after.x >= token.x + token.width - 0.01 && (after.y - token.y).abs() < 0.01, + "the following inline must remain on the same line: {token:?} {after:?}" + ); +} + #[test] fn inline_svg_derives_auto_height_from_view_box() { let tree = parse_html(