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.
This commit is contained in:
aiongg
2026-10-22 17:22:50 +00:00
parent 72fd2a1b24
commit 17f5d80f44
2 changed files with 29 additions and 2 deletions
+13
View File
@@ -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::<usize>(), 20_000);
assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word");
}
+16 -2
View File
@@ -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<u64> = 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() {