From b56ccad4fd8097bf4a5947a494a5181a69303c2a Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 22:42:12 +0100 Subject: [PATCH 01/10] 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" From f9c1ddb5f9bf05f4c16c55e888b12d66b0a8c056 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 23:27:04 +0100 Subject: [PATCH 02/10] M10: recognize a scanned page when the Edit tab is opened, not only the Edit text tool The Edit tab opens the Edit panel without picking Edit text & images, so the recognition added in the previous commit did not run for people who just clicked Edit. It now runs whenever Edit is on by the page view's own test: the Edit panel is open, or Edit text & images or Add text is the tool. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 8 ++++++-- crates/ui-egui/tests/ocr.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 68f525acc9..27778957c9 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -171,11 +171,15 @@ impl PdfCraftApp { /// 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 + /// while Edit is on (its tab, or the Edit text & images or Add text tool), 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() { + // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, + // the same test the page view uses for editing content. + let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) + || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); + if !editing || self.ocr_run.is_some() || self.ocr_batch.is_some() { return; } let Some((vi, id)) = self.active_ids() else { return }; diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index ee1256a498..415b3eea27 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -205,6 +205,23 @@ fn editing_a_scanned_page_recognizes_its_text_first_and_only_once() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "recognized only once per page"); } +/// The Edit tab (its tool panel) turns Edit on without picking Edit text & images, and Acrobat's +/// conversion happens there too. +#[test] +fn the_edit_panel_recognizes_a_scanned_page_too() { + 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); + h.state_mut().set_option("tool", "edit").unwrap(); + 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")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 21df6fc30ee3b859ca06c8ccefd5bb48d9505766 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 23:30:34 +0100 Subject: [PATCH 03/10] M10: Edit recognizes a scan that also carries some text of its own Pages with a comment or text box over a scanned picture count as having text, so the recognition run skipped them ("the page already contains text") and Edit did nothing for them. The run started by Edit now also reads the picture of such a page, as Acrobat does, and the words the page's text already has are not added twice. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 5 ++++- crates/ui-egui/tests/ocr.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 27778957c9..3549753f74 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -201,7 +201,10 @@ impl PdfCraftApp { if !covered || !pdfcraft_engine::ocr::available() { return; } - let settings = self.ocr_draft.settings(); + // A picture on a page that has some text of its own (a comment, a text box) is still a + // scan, as in Acrobat; the words the page's text already has are not added twice. + let mut settings = self.ocr_draft.settings(); + settings.skip_text_pages = false; self.start_ocr_pages(id, &[page], settings); } diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 415b3eea27..b53623b6b6 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -222,6 +222,38 @@ fn the_edit_panel_recognizes_a_scanned_page_too() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } +/// A scanned page that also carries some text of its own (a comment or text box over the map) is +/// still a scan: Edit reads the picture, and the page's own words are not added a second time. +#[test] +fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let hybrid = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = + AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(hybrid); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); + let doc = h.state().session.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let text = text.text.unwrap().plain_text().to_lowercase(); + assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}"); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 2f761c852d361ad6b7c5da99ede3fd166d7cd883 Mon Sep 17 00:00:00 2001 From: Philip Date: Tue, 13 Oct 2026 09:39:57 +0100 Subject: [PATCH 04/10] M10: Edit reads only a page with no text of its own Review of #1609: the Edit-triggered recognition also read pages that have real text (a flyer or slide export with a full-page background picture), adding an undo step and marking the file modified just because Edit was turned on. It now runs with skip_text_pages on, so such a page is left alone. A new OcrSettings::native_text_only decides what counts as the page's text: its own content and form fields, not comments and text boxes (which are also not drawn into the picture that is read), so a scan with a few comments on it is still a scan, as in Acrobat. The doc comment now says what the code does. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/engine/src/ocr.rs | 10 +++++-- crates/engine/src/tests.rs | 56 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ocr_ui.rs | 18 +++++++----- crates/ui-egui/tests/ocr.rs | 41 +++++++++++++++++++------- parity/acrobat-features.toml | 2 +- 6 files changed, 108 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4fe4a221e..40d7322d93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,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 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) 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 real text (a flyer with a background picture), 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 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 6bf6dc9c75..1972a42710 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -24,6 +24,11 @@ pub struct OcrSettings { /// text" and skips them). When `false`, such pages are read too (a picture on a page that /// also has text, #1028) and only the words that aren't already there are added. pub skip_text_pages: bool, + /// What counts as the page's text when `skip_text_pages` is on: only its own (page content + /// and form fields). Comments and text boxes are left out, and are not drawn into the picture + /// that is read, so a scan with a few comments on it is still a scan. `false`: everything + /// the page shows. + pub native_text_only: bool, } /// A recognised word whose box is at least this much covered by the page's own text is that @@ -47,7 +52,7 @@ fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 { impl Default for OcrSettings { fn default() -> Self { - OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true } + OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true, native_text_only: false } } } @@ -101,7 +106,8 @@ impl OcrJob { /// Read the pages. `progress(done, total)` is called before each page; returning `false` /// stops (the pages read so far are returned). pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec { - let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() }; + let config = + RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() }; let mut r = PageRenderer::new(self.bytes.clone(), config); let mut out = Vec::new(); let total = self.pages.len(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37cafd440a..25b1eb076b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2670,6 +2670,62 @@ fn recognize_text_reads_a_picture_on_a_page_that_has_text() { assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1)); } +/// Edit reads a scan that only has comments on it, not a page with real text and a picture +/// (`native_text_only`): a text box over the picture is not the page's text, page content is. +#[test] +fn native_text_only_ignores_comments_but_not_page_text() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let comment_only = |s: &mut Session| { + let id = s.open("scan.pdf", None, scan.clone(), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_annot::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: pdfcraft_annot::Style::default_for(&shape), + shape, + contents: "Invoice reference 4711".into(), + author: "Test".into(), + }), + ) + .unwrap(); + id + }; + let native = ocr::OcrSettings { native_text_only: true, ..Default::default() }; + + // A comment over the picture: the default run counts it as text and skips the page, the + // native-text run reads the picture. + let id = comment_only(&mut s); + let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(skipped[0].skipped.is_some(), "a comment is text for the default run"); + let found = s.recognize_text(id, &[], native.clone()).unwrap(); + assert_eq!(found[0].skipped, None, "{:?}", found[0]); + assert!(found[0].text().to_lowercase().contains("brown"), "{:?}", found[0]); + + // Page content over the picture is the page's own text: skipped, whatever the setting. + let id = comment_only(&mut s); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = AddedText { rect: [w * 0.1, h * 0.6, w * 0.9, h * 0.7], text: "Page text".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + let skipped = s.recognize_text(id, &[], native).unwrap(); + assert!(skipped[0].skipped.is_some(), "{:?}", skipped[0]); +} + /// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a /// document-level function), shown with a custom format; qty is validated; a button script. fn scripted_form() -> Vec { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 3549753f74..64d44e714a 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -41,7 +41,7 @@ impl Default for OcrDraft { impl OcrDraft { /// The recognition settings these choices make. pub fn settings(&self) -> OcrSettings { - OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages } + OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages, ..Default::default() } } } @@ -171,9 +171,11 @@ impl PdfCraftApp { /// 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 Edit is on (its tab, or the Edit text & images or Add text tool), 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. + /// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a + /// picture (an image covering at least half of it) with no text of its own: comments and text + /// boxes over it do not count as text, but real page text does, and such a page (a flyer with a + /// background picture) is left alone. It is one undoable step, "Recognize text", and is not + /// run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. @@ -201,10 +203,12 @@ impl PdfCraftApp { if !covered || !pdfcraft_engine::ocr::available() { return; } - // A picture on a page that has some text of its own (a comment, a text box) is still a - // scan, as in Acrobat; the words the page's text already has are not added twice. + // Only a page with no text of its own is read. Comments and text boxes over the picture do + // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but + // a page with real text and a background picture is left alone. let mut settings = self.ocr_draft.settings(); - settings.skip_text_pages = false; + settings.skip_text_pages = true; + settings.native_text_only = true; self.start_ocr_pages(id, &[page], settings); } diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index b53623b6b6..906551554d 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -222,14 +222,38 @@ fn the_edit_panel_recognizes_a_scanned_page_too() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } -/// A scanned page that also carries some text of its own (a comment or text box over the map) is -/// still a scan: Edit reads the picture, and the page's own words are not added a second time. +/// A scan with only a comment (a text box) over it is still a scan: Edit reads the picture. The +/// comment's own words are not read into the layer, and it counts as no page text. #[test] -fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { +fn editing_a_scan_with_a_comment_over_it_recognizes_the_picture() { if !pdfcraft_engine::ocr::available() { eprintln!("skipped: OCR models not installed"); return; } + let with_comment = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_engine::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + let style = pdfcraft_engine::Style::default_for(&shape); + let note = pdfcraft_engine::NewAnnotation { page: 0, shape, style, contents: "Invoice reference 4711".into(), author: "Test".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(with_comment); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + +/// A born-digital page with a full-page background picture and real text (a flyer, a slide +/// export) must not change just because Edit is turned on (maintainer review of #1609). +#[test] +fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { let hybrid = { let mut s = Session::new(); let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); @@ -245,13 +269,10 @@ fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { let mut h = edit_harness(hybrid); let id = h.state().active_ids().unwrap().1; assert!(h.state_mut().execute("edit.edit_text")); - h.run_steps(4); - assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); - let doc = h.state().session.get(id).unwrap(); - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); - let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let text = text.text.unwrap().plain_text().to_lowercase(); - assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}"); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified"); + assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ff9f2b017f..17fa5103bc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6517,7 +6517,7 @@ 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", "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)." +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 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), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" From 703db23de301aa307b55bf235c3bc955462899d8 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 16:46:35 +0100 Subject: [PATCH 05/10] M10: review of #1609: judge a page that is no scan again once the document changes CodeRabbit: Edit marked a page as tried before checking that it was a scan, so a page that was not one when Edit first looked (no picture over it yet) was never looked at again, even after a picture of text was added to it. A page found not to be a scan is now remembered with the document's edit generation and judged again only after the document changes (not every frame: listing a page's images parses it); a page whose recognition has started is still never started again, so Undo sticks. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++++ crates/ui-egui/src/ocr_ui.rs | 13 ++++++++++--- crates/ui-egui/tests/ocr.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3ad4317974..6c6b9fb347 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -476,6 +476,9 @@ pub struct DocView { 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, + /// Pages Edit looked at and found not to be a scan, with the document's edit generation then: + /// they are looked at again only after the document changes (an image added, say). + pub(crate) auto_ocr_skipped: std::collections::HashMap, /// 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. @@ -760,6 +763,7 @@ impl DocView { zoom_anchor: None, shown: false, auto_ocr_tried: Default::default(), + auto_ocr_skipped: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, wheel_boost_x: Default::default(), diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 64d44e714a..0dcd5c9a6b 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -174,8 +174,8 @@ impl PdfCraftApp { /// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a /// picture (an image covering at least half of it) with no text of its own: comments and text /// boxes over it do not count as text, but real page text does, and such a page (a flyer with a - /// background picture) is left alone. It is one undoable step, "Recognize text", and is not - /// run again for that page after Undo. + /// background picture) is left alone, and is judged again only when the document changes. It + /// is one undoable step, "Recognize text", and is not run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. @@ -193,16 +193,23 @@ impl PdfCraftApp { if doc.read_only_reason.is_some() || !doc.allows_modification() { return; } + // A page found not to be a scan is judged again only once the document has changed, not + // every frame (listing its images parses the page). + let generation = doc.edit_generation(); + if self.views[vi].auto_ocr_skipped.get(&page) == Some(&generation) { + 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() { + self.views[vi].auto_ocr_skipped.insert(page, generation); return; } + self.views[vi].auto_ocr_tried.insert(page); // Only a page with no text of its own is read. Comments and text boxes over the picture do // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but // a page with real text and a background picture is left alone. diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 906551554d..5312096345 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -275,6 +275,37 @@ fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); } +/// A page that is not a scan when Edit looks at it is looked at again after the document changes: +/// adding a picture of text that covers it turns it into a scan, and Edit then reads it (and does +/// so once). +#[test] +fn a_page_that_becomes_a_scan_while_edit_is_on_is_recognized() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let png = { + let mut s = Session::new(); + let text = s.create_from_text("t", "Recognize these scanned words").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap() + }; + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut h = edit_harness(blank); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "a blank page is no scan: nothing happened"); + // The same page, now with a picture of text over all of it. + h.state_mut() + .session + .apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: std::sync::Arc::new(png) }) + .unwrap(); + h.run_steps(6); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 8a6d7dede7ff070baeb93210e065a4d4118262c0 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 18:25:31 +0100 Subject: [PATCH 06/10] M10: review of #1609: Edit-triggered recognition says nothing when it reads nothing CodeRabbit: the recognition Edit starts used the same notifications as a run the user asked for, so turning Edit on for a page with text of its own (skipped by the run) showed "Recognizing text..." and then "Recognized 0 words on 0 pages; 1 page already had text". The run Edit starts is now marked automatic: it shows no progress toast, and no completion toast when it added no words. A run the user asks for, and an automatic run that did read words, notify as before. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 19 +++++++++++++------ crates/ui-egui/tests/ocr.rs | 3 +++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 0dcd5c9a6b..34f023d6dd 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -65,6 +65,9 @@ pub struct BatchProgress { pub struct OcrRun { pub doc: DocId, pub progress: Arc>, + /// Started by Edit on a scanned page, not asked for: it shows no progress, and says nothing when + /// it found no words (a page with text of its own is skipped, and Edit must not make noise). + pub automatic: bool, } pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -165,7 +168,7 @@ impl PdfCraftApp { OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); - self.start_ocr_pages(id, &pages, settings); + self.start_ocr_pages(id, &pages, settings, false); } /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and @@ -216,11 +219,11 @@ impl PdfCraftApp { let mut settings = self.ocr_draft.settings(); settings.skip_text_pages = true; settings.native_text_only = true; - self.start_ocr_pages(id, &[page], settings); + self.start_ocr_pages(id, &[page], settings, true); } /// Recognize `pages` (0-based; empty = all) of document `id` in the background. - fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings) { + fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings, automatic: bool) { 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(); @@ -245,7 +248,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] work(); - self.ocr_run = Some(OcrRun { doc: id, progress }); + self.ocr_run = Some(OcrRun { doc: id, progress, automatic }); self.poll_ocr(); } @@ -369,7 +372,7 @@ impl PdfCraftApp { } } let Some(run) = self.ocr_run.as_ref() else { return }; - let (doc, progress) = (run.doc, run.progress.clone()); + let (doc, progress, automatic) = (run.doc, run.progress.clone(), run.automatic); let Ok(mut s) = progress.lock() else { return }; let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( @@ -377,7 +380,7 @@ impl PdfCraftApp { &[("d", &(s.done + 1).min(s.total.max(1)).to_string()), ("t", &s.total.max(1).to_string())], ); drop(s); - if self.toast.as_ref().is_none_or(|t| t.0 != msg) { + if !automatic && self.toast.as_ref().is_none_or(|t| t.0 != msg) { self.notify(msg); } if let Some(ctx) = &self.ctx { @@ -403,6 +406,10 @@ impl PdfCraftApp { { view.document_changed(&info); } + // An automatic run that found nothing (a page that has text of its own) says nothing. + if automatic && words == 0 { + return; + } let words_part = if words == 1 { crate::i18n::fmt(tl!("Recognized 1 word"), &[]) } else { diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 5312096345..86740c4517 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -273,6 +273,9 @@ fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { assert!(h.state().ocr_run.is_none()); assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified"); assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); + // And Edit says nothing about recognizing: no progress, no "0 words ... already had text". + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(!toast.contains("ecogniz") && !toast.contains("already had text"), "{toast:?}"); } /// A page that is not a scan when Edit looks at it is looked at again after the document changes: From 0b3fa7afb5a9770b80024e3db9764adcb3aab887 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 21:26:26 +0100 Subject: [PATCH 07/10] M10: review of #1609: no automatic recognition in Read mode or beneath a dialog CodeRabbit: Read mode leaves the Edit tool picked, so the recognition Edit starts could run in Read mode and change a scanned document; and it could run while a dialog or prompt was open, which PdfCraftApp::modal_open says nothing may act beneath. It now waits for Edit to be on outside Read mode with no modal open, and is tried again once they are gone. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 5 ++++- crates/ui-egui/tests/ocr.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 34f023d6dd..dcd3553756 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -184,7 +184,10 @@ impl PdfCraftApp { // the same test the page view uses for editing content. let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); - if !editing || self.ocr_run.is_some() || self.ocr_batch.is_some() { + // Not in Read mode (which leaves the Edit tool picked but is not editing) and not beneath a + // dialog or prompt: nothing may change the document under those. It is tried again once + // they are gone. + if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() { return; } let Some((vi, id)) = self.active_ids() else { return }; diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 86740c4517..872768c549 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -309,6 +309,33 @@ fn a_page_that_becomes_a_scan_while_edit_is_on_is_recognized() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } +/// Edit changes the document only while the user is editing: not in Read mode (which leaves the Edit +/// tool picked), and not beneath a dialog. The page is recognized once those are gone. +#[test] +fn edit_recognizes_a_scan_only_outside_read_mode_and_beneath_no_dialog() { + 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; + let undo = |h: &Harness<'static, PdfCraftApp>| -> Option { h.state().session.get(id).unwrap().can_undo().map(String::from) }; + // Read mode with the Edit tool still picked. + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(5); + assert_eq!(undo(&h), None, "not in Read mode"); + // A dialog over the window. + h.state_mut().set_option("mode", "edit").unwrap(); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::JsConsole); + h.run_steps(5); + assert_eq!(undo(&h), None, "not beneath a dialog"); + // Both gone: it is read now. + h.state_mut().dialog = None; + h.run_steps(5); + assert_eq!(undo(&h).as_deref(), Some("Recognize text")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 3bdc4ddcc78c3d492973eea6d2b379aa290dcebc Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 21 Oct 2026 20:26:24 +0100 Subject: [PATCH 08/10] M10: review of #1609: test the Edit-triggered recognition rules without OCR models FredApps: CI never fetches the OCR models, so the tests that recognize something skip there and the recognition paths this PR adds went untested in CI. The decision is split out of auto_ocr_scanned_page into scan_to_read, which needs no models (the run itself judges the page's own text), with unit tests that run everywhere: a picture page is read and a text or blank page is not; only Edit being on starts it (its tab counts); Read mode and an open dialog wait; a page already read is not read again and a page found no scan is judged again once the document changes. Two mutants (no Read/modal guard; a no-scan page never judged again) each fail one of them. The tests that recognize words still need the models. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 155 +++++++++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index dcd3553756..039384da80 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -62,6 +62,15 @@ pub struct BatchProgress { pub message: Option, } +/// A page Edit should read now (see [`PdfCraftApp::scan_to_read`]). +struct ScanToRead { + view: usize, + doc: DocId, + page: usize, + /// The document's edit generation when it was judged. + generation: u64, +} + pub struct OcrRun { pub doc: DocId, pub progress: Arc>, @@ -180,6 +189,27 @@ impl PdfCraftApp { /// background picture) is left alone, and is judged again only when the document changes. It /// is one undoable step, "Recognize text", and is not run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { + let Some(scan) = self.scan_to_read() else { return }; + if !pdfcraft_engine::ocr::available() { + self.views[scan.view].auto_ocr_skipped.insert(scan.page, scan.generation); + return; + } + self.views[scan.view].auto_ocr_tried.insert(scan.page); + // Only a page with no text of its own is read. Comments and text boxes over the picture do + // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but + // a page with real text and a background picture is left alone. + let mut settings = self.ocr_draft.settings(); + settings.skip_text_pages = true; + settings.native_text_only = true; + self.start_ocr_pages(scan.doc, &[scan.page], settings, true); + } + + /// The page Edit should read now, if there is one: Edit is on outside Read mode with no dialog + /// open, nothing else is being recognized, the document may be changed, the page in view has + /// not been read before (nor been found no scan since the document last changed), and an image + /// covers at least half of it. Needs no OCR models, so the rules can be tested without them; + /// the page's own text is judged by the run itself. + fn scan_to_read(&mut self) -> Option { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) @@ -188,41 +218,34 @@ impl PdfCraftApp { // dialog or prompt: nothing may change the document under those. It is tried again once // they are gone. if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() { - return; + return None; } - 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 (view, doc) = self.active_ids()?; + let page = self.views[view].current; + if self.views[view].auto_ocr_tried.contains(&page) { + return None; } - let Some(doc) = self.session.get(id) else { return }; - if doc.read_only_reason.is_some() || !doc.allows_modification() { - return; + let document = self.session.get(doc)?; + if document.read_only_reason.is_some() || !document.allows_modification() { + return None; } // A page found not to be a scan is judged again only once the document has changed, not // every frame (listing its images parses the page). - let generation = doc.edit_generation(); - if self.views[vi].auto_ocr_skipped.get(&page) == Some(&generation) { - return; + let generation = document.edit_generation(); + if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) { + return None; } - let Some(info) = doc.info.pages.get(page) else { return }; + let info = document.info.pages.get(page)?; let page_area = f64::from(info.width) * f64::from(info.height); - let covered = doc.page_images(page).iter().any(|i| { + let covered = document.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 }); - if !covered || !pdfcraft_engine::ocr::available() { - self.views[vi].auto_ocr_skipped.insert(page, generation); - return; + if !covered { + self.views[view].auto_ocr_skipped.insert(page, generation); + return None; } - self.views[vi].auto_ocr_tried.insert(page); - // Only a page with no text of its own is read. Comments and text boxes over the picture do - // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but - // a page with real text and a background picture is left alone. - let mut settings = self.ocr_draft.settings(); - settings.skip_text_pages = true; - settings.native_text_only = true; - self.start_ocr_pages(id, &[page], settings, true); + Some(ScanToRead { view, doc, page, generation }) } /// Recognize `pages` (0-based; empty = all) of document `id` in the background. @@ -433,3 +456,87 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Dialog, LeftPanel, Mode, QuickTool}; + use pdfcraft_engine::{Edit, Session}; + + /// A one-page PDF that is one picture covering the page. + fn scan() -> Vec { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + Session::new().create_from_images(&[("scan.png".into(), png.into_inner())]).unwrap().to_vec() + } + + fn app_with(pdf: Vec) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); + app.quick_tool = QuickTool::EditText; + app + } + + fn found(app: &mut PdfCraftApp) -> Option { + app.scan_to_read().map(|s| s.page) + } + + /// These rules need no OCR models (the run itself judges the page's own text), so they are + /// tested everywhere, unlike the tests that recognize something. + #[test] + fn edit_reads_a_page_that_is_a_picture_and_no_other() { + assert_eq!(found(&mut app_with(scan())), Some(0)); + let text = Session::new().create_from_text("t", "Real text, no picture").unwrap().to_vec(); + assert_eq!(found(&mut app_with(text)), None, "a page of text is no scan"); + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + assert_eq!(found(&mut app_with(blank)), None, "nor is a blank page"); + } + + #[test] + fn only_edit_being_on_starts_it() { + let mut app = app_with(scan()); + app.quick_tool = QuickTool::Select; + assert_eq!(found(&mut app), None, "another tool"); + // The Edit tab's panel counts, as for the page view, with no tool picked. + app.left = LeftPanel::Tool("edit"); + app.left_open = true; + assert_eq!(found(&mut app), Some(0), "the Edit panel"); + app.left_open = false; + assert_eq!(found(&mut app), None, "the Edit panel closed"); + } + + #[test] + fn it_waits_for_read_mode_and_dialogs_to_end() { + let mut app = app_with(scan()); + app.mode = Mode::Read; + assert_eq!(found(&mut app), None, "Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + assert_eq!(found(&mut app), None, "beneath a dialog"); + app.dialog = None; + assert_eq!(found(&mut app), Some(0), "tried again once they are gone"); + } + + #[test] + fn a_page_is_read_once_and_a_page_found_no_scan_is_judged_again_after_a_change() { + let mut app = app_with(scan()); + app.views[0].auto_ocr_tried.insert(0); + assert_eq!(found(&mut app), None, "a page already read is not read again (Undo sticks)"); + + // A blank page is no scan; it is judged again only once the document changes. + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut app = app_with(blank); + assert_eq!(found(&mut app), None); + assert!(app.views[0].auto_ocr_skipped.contains_key(&0), "remembered with the document's generation"); + let id = app.active_ids().unwrap().1; + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let add = Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: Arc::new(png.into_inner()) }; + app.session.apply(id, add).unwrap(); + assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan"); + } +} From 90c15aa514872e7dfa3e823326b58fbb9cd9cb19 Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 21 Oct 2026 21:43:58 +0100 Subject: [PATCH 09/10] M10: review of #1609: an automatic run's result waits for Read mode and dialogs CodeRabbit: Edit refuses to start recognition in Read mode or beneath a dialog, but a run started a moment earlier could finish after the user had switched to Read mode or opened a dialog, and poll_ocr applied its result there. The result of a run Edit started is now left until both are gone; a run the user asked for completes as before. The unit test uses a made-up result, so it needs no OCR models, and fails without the check. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 039384da80..186e5753c6 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -400,6 +400,16 @@ impl PdfCraftApp { let Some(run) = self.ocr_run.as_ref() else { return }; let (doc, progress, automatic) = (run.doc, run.progress.clone(), run.automatic); let Ok(mut s) = progress.lock() else { return }; + // The result of a run Edit started waits while Read mode or a dialog is up: nothing may change + // the document beneath them, and the run may have been started before they appeared. A run + // the user asked for completes as before. + if automatic && s.result.is_some() && (self.mode == crate::Mode::Read || self.modal_open()) { + drop(s); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + return; + } let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( tl!("Recognizing text… page {d} of {t}"), @@ -539,4 +549,37 @@ mod tests { app.session.apply(id, add).unwrap(); assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan"); } + /// A finished run Edit started is applied only once Read mode and dialogs are gone; one the user + /// asked for is applied at once. The result is made up, so no OCR models are needed. + #[test] + fn the_result_of_an_automatic_run_waits_for_read_mode_and_dialogs() { + let word = || pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }; + let finished = |app: &PdfCraftApp, automatic: bool| { + let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None }; + let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); + OcrRun { doc: app.active_ids().unwrap().1, progress, automatic } + }; + let label = |app: &PdfCraftApp| app.session.get(app.active_ids().unwrap().1).unwrap().can_undo().map(String::from); + + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, true)); + app.mode = Mode::Read; + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits in Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits beneath a dialog"); + app.dialog = None; + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "taken once they are gone"); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + + // A run the user asked for is not held back. + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, false)); + app.mode = Mode::Read; + app.poll_ocr(); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + } } From 5051a16b5410dd3a6c9bd429d661a446d1e4a1b0 Mon Sep 17 00:00:00 2001 From: Philip Date: Fri, 23 Oct 2026 16:32:11 +0100 Subject: [PATCH 10/10] M10: review of #1609: drop an automatic run's result once the document has changed CodeRabbit: a recognition run reads a snapshot of the document and its result names a page by index. If the document changed while it ran (a page deleted or moved), Session::apply_ocr would put the words on whatever page now has that index. An Edit-started run now remembers the page and the document's edit generation (AutoOcr); when its result arrives after a change it is dropped and the page's "tried" mark is cleared so the page in view is judged again. A run the user asked for is unchanged. The unit test uses a made-up result (no OCR models) and fails without the check. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 72 +++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 186e5753c6..a267aa000e 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -74,9 +74,20 @@ struct ScanToRead { pub struct OcrRun { pub doc: DocId, pub progress: Arc>, - /// Started by Edit on a scanned page, not asked for: it shows no progress, and says nothing when - /// it found no words (a page with text of its own is skipped, and Edit must not make noise). - pub automatic: bool, + /// Set for a run Edit started on a scanned page, not asked for: it shows no progress, says nothing + /// when it found no words (a page with text of its own is skipped, and Edit must not make noise), + /// and its result waits for Read mode and dialogs and is dropped if the document changed. + pub automatic: Option, +} + +/// What an Edit-started run needs to know to refuse a result that no longer fits its document. +#[derive(Clone, Copy, Debug)] +pub struct AutoOcr { + /// The page it reads (0-based), when it was started. + pub page: usize, + /// The document's edit generation when it was started: a result is for those bytes only, and + /// any later change (a page deleted or moved, say) may put a different page at that index. + pub generation: u64, } pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -250,6 +261,12 @@ impl PdfCraftApp { /// Recognize `pages` (0-based; empty = all) of document `id` in the background. fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings, automatic: bool) { + // What an automatic run's result must still fit when it arrives (see `AutoOcr`). + let auto = if automatic { + pages.first().map(|&page| AutoOcr { page, generation: self.session.get(id).map_or(0, |d| d.edit_generation()) }) + } else { + None + }; 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(); @@ -274,7 +291,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] work(); - self.ocr_run = Some(OcrRun { doc: id, progress, automatic }); + self.ocr_run = Some(OcrRun { doc: id, progress, automatic: auto }); self.poll_ocr(); } @@ -398,7 +415,8 @@ impl PdfCraftApp { } } let Some(run) = self.ocr_run.as_ref() else { return }; - let (doc, progress, automatic) = (run.doc, run.progress.clone(), run.automatic); + let (doc, progress, auto) = (run.doc, run.progress.clone(), run.automatic); + let automatic = auto.is_some(); let Ok(mut s) = progress.lock() else { return }; // The result of a run Edit started waits while Read mode or a dialog is up: nothing may change // the document beneath them, and the run may have been started before they appeared. A run @@ -426,6 +444,17 @@ impl PdfCraftApp { }; drop(s); self.ocr_run = None; + // The result is for the bytes the run started from. If the document has changed since (a + // page deleted or moved, say) its page index may now name another page: drop it, and let + // the page in view be judged again. + if let Some(a) = auto + && self.session.get(doc).map(|d| d.edit_generation()) != Some(a.generation) + { + if let Some(view) = self.views.iter_mut().find(|v| v.id == doc) { + view.auto_ocr_tried.remove(&a.page); + } + return; + } let found = match result { Ok(f) => f, Err(e) => { @@ -555,9 +584,10 @@ mod tests { fn the_result_of_an_automatic_run_waits_for_read_mode_and_dialogs() { let word = || pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }; let finished = |app: &PdfCraftApp, automatic: bool| { + let generation = app.session.get(app.active_ids().unwrap().1).unwrap().edit_generation(); let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None }; let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); - OcrRun { doc: app.active_ids().unwrap().1, progress, automatic } + OcrRun { doc: app.active_ids().unwrap().1, progress, automatic: automatic.then_some(AutoOcr { page: 0, generation }) } }; let label = |app: &PdfCraftApp| app.session.get(app.active_ids().unwrap().1).unwrap().can_undo().map(String::from); @@ -582,4 +612,34 @@ mod tests { app.poll_ocr(); assert_eq!(label(&app).as_deref(), Some("Recognize text")); } + /// A run's result is for the document as it was when the run started. Once the document has + /// changed, a result of Edit's is dropped, not applied to whatever page now has its index, and + /// the page may be judged again. + #[test] + fn the_result_of_an_automatic_run_is_dropped_once_the_document_has_changed() { + let mut app = app_with(scan()); + let id = app.active_ids().unwrap().1; + let generation = app.session.get(id).unwrap().edit_generation(); + let page = pdfcraft_engine::ocr::OcrPage { + page: 0, + words: vec![pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }], + already_on_page: 0, + skipped: None, + }; + let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); + app.ocr_run = Some(OcrRun { doc: id, progress, automatic: Some(AutoOcr { page: 0, generation }) }); + app.views[0].auto_ocr_tried.insert(0); + // The document changes while the run is out. + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + app.session + .apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "p.png".into(), bytes: Arc::new(png.into_inner()) }) + .unwrap(); + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "the run is over"); + assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Add image"), "its result was not applied"); + assert!(!app.views[0].auto_ocr_tried.contains(&0), "the page may be judged again"); + } }