fix(engine): read long underscore runs in one pass

Word assembly checked whether the whole word so far was underscores for
every glyph it added, so a long run of underscores on one line took
quadratic time. The underscore state of the current word is kept as it
grows.
This commit is contained in:
aiongg
2026-10-22 18:02:33 +00:00
parent 467c8556ec
commit f2f86455dd
+36 -5
View File
@@ -289,6 +289,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
.collect()
}
#[cfg(test)]
thread_local! {
/// Characters read to tell underscore runs from other words.
static UNDERSCORE_CHARS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
@@ -296,6 +302,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
let mut last_line = u32::MAX;
// A space glyph ends the word before it.
let mut gap = false;
// Whether the last word is an underscore run (a form's blank), which other glyphs don't join.
let mut last_under = false;
for (i, g) in text.glyphs.iter().enumerate() {
let line = text.line_of.get(i).copied().unwrap_or(0);
if g.text.trim().is_empty() {
@@ -303,10 +311,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
continue;
}
let under = g.text.chars().all(|c| c == '_');
let breaks = match words.last() {
None => true,
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under,
};
#[cfg(test)]
UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len()));
let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under;
gap = false;
last_line = line;
match words.last_mut() {
@@ -317,7 +324,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
r[2] = r[2].max(g.rect[2]);
r[3] = r[3].max(g.rect[3]);
}
_ => words.push((g.text.clone(), g.rect)),
_ => {
words.push((g.text.clone(), g.rect));
last_under = under;
}
}
}
words
@@ -409,6 +419,27 @@ impl Session {
mod review_tests {
use super::*;
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
/// the word so far without reading the word again.
#[test]
fn a_long_underscore_run_is_read_in_one_pass() {
let n = 10_000;
let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph {
text: t.into(),
rect: [i as f32, 0.0, i as f32 + 1.0, 10.0],
quad: [[0.0; 2]; 4],
direction: [1.0, 0.0],
};
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
glyphs.push(glyph(n, "x"));
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs };
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
let words = words_with(&text, |x, y| [x, y]);
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get);
assert!(read <= 4 * n, "{read} characters read for {n} glyphs");
}
#[test]
fn duplicate_field_names_diff_against_first_original() {
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);