diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index cd25606b9..26fca1937 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -695,6 +695,79 @@ fn bands_unit() { assert!(w.len() > 2); } +/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines. +fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +#[test] +fn wrap_lines_matches_the_remeasuring_wrapper() { + let texts = [ + "the quick brown fox jumps", + "Supercalifragilistic", + "a b c\n\nverylongwordthatneedsbreaking short\nend ", + "Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM", + "€ ü ß — unicode Ünïcödé text", + "", + " ", + ]; + for text in texts { + for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] { + for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] { + assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}"); + } + } + } +} + +#[test] +fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() { + // One character per line: the old wrapper re-measured and copied the rest of the word for + // each of the 100,000 lines before checking the limit. + let word = "w".repeat(100_000); + let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0); + assert!(lines.len() <= 2002, "{}", lines.len()); + assert!(lines.iter().all(|l| l == "w")); + // A long paragraph in a very wide box is one line. + let para = vec!["word"; 50_000].join(" "); + assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); +} + #[test] fn center_across_spans_following_empty_center_across_cells() { let mut styles = StyleTable::default(); diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs index 6f71699ce..0523aa4ff 100644 --- a/crates/engine/src/cmd/print/text.rs +++ b/crates/engine/src/cmd/print/text.rs @@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet const PAD_PX: f32 = 2.0; +/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph). +const MAX_WRAP_LINES: usize = 2000; + /// Word-wraps `text` to `width` points. +/// +/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each +/// character is measured once: re-measuring the line for every word, or the rest of an +/// over-long word for every piece cut from it, made a long paragraph or word quadratic. pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() }; + let fits = |units: u64| units as f32 * size / 1000.0 <= width; + let space = units_of(" "); let mut out = Vec::new(); for para in text.split('\n') { let mut line = String::new(); + let mut line_units: u64 = 0; for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; + let word_units = units_of(word); + if line.is_empty() { + line.push_str(word); + line_units = word_units; + } else if fits(line_units + space + word_units) { + line.push(' '); + line.push_str(word); + line_units += space + word_units; } else { out.push(std::mem::take(&mut line)); - line = word.to_string(); + line.push_str(word); + line_units = word_units; } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; + // Break a single over-long word by characters: each piece is the longest that fits + // (at least one character). + if !fits(line_units) && line.chars().nth(1).is_some() { + let mut piece = String::new(); + let mut units: u64 = 0; + for c in line.chars() { + let w = u64::from(font.char_width(c)); + if !piece.is_empty() && !fits(units + w) { + out.push(std::mem::take(&mut piece)); + units = 0; + if out.len() > MAX_WRAP_LINES { + return out; + } } + piece.push(c); + units += w; } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); + line = piece; + line_units = units; } - if out.len() > 2000 { + if out.len() > MAX_WRAP_LINES { return out; } }