diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index fd2ca0ed18..7bf815d837 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 e7fde8545a..8741888c4e 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; @@ -762,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle) 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 })) } @@ -842,7 +848,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..69b3a16850 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -398,6 +398,17 @@ 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) { + // 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; + } + } blocks } @@ -452,3 +463,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 442913b61b..745baee688 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -321,6 +321,23 @@ 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. 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) { + let fitted = *factor * own / phrase; + if fitted.is_finite() { + *factor = fitted; + } + } + } +} + 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 +908,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 +928,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..91fe8c2f6e 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -160,6 +160,116 @@ 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"); +} + +/// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and +/// a phrase whose size is NaN, zero, negative or infinite falls back to its words' sizes (#3313). +#[test] +fn only_an_ocr_phrase_with_a_usable_size_is_set_in_one_size() { + let (s, id) = uneven_phrase(); + let block = s.get(id).unwrap().text_blocks(0).remove(0); + assert!(block.invisible); + assert_eq!(block.phrase_size(), Some(block.size)); + let visible = pdfcraft_engine::TextBlock { invisible: false, ..block.clone() }; + assert_eq!(visible.phrase_size(), None, "visible text keeps its runs' sizes"); + for size in [f64::NAN, 0.0, -3.0, f64::INFINITY] { + assert_eq!(pdfcraft_engine::TextBlock { size, ..block.clone() }.phrase_size(), None, "a {size} pt phrase falls back to its words"); + } +} + +/// 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); + } +} + +/// 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}; 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 fc1cadf95f..777d8c40e5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6767,8 +6767,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"