From b56ccad4fd8097bf4a5947a494a5181a69303c2a Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 22:42:12 +0100 Subject: [PATCH] M10: recognize a scanned page when Edit is turned on for it Acrobat's Edit on a scanned page first converts it to editable text and images. In PdfCraft the words had to be recognized through Scan & OCR before Edit text & images could open them. Turning Edit text & images on for a page that is a picture (an image covering at least half of a page with no text) now runs the recognition for that page first: once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog. Pages with text, read-only files and other tools are left alone. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ocr_ui.rs | 40 ++++++++++++++++++++++- crates/ui-egui/tests/ocr.rs | 61 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d8..d8494f382b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit text & images on for a scanned page (an image covering at least half of a page with no text) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with text, read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e213..a8b7ed542e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -373,6 +373,8 @@ pub struct DocView { zoom_anchor: Option<(usize, f32, f32, Vec2)>, /// The page view has been shown, so its scroll offset in egui's memory is this document's. shown: bool, + /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). + pub(crate) auto_ocr_tried: std::collections::HashSet, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. @@ -625,6 +627,7 @@ impl DocView { viewport_screen: Rect::NOTHING, zoom_anchor: None, shown: false, + auto_ocr_tried: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, auto_scroll: Default::default(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..be1f2e9005 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2524,6 +2524,7 @@ impl eframe::App for PdfCraftApp { } } self.poll_export(); + self.auto_ocr_scanned_page(); self.poll_ocr(); self.poll_optimize(); self.poll_action(); diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index a10cb0b0aa..68f525acc9 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -165,7 +165,45 @@ impl PdfCraftApp { OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); - let Some(job) = self.session.ocr_job(id, &pages, settings) else { return }; + self.start_ocr_pages(id, &pages, settings); + } + + /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and + /// Images", the page's words are recognized first, so that they open for editing (Edit text & + /// images groups the recognised words into phrases). Runs once per page, for the page in view + /// while the Edit tool is on, and only for a page that is a picture (an image covering at least + /// half of it, no text of its own: a page with text is skipped by the run). It is one undoable + /// step, "Recognize text", and Undo is not offered again for that page. + pub(crate) fn auto_ocr_scanned_page(&mut self) { + if self.quick_tool != crate::QuickTool::EditText || self.ocr_run.is_some() || self.ocr_batch.is_some() { + return; + } + let Some((vi, id)) = self.active_ids() else { return }; + let page = self.views[vi].current; + if self.views[vi].auto_ocr_tried.contains(&page) { + return; + } + let Some(doc) = self.session.get(id) else { return }; + if doc.read_only_reason.is_some() || !doc.allows_modification() { + return; + } + let Some(info) = doc.info.pages.get(page) else { return }; + let page_area = f64::from(info.width) * f64::from(info.height); + let covered = doc.page_images(page).iter().any(|i| { + let [x0, y0, x1, y1] = i.visible_rect.unwrap_or(i.rect); + page_area > 0.0 && ((x1 - x0) * (y1 - y0)).abs() >= 0.5 * page_area + }); + self.views[vi].auto_ocr_tried.insert(page); + if !covered || !pdfcraft_engine::ocr::available() { + return; + } + let settings = self.ocr_draft.settings(); + self.start_ocr_pages(id, &[page], settings); + } + + /// Recognize `pages` (0-based; empty = all) of document `id` in the background. + fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings) { + let Some(job) = self.session.ocr_job(id, pages, settings) else { return }; let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() })); let p = progress.clone(); let work = move || { diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 20fb73344d..ee1256a498 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -162,3 +162,64 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { h.run_steps(4); assert_eq!(opened(&h).as_deref(), Some("Recognize"), "moving the pointer away keeps the word open"); } + +/// A page of real text, not a picture. +fn text_page() -> Vec { + let s = Session::new(); + s.create_from_text("t", "This page already has its own text").unwrap().to_vec() +} + +fn edit_harness(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +/// Like Acrobat's "Converting scanned page to Editable Text and Images": turning Edit on for a +/// scanned page recognizes its words first (one undoable "Recognize text" step), once. Undoing it +/// is not undone again by the next frame. +#[test] +fn editing_a_scanned_page_recognizes_its_text_first_and_only_once() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing is recognized before Edit"); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); + // Undo takes the text layer away and Edit does not put it back. + h.state_mut().undo(); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "recognized only once per page"); +} + +#[test] +fn editing_a_page_that_has_text_does_not_recognize_anything() { + let mut h = edit_harness(text_page()); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} + +/// Only the Edit tool recognizes: viewing a scan, or any other tool, leaves the file as it is. +#[test] +fn only_the_edit_tool_recognizes_a_scanned_page() { + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + h.run_steps(6); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index eeba76118f..d99296efa2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6516,8 +6516,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"] -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; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +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; 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, once per page per window; 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), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample"