From 5e38c787362be7d00f13e2b95fed366f6050a886 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 3 Sep 2026 12:19:56 +0000 Subject: [PATCH 1/7] parley_bench: add long single-line benchmarks (#765) Adds long-single-line benchmarks to measure cases where performance is `O(line length)` These have been used to test https://github.com/linebender/parley/pull/762 **LLM Contributions**: Generated with Fable 5.1 Low **Changelog**: None --- parley_bench/benches/main.rs | 3 ++- parley_bench/src/benches.rs | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/parley_bench/benches/main.rs b/parley_bench/benches/main.rs index afcfc5275..83f00eaac 100644 --- a/parley_bench/benches/main.rs +++ b/parley_bench/benches/main.rs @@ -5,7 +5,7 @@ use tango_bench::tango_benchmarks; -use parley_bench::benches::{defaults, repeated_justification, spacing, styled}; +use parley_bench::benches::{defaults, long_line, repeated_justification, spacing, styled}; use parley_bench::fontique_benches::system_fonts_init; tango_benchmarks!( @@ -13,5 +13,6 @@ tango_benchmarks!( styled(), spacing(), repeated_justification(), + long_line(), system_fonts_init() ); diff --git a/parley_bench/src/benches.rs b/parley_bench/src/benches.rs index d1c4485f1..8d392ede4 100644 --- a/parley_bench/src/benches.rs +++ b/parley_bench/src/benches.rs @@ -211,3 +211,51 @@ pub fn styled() -> Vec { }) .collect() } + +/// Benchmark for a single very long line (no wrapping) with and without justification. +/// +/// This exercises per-line work that scales with line length. +pub fn long_line() -> Vec { + const DISPLAY_SCALE: f32 = 1.0; + const QUANTIZE: bool = true; + const REPEAT: usize = 4; + + fn layout_long_line(text: &str, max_advance: Option, alignment: Alignment) { + with_contexts(|font_cx, layout_cx| { + let mut builder = layout_cx.ranged_builder(font_cx, text, DISPLAY_SCALE, QUANTIZE); + builder.push_default(FontFamily::from(FONT_FAMILY_LIST)); + + let mut layout: Layout = builder.build(text); + layout.break_all_lines(max_advance); + layout.align(alignment, AlignmentOptions::default()); + + black_box(layout); + }); + } + + let samples = get_samples(); + + samples + .iter() + .filter(|sample| sample.modification == "4 paragraph") + .flat_map(|sample| { + let text: &'static str = Box::leak( + sample + .text + .replace('\n', " ") + .repeat(REPEAT) + .into_boxed_str(), + ); + [ + benchmark_fn(format!("Long Line - {}", sample.name), move |b| { + b.iter(move || layout_long_line(text, None, Alignment::Start)) + }), + benchmark_fn(format!("Long Line Justify - {}", sample.name), move |b| { + // Justification requires a finite `max_advance`; use one wide enough that the + // text still lays out as a single line. + b.iter(move || layout_long_line(text, Some(1.0e7), Alignment::Justify)) + }), + ] + }) + .collect() +} From 9375a07ae5fa25a065bb2822dca182641eceb5bd Mon Sep 17 00:00:00 2001 From: Tom Churchman Date: Thu, 3 Sep 2026 12:54:34 +0000 Subject: [PATCH 2/7] Test: justification with consecutive interior spaces (#759) LLM Contributions: Mostly generated. Follow-up to https://github.com/linebender/parley/pull/738#discussion_r3841103178. This is also what Gecko and Blink do (with whitespace collapsing off). Compare: https://developer.mozilla.org/en-US/play?id=gilITaZ%2B0hCJVuNWyCoME9xqzkpiKGs0u2alPNKuvKXYzUEm2XX1efjm7j5qJPFZdiEZU%2FYl7eSQxZlN. **Changelog: None** --- .../justify_consecutive_spaces-0.png | Bin 0 -> 2629 bytes parley_tests/tests/basic.rs | 34 ++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 parley_tests/snapshots/justify_consecutive_spaces-0.png diff --git a/parley_tests/snapshots/justify_consecutive_spaces-0.png b/parley_tests/snapshots/justify_consecutive_spaces-0.png new file mode 100644 index 0000000000000000000000000000000000000000..b3718abdeb200f8c8ae64158f4408c0db06d7e6f GIT binary patch literal 2629 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 literal 0 HcmV?d00001 diff --git a/parley_tests/tests/basic.rs b/parley_tests/tests/basic.rs index b28f52466..c15df2002 100644 --- a/parley_tests/tests/basic.rs +++ b/parley_tests/tests/basic.rs @@ -597,6 +597,40 @@ fn justify_with_overflowing_trailing_space() { ); } +#[test] +fn justify_consecutive_spaces() { + let mut env = TestEnv::new(test_name!(), None); + + // Each space is a separate justification opportunity, including when they are consecutive + // (without whitespace collapsing). The second paragraph here has a run of three consecutive + // spaces. + let text = "Foo bar baz supercalifragilistic.\n\nFoo bar baz supercalifragilistic."; + let builder = env.ranged_builder(text); + let mut layout = builder.build(text); + layout.break_all_lines(Some(150.0)); + layout.align(Alignment::Justify, AlignmentOptions::default()); + + env.check_layout_snapshot(&layout); + + // Check that the second paragraph's interior spaces on the first line all stretch by the same + // amount. + let space_advances: Vec<_> = layout + .get(3) + .unwrap() + .runs() + .flat_map(|run| run.clusters()) + .filter(|cluster| cluster.is_space_or_nbsp()) + .map(|cluster| cluster.advance()) + .collect(); + assert_eq!(space_advances.len(), 5, "four spaces plus one trailing"); + assert!( + space_advances[..4] + .iter() + .all(|advance| (advance - space_advances[0]).abs() < 0.001), + "The interior spaces should stretch equally" + ); +} + #[test] fn content_widths() { let mut env = TestEnv::new(test_name!(), None); From ab28fe31e25d8056624b402a33b21df79b27fc76 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Thu, 13 Aug 2026 20:16:06 +0000 Subject: [PATCH 3/7] Fix max-content width of inline boxes following a mandatory break --- parley/src/layout/data.rs | 30 +++++++++---- ...ntent_width-inline_boxes_after_newline.png | Bin 0 -> 185 bytes parley_tests/tests/issues.rs | 41 ++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 parley_tests/snapshots/inline_boxes_after_newline_max_content_width-inline_boxes_after_newline.png diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index f87c33a8b..daeb7ea56 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -357,6 +357,7 @@ impl LayoutData { } for atom in slice.atoms_start() { let character = &atom.characters()[0]; + let whitespace = character.info.whitespace(); let boundary = character.info.boundary(); let style = &self.styles[character.style_index as usize]; let prev_text_wrap_mode = text_wrap_mode; @@ -369,16 +370,27 @@ impl LayoutData { let trailing_whitespace = whitespace_advance(prev_atom); min_width = min_width.max(running_min_width - trailing_whitespace); running_min_width = 0.0; - if boundary == Boundary::Mandatory { - max_width = max_width.max(running_max_width - trailing_whitespace); - running_max_width = 0.0; - } } - let advance = spacing.atom_advance(&atom); - running_min_width += advance; - running_max_width += advance; - if !is_rtl { - prev_atom = Some((character.info.whitespace(), advance)); + if whitespace == Whitespace::Newline { + // Mirror the line breaker: a newline ends the line immediately + // after itself (any content that follows it — including inline + // boxes — belongs to the next line) and does not contribute to + // line width. + let trailing_whitespace = whitespace_advance(prev_atom); + min_width = min_width.max(running_min_width - trailing_whitespace); + max_width = max_width.max(running_max_width - trailing_whitespace); + running_min_width = 0.0; + running_max_width = 0.0; + if !is_rtl { + prev_atom = None; + } + } else { + let advance = spacing.atom_advance(&atom); + running_min_width += advance; + running_max_width += advance; + if !is_rtl { + prev_atom = Some((whitespace, advance)); + } } } let trailing_whitespace = whitespace_advance(prev_atom); diff --git a/parley_tests/snapshots/inline_boxes_after_newline_max_content_width-inline_boxes_after_newline.png b/parley_tests/snapshots/inline_boxes_after_newline_max_content_width-inline_boxes_after_newline.png new file mode 100644 index 0000000000000000000000000000000000000000..b9829cd9b74e413ee25f8021142942347aa37a0d GIT binary patch literal 185 zcmeAS@N?(olHy`uVBq!ia0y~yV2lB>BUzY%WJ6P<1dtL9@Ck8UcH%z}eEs_M_3PIn zA|hN|AT76kpKb$E@t!V@Ar-gY-rC5^pvc2=F#n)`_k(}l3d-9(dQ74Z9$Efd#dDI1 z=iKgX4` +/// to wrap inside a shrink-to-fit container even though they fit on one line. +#[test] +fn inline_boxes_after_newline_max_content_width() { + let mut env = TestEnv::new(test_name!(), None); + + // A newline followed by three inline boxes separated by spaces. + let text = "\n "; + let mut builder = env.ranged_builder(text); + for (id, index) in [(0_u64, 1_usize), (1, 2), (2, 3)] { + builder.push_inline_box(InlineBox { + id, + kind: InlineBoxKind::InFlow, + index, + width: 100.0, + height: 30.0, + baseline: None, + }); + } + let mut layout = builder.build(text); + + let content_widths = layout.calculate_content_widths(); + + layout.break_all_lines(Some(content_widths.max)); + layout.align(Alignment::Start, AlignmentOptions::default()); + assert!( + layout.width() <= content_widths.max, + "Layout should never be wider than the max content width (width: {}, max: {})", + layout.width(), + content_widths.max + ); + env.with_name("inline_boxes_after_newline") + .check_layout_snapshot(&layout); +} From b5223d1ebdcb8b2332796b46164caffe2bd90f6a Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Thu, 13 Aug 2026 20:32:59 +0000 Subject: [PATCH 4/7] Shrink regression test snapshot to a square --- ...content_width-inline_boxes_after_newline.png | Bin 185 -> 151 bytes parley_tests/tests/issues.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/parley_tests/snapshots/inline_boxes_after_newline_max_content_width-inline_boxes_after_newline.png b/parley_tests/snapshots/inline_boxes_after_newline_max_content_width-inline_boxes_after_newline.png index b9829cd9b74e413ee25f8021142942347aa37a0d..af29cc52673bd9ffdb7ac8919669f5349418fead 100644 GIT binary patch delta 124 zcmdnVIGs_kGr-TCmrII^fq{Y7)59eQNJjy2BnvZ;^byjVKT%O7$kx-vF{I+w+iMGX z85DRJ9K$r$aJ;c+S|runIHCG-*Ja=CCDXfOP5w5{i@Y*NIZAKtvdmd_7r(FHd)>Ec aCu<;!(sU`l-*G?#89ZJ6T-G@yGywpw!zr5p delta 158 zcmbQvxRX(_Gr-TCmrII^fq{Y7)59f*fq^jw$c|)T29ga;jS>?TRZ8MLT^vIyZoR#= zk(WV{hvi`YLI3Ut|GX8Hw|n%ML?1k|{I`ndBo)uO-P;~Mf`NO*n`8b&<{mwfB`l5s lzH6?l761MuTM4Y~_9?E@D_nZj8-Z3bc)I$ztaD0e0sy8WIu`%{ diff --git a/parley_tests/tests/issues.rs b/parley_tests/tests/issues.rs index 74a44237d..999125c48 100644 --- a/parley_tests/tests/issues.rs +++ b/parley_tests/tests/issues.rs @@ -193,7 +193,7 @@ fn inline_boxes_after_newline_max_content_width() { id, kind: InlineBoxKind::InFlow, index, - width: 100.0, + width: 14.0, height: 30.0, baseline: None, }); From f3f31b560793f376c9f97709e61bc05043715f2d Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 13 Aug 2026 21:43:33 +0100 Subject: [PATCH 5/7] Improve comment Signed-off-by: Nico Burns --- parley/src/layout/data.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index daeb7ea56..d00f4158f 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -371,11 +371,16 @@ impl LayoutData { min_width = min_width.max(running_min_width - trailing_whitespace); running_min_width = 0.0; } + + // Handle `Whitespace::Newline` rather than relying on `Boundary::Mandatory`, + // because `Boundary::Mandatory` is only set on the character *following* a break, + // at which point it is too late too handle inline boxes between the line break + // and the following character. + // + // This function doesn't have special handling for CRLF because two linebreaks + // immediately following each are equivalent to one linebreak for the purpose of + // width calculation. if whitespace == Whitespace::Newline { - // Mirror the line breaker: a newline ends the line immediately - // after itself (any content that follows it — including inline - // boxes — belongs to the next line) and does not contribute to - // line width. let trailing_whitespace = whitespace_advance(prev_atom); min_width = min_width.max(running_min_width - trailing_whitespace); max_width = max_width.max(running_max_width - trailing_whitespace); From 9f725ed6b3ea69533ba1b4f28cf3f869a2a5932c Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 18 Aug 2026 09:06:04 +0100 Subject: [PATCH 6/7] Update parley_tests/tests/issues.rs Co-authored-by: Daniel McNab <36049421+DJMcNab@users.noreply.github.com> --- parley_tests/tests/issues.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/parley_tests/tests/issues.rs b/parley_tests/tests/issues.rs index 999125c48..089b7195a 100644 --- a/parley_tests/tests/issues.rs +++ b/parley_tests/tests/issues.rs @@ -175,12 +175,6 @@ fn issue_748_language_subtags() { /// Test that inline boxes directly following a mandatory line break all /// contribute to the max content width of the line they end up on. -/// -/// The mandatory break used to be detected via the boundary flag on the text -/// atom *after* the break, so inline boxes between the newline and that atom -/// were attributed to the line *before* the break, under-reporting the max -/// content width. In Blitz this caused inline-block links following a `
` -/// to wrap inside a shrink-to-fit container even though they fit on one line. #[test] fn inline_boxes_after_newline_max_content_width() { let mut env = TestEnv::new(test_name!(), None); From 45feeb2ed8d1e2d21bc0a05b7659bba65971b9d7 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Thu, 3 Sep 2026 14:20:57 +0100 Subject: [PATCH 7/7] Apply typo fixes from review Signed-off-by: Nico Burns --- parley/src/layout/data.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index d00f4158f..4f1218d05 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -374,12 +374,12 @@ impl LayoutData { // Handle `Whitespace::Newline` rather than relying on `Boundary::Mandatory`, // because `Boundary::Mandatory` is only set on the character *following* a break, - // at which point it is too late too handle inline boxes between the line break + // at which point it is too late to handle inline boxes between the line break // and the following character. // // This function doesn't have special handling for CRLF because two linebreaks - // immediately following each are equivalent to one linebreak for the purpose of - // width calculation. + // immediately following each other are equivalent to one linebreak for the purpose + // of width calculation. if whitespace == Whitespace::Newline { let trailing_whitespace = whitespace_advance(prev_atom); min_width = min_width.max(running_min_width - trailing_whitespace);