From ac4c61c33c5bbc63303570b336b4e27cfa0cdd83 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 3 Sep 2026 02:54:23 +0000 Subject: [PATCH 1/3] Fix line box content detection and NBSP handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An in-flow inline box with a negative reserved height (e.g. an atomic inline with a large negative margin-top) is still content: the strut must apply and the line must not be treated as empty. Only out-of-flow boxes (infinitely negative extents) are ignored. - Only ordinary spaces (U+0020) are collapsible/hangable whitespace for the purposes of min/max content widths and line-end trimming; no-break spaces are preserved (css-text-3 §4.1.1, §4.1.3). --- parley/src/layout/data.rs | 2 +- parley/src/layout/line_break.rs | 6 ++- parley/src/tests/test_style_metrics.rs | 54 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index 0f4c0be81..87eccff8c 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -371,7 +371,7 @@ impl LayoutData { #[expect(clippy::cast_possible_truncation, reason = "deferred")] pub(crate) fn calculate_content_widths(&self) -> ContentWidths { fn whitespace_advance(atom: Option<(Whitespace, f32)>) -> f32 { - atom.filter(|(whitespace, _)| whitespace.is_space_or_nbsp()) + atom.filter(|(whitespace, _)| *whitespace == Whitespace::Space) .map_or(0.0, |(_, advance)| advance) } diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index 61b68aea8..c0577cbd3 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -306,7 +306,9 @@ impl LineBoxMetrics { } else { (ascent, descent) }; - if ascent + descent > 0. { + // Negative margins can make the reserved space negative; the box is still content. + // Out-of-flow boxes have infinitely negative extents and are not. + if (ascent != 0. || descent != 0.) && ascent.is_finite() && descent.is_finite() { self.has_content = true; } let subtree = self.subtree_mut(aligned_subtree); @@ -941,7 +943,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { let first_character = &atom.characters()[0]; let whitespace = first_character.info.whitespace(); let is_newline = whitespace == Whitespace::Newline; - let is_space = whitespace.is_space_or_nbsp(); + let is_space = whitespace == Whitespace::Space; let boundary = first_character.info.boundary(); let run_box = &run.data.box_metrics; let line_height = run.data.line_height; diff --git a/parley/src/tests/test_style_metrics.rs b/parley/src/tests/test_style_metrics.rs index e29122106..b39c424c3 100644 --- a/parley/src/tests/test_style_metrics.rs +++ b/parley/src/tests/test_style_metrics.rs @@ -317,3 +317,57 @@ fn line_with_only_empty_inline_boxes_is_invisible() { // A non-empty in-flow box does. assert_eq!(line_height(10., InlineBoxKind::InFlow), 30.); } + +/// A box whose reserved space is negative (e.g. an atomic inline with a large negative +/// `margin-top`) is still content: the strut applies and the line keeps its height. +#[test] +fn line_with_negative_height_inline_box_is_not_invisible() { + let mut fcx = create_font_context(); + let mut lcx: LayoutContext = LayoutContext::new(); + let root = root_style(); + let mut builder = lcx.tree_builder(&mut fcx, 1., false, &root); + builder.push_inline_box(InlineBox { + id: 0, + kind: InlineBoxKind::InFlow, + index: 0, + width: 10., + height: -300., + baseline: Some(-305.), + vertical_align: VerticalAlign::BASELINE, + }); + let (mut layout, _) = builder.build(); + layout.break_all_lines(None); + let line = layout.lines().next().unwrap(); + assert_eq!(line.metrics().line_height, 30.); + let inline_box = line + .items() + .find_map(|item| match item { + crate::PositionedLayoutItem::InlineBox(inline_box) => Some(inline_box), + _ => None, + }) + .unwrap(); + // The box's own baseline coincides with the line's baseline. + assert_eq!( + inline_box.y + inline_box.baseline.unwrap(), + line.metrics().baseline + ); +} + +/// No-break spaces are neither collapsible nor hangable (css-text-3 §4.1.1, §4.1.3), so a +/// line of only NBSPs keeps its width and never wraps at its own max-content width. +#[test] +fn no_break_spaces_are_not_hanging_whitespace() { + let mut fcx = create_font_context(); + let mut lcx: LayoutContext = LayoutContext::new(); + let root = root_style(); + let mut builder = lcx.tree_builder(&mut fcx, 1., false, &root); + builder.push_text("\u{a0}\u{a0}\u{a0}"); + let (mut layout, _) = builder.build(); + let content_widths = layout.calculate_content_widths(); + assert!(content_widths.max > 0.); + assert_eq!(content_widths.min, content_widths.max); + layout.break_all_lines(Some(content_widths.max)); + assert_eq!(layout.len(), 1); + let line = layout.lines().next().unwrap(); + assert_eq!(line.metrics().advance, content_widths.max); +} From cb3018a6e40cabffd7857df56030077bdb19e996 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 3 Sep 2026 03:10:33 +0000 Subject: [PATCH 2/3] Don't round inline box ascent and descent separately when quantizing Inline box extents are exact sizes supplied by the caller; rounding the ascent and descent independently could reserve one more pixel than the box's height, producing an off-by-one line height (WPT floats-141). --- parley/src/layout/line_break.rs | 8 +++----- parley/src/tests/test_style_metrics.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index c0577cbd3..cb2b2aa2c 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -301,11 +301,9 @@ impl LineBoxMetrics { descent: f32, quantize: bool, ) { - let (ascent, descent) = if quantize { - (ascent.round(), descent.round()) - } else { - (ascent, descent) - }; + // Inline box extents are exact box sizes supplied by the caller, not font metrics; + // rounding them would change the space the box reserves relative to its height. + let _ = quantize; // Negative margins can make the reserved space negative; the box is still content. // Out-of-flow boxes have infinitely negative extents and are not. if (ascent != 0. || descent != 0.) && ascent.is_finite() && descent.is_finite() { diff --git a/parley/src/tests/test_style_metrics.rs b/parley/src/tests/test_style_metrics.rs index b39c424c3..38cefa904 100644 --- a/parley/src/tests/test_style_metrics.rs +++ b/parley/src/tests/test_style_metrics.rs @@ -318,6 +318,31 @@ fn line_with_only_empty_inline_boxes_is_invisible() { assert_eq!(line_height(10., InlineBoxKind::InFlow), 30.); } +/// With quantization, an inline box's fractional height must not be rounded up twice (once for +/// the ascent and once for the descent): the line reserves exactly the box's height. +#[test] +fn quantized_inline_box_extents_are_not_rounded_separately() { + let mut fcx = create_font_context(); + let mut lcx: LayoutContext = LayoutContext::new(); + let root = root_style(); + let mut builder = lcx.tree_builder(&mut fcx, 1., true, &root); + // Baseline splits the box into 40.5 above and 40.5 below the baseline; rounding each + // separately would reserve 82px for an 81px box. + builder.push_inline_box(InlineBox { + id: 0, + kind: InlineBoxKind::InFlow, + index: 0, + width: 10., + height: 81., + baseline: Some(40.5), + vertical_align: VerticalAlign::BASELINE, + }); + let (mut layout, _) = builder.build(); + layout.break_all_lines(None); + let line = layout.lines().next().unwrap(); + assert_eq!(line.metrics().line_height, 81.); +} + /// A box whose reserved space is negative (e.g. an atomic inline with a large negative /// `margin-top`) is still content: the strut applies and the line keeps its height. #[test] From 62c35980d58afa6e13fc1caa148fa346c086f530 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 3 Sep 2026 03:24:13 +0000 Subject: [PATCH 3/3] Keep treating NBSP as trailing space at line-break time Only the content-width calculation should stop trimming NBSP; changing the line-break-time handling regresses line-break: anywhere tests. --- parley/src/layout/line_break.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index cb2b2aa2c..48741c9d0 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -941,7 +941,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { let first_character = &atom.characters()[0]; let whitespace = first_character.info.whitespace(); let is_newline = whitespace == Whitespace::Newline; - let is_space = whitespace == Whitespace::Space; + let is_space = whitespace.is_space_or_nbsp(); let boundary = first_character.info.boundary(); let run_box = &run.data.box_metrics; let line_height = run.data.line_height;