From 7cb8ca3c01bb21e70dfb9137184be1a6cbdc43ad Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:53:26 -0400 Subject: [PATCH] fix(edit): OCR phrase size counts words and keeps widths past 2x (refs #3313) Review follow-ups: - The phrase's median counts each word once: neighbouring words OCR sized alike share a run, so 15, 9 and 9 pt gave 12 pt, not 9. - The styled rewrite's width calibration is not clamped again after the phrase ratio, so a word sized at more than twice the phrase keeps the width the scan shows it at. - The single-style rewrite (a new family or bold) scales the first word's Tz by its own size over the phrase's, so the line is as wide whatever size OCR guessed for that word. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/text/rewrite.rs | 5 +++ crates/edit/src/text_regions/grouping.rs | 8 +++- crates/edit/src/text_regions/styled.rs | 8 +++- crates/engine/tests/it/ocr_edit.rs | 50 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index b1111366f2..0a2941076b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -763,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option) -> Resul if let Some(sc) = style.scale { state.scale = (sc / 100.0).clamp(0.1, 10.0); } + // At the phrase's size, the first word's text matrix still carries its own width: scale it + // back, so the line is as wide as the scan whatever size OCR guessed for that word. + if phrase.is_some() && style.size.is_none() && style.scale.is_none() { + state.scale = (state.scale * old_size / size).clamp(0.1, 10.0); + } Ok((font_name, ParaStyle { state, size, old_size, k })) } diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index de7c47cd81..69b3a16850 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -399,7 +399,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { block.reflow_text = reflow_text(block, lines); } for block in blocks.iter_mut().filter(|block| block.invisible && block.lines.len() > 1) { - if let Some(size) = median_size(block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.size)) { + // Each word counts once: neighbouring words OCR sized alike share a run. + let words = block + .lines + .iter() + .filter_map(|i| lines.get(*i)) + .flat_map(|line| std::iter::repeat_n(line.size, line.text.split_whitespace().count().max(1))); + if let Some(size) = median_size(words) { block.size = size; } } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index cc32894a54..444cfdd22a 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -323,13 +323,17 @@ fn calibration_factors( /// The calibration factors fit each word to its box at its own size (its `Tf` in its text space, /// which a range's size doesn't change). An OCR phrase drawn at one size needs the inverse ratio -/// too, so every word still spans the box the scan shows it in. +/// too, so every word still spans the box the scan shows it in. The ratio is not clamped again: +/// a word OCR sized at three times the phrase's needs three times the calibration. fn fit_to_phrase(calibration: &mut HashMap, lines: &[TextLine], phrase: Option) { let Some(phrase) = phrase else { return }; for (index, factor) in calibration.iter_mut() { let own = lines.get(*index).and_then(|source| Some(source.origin.state.font.as_ref()?.1 * source.origin.k)); if let Some(own) = own.filter(|own| own.is_finite() && *own > 0.0) { - *factor = (*factor * own / phrase).clamp(0.5, 2.0); + let fitted = *factor * own / phrase; + if fitted.is_finite() { + *factor = fitted; + } } } } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index fa33a84858..c0dd805ddd 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -205,6 +205,56 @@ fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() { } } +/// Two neighbouring words OCR sized alike join into one run; the median still counts each word, +/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs. +#[test] +fn words_joined_into_one_run_each_count_toward_the_phrase_size() { + let (s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 15.0), + scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0), + scanned_word("JONES", 82.0, 112.0, 70.0, 9.0), + ]); + let doc = s.get(id).unwrap(); + assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run"); + let size = doc.text_blocks(0)[0].size; + assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}"); +} + +/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan. +#[test] +fn a_word_far_larger_than_its_phrase_keeps_its_width() { + let (mut s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 25.0), + scanned_word("Smith", 49.0, 78.0, 70.0, 10.0), + scanned_word("Example", 82.0, 124.0, 70.0, 10.5), + ]); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines[0].text.trim(), "John"); + assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); +} + +/// With a new font the phrase is one run in its first word's text state. How wide it is must not +/// depend on how far OCR misjudged that word's size: the same boxes give the same line. +#[test] +fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() { + let width = |first: f64| { + let (mut s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, first), + scanned_word("Smith", 49.0, 78.0, 70.0, 11.0), + scanned_word("Example", 82.0, 124.0, 70.0, 11.0), + ]); + let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() }; + // Short enough not to wrap in the phrase's box either way. + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1, "one line: {lines:?}"); + lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min) + }; + let (guessed, right) = (width(15.0), width(11.0)); + assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}"); +} + /// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write. fn stretched_visible() -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};