diff --git a/ROADMAP.md b/ROADMAP.md index 79db566006..04e1eef2e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -330,6 +330,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **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-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index c737889023..6b9869cead 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 @@ -51,7 +56,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 } } } @@ -105,7 +110,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 9fe93a0483..bd4830ce7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4360,6 +4360,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/canvas.rs b/crates/ui-egui/src/canvas.rs index 62918fe0a5..7426fe0f33 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -496,6 +496,11 @@ 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, + /// 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. @@ -794,6 +799,8 @@ impl DocView { viewport_screen: Rect::NOTHING, 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/lib.rs b/crates/ui-egui/src/lib.rs index 21aa10d554..9d92a143c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3355,6 +3355,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..a267aa000e 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() } } } @@ -62,9 +62,32 @@ 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>, + /// 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) { @@ -165,7 +188,86 @@ 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, false); + } + + /// 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). 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, 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")) + || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); + // 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 None; + } + let (view, doc) = self.active_ids()?; + let page = self.views[view].current; + if self.views[view].auto_ocr_tried.contains(&page) { + return None; + } + 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 = document.edit_generation(); + if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) { + return None; + } + let info = document.info.pages.get(page)?; + let page_area = f64::from(info.width) * f64::from(info.height); + 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 { + self.views[view].auto_ocr_skipped.insert(page, generation); + return None; + } + Some(ScanToRead { view, doc, page, generation }) + } + + /// 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(); let work = move || { @@ -189,7 +291,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: auto }); self.poll_ocr(); } @@ -313,15 +415,26 @@ impl PdfCraftApp { } } let Some(run) = self.ocr_run.as_ref() else { return }; - let (doc, progress) = (run.doc, run.progress.clone()); + 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 + // 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}"), &[("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 { @@ -331,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) => { @@ -347,6 +471,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 { @@ -367,3 +495,151 @@ 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"); + } + /// 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 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: 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); + + 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")); + } + /// 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"); + } +} diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index 7f2e3bdc53..d7a5501987 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -156,3 +156,195 @@ 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"); +} + +/// 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")); +} + +/// 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_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(); + 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(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"); + // 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: +/// 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")); +} + +/// 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()); + 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 fb02d29a6e..7b86533269 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6604,8 +6604,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -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/ui-egui/tests/it/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/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/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"] +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"