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 <noreply@anthropic.com>
This commit is contained in:
Tiago KielandClaude Opus 5.5 committed 2026-11-07 13:35:47 -05:00
1 parent 82116a5c7f
commit 7cb8ca3c01
4 files changed
+68 -3

No files matched your search

+5
View File
@@ -763,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option<f64>) -> 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 }))
}
+7 -1
View File
@@ -399,7 +399,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
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;
}
}
+6 -2
View File
@@ -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<usize, f64>, lines: &[TextLine], phrase: Option<f64>) {
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;
}
}
}
}
+50
View File
@@ -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<Vec<u8>> {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};