From b0b4deeee5000b7ec9ff995e7ddf4c2bfe5d54bc Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:34:21 +0000 Subject: [PATCH] Match forced line break geometry --- crates/obscura-render/src/dom.rs | 115 +++++++++++++++++++++++++++-- crates/obscura-render/src/style.rs | 9 +-- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 1dd1631..ce9484e 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -6033,7 +6033,12 @@ fn build_flex_grid_children( /// wrapping plain text with no inline styling. fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap) -> bool { let Some(node) = tree.get_node(id) else { return false }; - if node.as_element().is_none() { + let Some(element) = node.as_element() else { + return false; + }; + // BR is boxless-looking but semantically contributes a mandatory break. + // Flattening it as an empty wrapper deletes the break entirely. + if element.local.as_ref() == "br" { return false; } let Some(style) = styles.get(&id) else { return false }; @@ -7498,15 +7503,16 @@ fn build( } } - // A forced break that reaches the general box builder sits between block - // or atomic siblings, so it cannot be folded into a shaped inline run. - // Give that sentinel the inherited used line-height instead of the UA - // style's zero placeholder. This is the block/flex analogue of BRFrame's - // empty-line contribution; break tags inside text and mixed block runs - // are handled by the inline collector below. + // Outside a foldable inline run, BR is a zero-inline-size line + // participant. The mixed-inline fallback adds a separate anonymous + // full-width breaker after this mapped marker; keeping that control box + // anonymous prevents its layout surrogate from leaking into CSSOM/paint. if _name.local.as_ref() == "br" { let height = crate::inline::used_line_height(style).max(0.0); + taffy_style.size.width = taffy::style::Dimension::length(0.0); taffy_style.size.height = taffy::style::Dimension::length(height); + taffy_style.flex_grow = 0.0; + taffy_style.flex_shrink = 0.0; let leaf = taffy_tree.new_leaf(taffy_style).ok()?; id_map.insert(leaf, id); return Some(leaf); @@ -8322,7 +8328,53 @@ fn build_mixed_block( before_pending = false; } for &rc in run { - atoms.extend(build_any(tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles)); + let is_forced_break = tree + .get_node(rc) + .is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "br") + }); + if is_forced_break { + // A BR participates in the current line with zero + // inline size, then forces the following content onto + // a new line. A single 100%-wide, line-height-tall flex + // item creates a phantom third line for A
B. Split + // those responsibilities: the mapped marker supplies + // the current line strut, while an anonymous 100% + // breaker may occupy its own zero-height flex line. + atoms.extend(build_any( + tree, + rc, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + )); + let breaker_style = taffy::Style { + flex_grow: 0.0, + flex_shrink: 0.0, + flex_basis: taffy::style::Dimension::percent(1.0), + size: taffy::Size { + width: taffy::style::Dimension::percent(1.0), + height: taffy::style::Dimension::length(0.0), + }, + ..Default::default() + }; + atoms.push(taffy_tree.new_leaf(breaker_style).ok()?); + } else { + atoms.extend(build_any( + tree, + rc, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + )); + } } if join_after { atoms.extend(after_leaves.iter().copied()); @@ -11086,6 +11138,53 @@ mod tests { ); } + #[test] + fn forced_break_in_pseudo_joined_inline_run_has_no_phantom_line() { + let tree = parse_html( + r#" +

first
second

+

A

B

"#, + ); + let laid = layout_dom(&tree, (800.0, 400.0)); + let heading = tree.get_element_by_id("heading").unwrap(); + let heading_break = tree.get_element_by_id("heading-break").unwrap(); + let consecutive = tree.get_element_by_id("consecutive").unwrap(); + let break_style = &laid.styles[&heading_break]; + + assert_eq!(break_style.display, crate::Display::Inline); + assert_eq!(break_style.width, crate::Dimension::Auto); + assert_eq!(break_style.height, crate::Dimension::Auto); + assert!( + (laid.rects[&heading].height - 120.0).abs() < 0.01, + "A
B plus an inline pseudo must form two 60px lines: host={:?}, br={:?}, pseudo={}, run_ifc={}", + laid.rects[&heading], + laid.rects.get(&heading_break), + laid.styles[&heading].after_pseudo.is_some(), + laid.run_ifc_items + .get(&heading) + .map_or(0, |items| items.len()) + ); + assert_eq!( + laid.rects + .get(&heading_break) + .map_or(0.0, |rect| rect.width), + 0.0, + "the mapped BR marker must not expose the anonymous breaker width" + ); + assert!( + (laid.rects[&consecutive].height - 60.0).abs() < 0.01, + "A

B must form three 20px lines: {:?}", + laid.rects[&consecutive] + ); + } + #[test] fn generated_inline_boxes_apply_sizes_only_after_display_blockification() { let tree = parse_html( diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index c7aed87..c07bfe7 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -39,7 +39,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle { "span" | "a" | "b" | "i" | "strong" | "em" | "font" | "code" | "small" | "sub" | "sup" | "mark" | "abbr" | "cite" | "var" | "dfn" | "kbd" | "samp" | "q" | "time" | "s" | "u" | "del" | "ins" | "tt" | "big" - | "bdi" | "bdo" | "wbr" | "data" | "output" | "label" | "ruby" | "rt" + | "bdi" | "bdo" | "br" | "wbr" | "data" | "output" | "label" | "ruby" | "rt" | "rp" => Display::Inline, "tr" => Display::Flex, _ => Display::Block, @@ -67,13 +67,6 @@ pub fn ua_style(tag: &str) -> LayoutStyle { // not lay out as a real box (news CDNs put dimensions on ``, // which otherwise paints an empty box the size of the image). style.display = crate::Display::None; - } else if tag == "br" { - // Keep forced breaks as full-width sentinels in the general - // flex/block fallback. The builder replaces the zero height with the - // inherited used line-height; pure and mixed inline runs fold the tag - // into the shaped line stream instead. - style.width = crate::Dimension::Percent(1.0); - style.height = crate::Dimension::Px(0.0); } else if tag == "pre" { // HTML's UA sheet preserves source whitespace in preformatted blocks. style.white_space = Some(crate::WhiteSpace::Pre);