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};