fix(edit): an edited OCR phrase is drawn at one size (refs #3313)

Recognize text sizes each word from its own box and a guess at how much
of the em its letters fill, so one phrase's words come out several
points apart (9 to 15.6 pt on a LaTeX scan). Editing a phrase drew and
saved every word at its own size.

An invisible block's size is now the median of its words' sizes
(TextBlock::phrase_size). The styled rewrite draws every run at it and
scales each word's width calibration by its own size over the phrase's,
so words keep the width the scan shows them at; the single-style
rewrite (a new family or bold) uses it in place of the first word's
size; and the inline editor lays each word out at it while typing.

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 5644d56c64
commit 82116a5c7f
9 files changed
+145 -12

No files matched your search

+7
View File
@@ -37,6 +37,13 @@ pub struct TextBlock {
}
impl TextBlock {
/// The one size a rewrite and the editor set an OCR phrase in, in place of each word's own
/// (#3313): the block's size, which for invisible text is its words' median. `None` for
/// visible text, whose runs keep their sizes.
pub fn phrase_size(&self) -> Option<f64> {
(self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size)
}
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
/// source run. The texts are aligned word by word, so words left untouched between two edits
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
+5 -4
View File
@@ -749,12 +749,13 @@ struct ParaStyle {
k: f64,
}
/// Spacing and scaling (text state), with the chosen values.
fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> {
/// Spacing and scaling (text state), with the chosen values. `phrase` is the size an OCR phrase
/// is drawn at when none is chosen, in place of its first word's own (#3313).
fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option<f64>) -> Result<(String, ParaStyle), EditError> {
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
let k = o.k.max(1e-6);
// The size in text space: points ÷ the text-to-user scale.
let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1));
let size = style.size.or(phrase).map_or(old_size, |pt| (pt / k).max(0.1));
let mut state = o.state.clone();
if let Some(c) = style.char_spacing {
state.char_spacing = c / k;
@@ -842,7 +843,7 @@ pub(crate) fn rewrite_block_from(
let o = &first.origin;
let last = members.last().copied().unwrap_or(first);
let emit = &last.origin;
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?;
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?;
let source = source_font(doc, &fonts_res, &font_name, first, &flat, style);
let reuse = source.reuse;
// The chosen style also determines the real Japanese fallback outlines and advances.
+16
View File
@@ -398,6 +398,11 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
for block in &mut blocks {
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)) {
block.size = size;
}
}
blocks
}
@@ -452,3 +457,14 @@ fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option<
// measure leaves almost no room for the next word.
(slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1)
}
/// The size an OCR phrase is set in. Recognize text sizes each word from its own box and a guess
/// at how much of the em its letters fill, so one phrase's words come out several points apart
/// (#3313); a rewrite draws the phrase at this one size. `None` when no size is usable.
fn median_size(sizes: impl Iterator<Item = f64>) -> Option<f64> {
let mut sizes: Vec<f64> = sizes.filter(|size| size.is_finite() && *size > 0.0).collect();
sizes.sort_by(f64::total_cmp);
let middle = sizes.len() / 2;
let upper = sizes.get(middle).copied()?;
if sizes.len().is_multiple_of(2) { sizes.get(middle.saturating_sub(1)).map(|lower| (lower + upper) / 2.0) } else { Some(upper) }
}
+17 -2
View File
@@ -321,6 +321,19 @@ fn calibration_factors(
calibration
}
/// 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.
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);
}
}
}
fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result<Vec<&'a RangeStyle>, EditError> {
let mut ranges: Vec<_> = style.ranges.iter().collect();
ranges.sort_by_key(|range| range.range.start);
@@ -891,8 +904,9 @@ pub(crate) fn rewrite_styled_block(
.prepare()?;
let chars = prepared.values;
let substituted = prepared.substituted;
let phrase_size = block.phrase_size();
let size_of = |source: &TextLine, index: usize| {
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
overrides.get(&index).and_then(|r| r.size).or(style.size).or(phrase_size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
@@ -910,7 +924,8 @@ pub(crate) fn rewrite_styled_block(
spaces.borrow_mut().insert(name.to_owned(), w);
w
};
let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
let mut calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
fit_to_phrase(&mut calibration, lines, phrase_size);
let advance = |item: &(char, usize, String)| -> f64 {
let source = lines.get(item.1).unwrap_or(first);
let Some(m) = metrics.get(&item.2) else { return 0.0 };
+45
View File
@@ -160,6 +160,51 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
}
}
/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill,
/// so one phrase's words come out several points apart (#3313). Here the first word is the
/// largest and the middle one the smallest; the phrase is set in 11 pt.
fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) {
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("Example", 82.0, 124.0, 70.0, 11.0),
])
}
/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps
/// the width the scan shows it at (#3313).
#[test]
fn an_edited_ocr_phrase_is_drawn_at_one_size() {
let (mut s, id) = uneven_phrase();
let block = s.get(id).unwrap().text_blocks(0).remove(0);
assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["John", "Example"]);
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
// Each word keeps its width; the rest of the phrase closes up where Smith was.
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect);
assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John");
}
/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from
/// the first run: that is the phrase's size too, not the first word's guess.
#[test]
fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() {
let (mut s, id) = uneven_phrase();
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert!(!lines.is_empty());
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
}
/// 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};
+43
View File
@@ -1345,6 +1345,49 @@ C", &picked
assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::<String>(), "Text preview");
}
/// Typing in a recognised phrase whose words OCR sized at 15, 9 and 11 pt lays every word out
/// at the phrase's one size, as the engine redraws it (#3313); each word used its own.
#[test]
fn typing_in_an_ocr_phrase_lays_every_word_out_at_one_size() {
use pdfcraft_engine::ocr::PlacedWord;
// A word box as OCR draws it around capitals: the cap height of a `size` pt word.
let word = |text: &str, x0: f64, x1: f64, size: f64| PlacedWord {
text: text.into(),
origin: [x0, 70.0],
across: [x1 - x0, 0.0],
up: [0.0, 0.718 * size],
confidence: None,
source: "ocrs".into(),
};
let mut app = crate::PdfCraftApp::new();
let png = pdfcraft_engine::export::encode_png(200, 100, &[235u8; 200 * 100 * 4]).unwrap();
let bytes = app.session.create_from_images(&[("scan.png".into(), png)]).unwrap();
app.open_bytes("scan.pdf", None, bytes.as_ref().clone()).unwrap();
let id = app.views[0].id;
let words = vec![word("JOHN", 20.0, 45.0, 15.0), word("SMITH", 49.0, 78.0, 9.0), word("EXAMPLE", 82.0, 124.0, 11.0)];
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
let doc = app.session.get(id).unwrap();
let block = doc.text_blocks(0).remove(0);
assert!(block.invisible && block.lines.len() == 3, "one phrase of three recognised words: {block:?}");
let (_, mut ed) = preview_app();
ed.text = format!("{}S", block.editor_text);
ed.original = block.editor_text.clone();
ed.look = look_of(&block);
ed.look0 = look_of(&block);
ed.source = Some((block, doc.text_lines(0)));
let ctx = egui::Context::default();
let mut sizes = Vec::new();
ctx.run_ui(egui::RawInput::default(), |ui| {
let job = editor_job(ui, &ed, &ed.text, 1.0, f32::INFINITY);
sizes = job.sections.iter().map(|section| section.format.font_id.size).collect();
})
.textures_delta
.clear();
assert!(sizes.len() >= 3, "a section for each word: {sizes:?}");
assert!(sizes.iter().all(|size| (size - 11.0).abs() < 0.05), "every word at the phrase's 11 pt: {sizes:?}");
}
#[test]
fn a_refused_paragraph_stays_open_with_its_text() {
let (mut app, mut ed) = preview_app();
@@ -126,10 +126,14 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
if centered {
job.halign = egui::Align::Center;
}
// An OCR phrase is set in one size, as the engine redraws it: each word's own is a guess
// from its box. Its words still keep their widths.
let phrase = block.phrase_size();
for (range, index) in block.edited_spans(text) {
let Some(line) = lines.get(index) else { continue };
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
look.size = line.size;
let base = phrase.unwrap_or(line.size);
let mut look = source_look(&line.base_font, base, line.color, line.bold, line.italic);
look.size = base;
if ed.look.family != ed.look0.family || ed.family_chosen {
look.family = ed.look.family;
}
@@ -153,7 +157,7 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
font.family = FontFamily::Name(name.clone().into());
}
let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x);
let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale;
let target_width = ((line.rect[2] - line.rect[0]) * look.size / base.max(0.01)) as f32 * scale;
let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 };
job.append(
text.get(range).unwrap_or_default(),
+3 -1
View File
@@ -89,7 +89,9 @@ On a scanned page, recognised text (Scan & OCR) is an invisible layer over the p
text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each
scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or
the one chosen) and first covers the old words with the paper colour sampled around them. Each
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping.
OCR sizes each word from its own box, so a phrase's words can come out several points apart: the
editor and the rewrite set the whole phrase in one size, the median of its words' sizes. The
cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing
invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a
+2 -2
View File
@@ -6755,8 +6755,8 @@ milestone = "M10"
status = "partial"
commands = ["edit.edit_text", "ocr.correct"]
tools = ["text_edit", "ocr_recognize"]
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_edited_ocr_phrase_is_drawn_at_one_size", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping, and is set in one size, the median of its words' (OCR sizes each word from its own box, several points apart), both while typing and when applied (#3313). Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
[[feature]]
id = "ocr.ocr-downsample"