From 17f5d80f44cdd0fe58f035adc45ddc8f8a02b95b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:20:22 +0000 Subject: [PATCH] fix(xfa): break a long word in linear time Text layout broke a word wider than its line by measuring the whole growing piece after every character, and counting its characters again each time. A long unbroken word in a wide box with a small font made that quadratic. The piece's advance is kept as a running sum of character widths instead. --- crates/xfa/src/tests.rs | 13 +++++++++++++ crates/xfa/src/text.rs | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f..17e286d6c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2657,3 +2657,16 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6); assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } + +/// A word longer than its line is broken by character in work linear in its length. +#[test] +fn a_long_word_breaks_in_linear_work() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 1.0, ..Font::default() }); + let word = "i".repeat(20_000); + text::MEASURED.with(|m| m.set(0)); + let b = text::layout(&text::plain(&word), Rect::new(0.0, 0.0, 1000.0, 0.0), &Para::default(), &face, &|_| String::new()); + let measured = text::MEASURED.with(|m| m.get()); + assert!(b.spans.len() > 2, "{} lines", b.spans.len()); + assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); + assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 1598a4034..fc09d355e 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -123,6 +123,8 @@ impl Face { /// Advance of `s` at this face's size. pub fn width(&self, s: &str) -> f64 { + #[cfg(test)] + MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); let designed = match self.family { Family::Courier => s.chars().count() as f64 * 0.6 * self.size, Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, @@ -132,6 +134,12 @@ impl Face { } } +// Bytes of text measured on this thread, for tests that bound layout work. +#[cfg(test)] +thread_local! { + pub(crate) static MEASURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Default line height: XFA's font leading for the standard faces. pub fn line_height(size: f64) -> f64 { size * 1.15 @@ -233,11 +241,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn lines.push((done, p.h_align)); } if ink > avail - indent(line.first) && line.words.is_empty() { - // A word longer than a line: break it by character. + // A word longer than a line: break it by character. The piece's advance grows by + // each character's (widths add up), so no prefix is measured again. let mut piece = String::new(); + let mut advance = 0.0; + let mut buf = [0u8; 4]; for ch in w.text.chars() { + let ch_width = w.face.width(ch.encode_utf8(&mut buf)); piece.push(ch); - if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 { + advance += ch_width; + if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); @@ -246,6 +259,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); piece.push(ch); + advance = ch_width; } } if !piece.is_empty() {