diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 48fe956f4f..912fa873f4 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -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 { + (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. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index d27d23e0b5..b1111366f2 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -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) -> 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. diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 788499fafe..de7c47cd81 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -398,6 +398,11 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { 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) -> Option { + let mut sizes: Vec = 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) } +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7a9204a8c0..cc32894a54 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -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, 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); + } + } +} + fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, 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 }; diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 2d358e327e..fa33a84858 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -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::>(), ["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> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f2833de356..0a13a78309 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -1345,6 +1345,49 @@ C", &picked assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "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(); diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 1946519203..ebcd923c6a 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -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(), diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 426058f7b7..d5776e0646 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7582b3f6bc..8d4960599a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"