From a7ffee01b538950f77e6cc8c1c614eb96e7c85a4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:54:11 -0400 Subject: [PATCH] M10: Recognize text in multiple files (ocr.recognize_batch, ocr_recognize_files): searchable copies written to a folder in the background Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/a11y.rs | 35 +++++++++- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 11 ++++ crates/automation/tests/automation.rs | 21 ++++++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/ocr.rs | 37 +++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/files.rs | 8 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/ocr_ui.rs | 92 +++++++++++++++++++++++++++ crates/ui-egui/tests/commands.rs | 14 +++- crates/ui-egui/tests/ocr.rs | 22 +++++++ parity/acrobat-features.toml | 10 +-- 14 files changed, 246 insertions(+), 11 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index adb43203..8254f519 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -136,9 +136,8 @@ impl Automation { } impl Automation { - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - use printcraft_engine::ocr::OcrSettings; - let mut settings = OcrSettings::default(); + fn ocr_settings(&self, a: &Args) -> Result { + let mut settings = printcraft_engine::ocr::OcrSettings::default(); if let Some(d) = a.opt_num("dpi")? { settings.dpi = d.clamp(72.0, 600.0) as f32; } @@ -151,6 +150,36 @@ impl Automation { if let Some(s) = a.opt_bool("skip_text_pages")? { settings.skip_text_pages = s; } + Ok(settings) + } + + pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { + let settings = self.ocr_settings(a)?; + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + let ocr = printcraft_engine::ocr::engine().map_err(failed)?; + let mut out = Vec::new(); + for p in a.strs("paths")? { + let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); + let result = + self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { + printcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) + }); + out.push(match result { + Ok(r) => { + let target = folder.join(&name); + write_atomic(&target, &r.bytes)?; + let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); + json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { + let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; let id = self.doc(a)?.id; let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 03dd3db6..9148be83 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -504,6 +504,7 @@ impl Automation { "accessibility_check" => self.a11y_check(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "ocr_status" => self.ocr_status()?, + "ocr_recognize_files" => self.ocr_recognize_files(&a)?, "accessibility_report" => self.a11y_report(&a)?, "accessibility_fix" => self.a11y_fix(&a)?, "accessibility_figures" => self.accessibility_figures(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d9422b29..ba7c288d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -749,6 +749,17 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone). Returns, per file, the output path, word count and skipped pages, or the error.") + .cmd("ocr.recognize_batch") + .with(schema( + json!({ + "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "folder": { "type": "string" }, + "dpi": { "type": "number", "minimum": 72, "maximum": 600 }, + "language": { "type": "string", "enum": ["en"] }, + }), + &["paths", "folder"], + )), t("ocr_status", "OCR status", "Whether text recognition is available (its models are installed: run `cargo xtask models` or set PRINTCRAFT_MODELS), where it looks for them, and the languages it reads.") .ro() .cmd("ocr.recognize") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index b23f0153..fca02228 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1308,3 +1308,24 @@ fn ocr_tools_make_a_scan_searchable() { assert!(again["pages"][0]["skipped"].is_string(), "{again}"); assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn ocr_recognize_files_writes_searchable_copies() { + let dir = workdir("ocr-files"); + let mut a = auto(&dir); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Batch recognition works" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "in/scan.pdf" })); + let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["in/scan.pdf", "missing.pdf"], "folder": "out" })); + assert!(r["files"][0]["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["files"][1]["error"].is_string(), "{r}"); + let out = ok(&mut a, "doc_open", json!({ "path": "out/scan.pdf" }))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({ "doc": out, "query": "recognition" })); + assert!(found.to_string().contains("\"page\""), "{found}"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 8cdc62c0..ba746456 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -244,7 +244,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Recognize text", items: &[ item("In this file", "scan-text", "ocr.recognize", Ready), - item("In multiple files", "files", "ocr.recognize_batch", Planned("M10")), + item("In multiple files", "files", "ocr.recognize_batch", Ready), item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")), item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0e3ff58c..5970fc99 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -249,6 +249,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Document, "type"), c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"), + c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), c("a11y.report", "Open accessibility report", None, None, Document, "file-text"), c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"), diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 2d0f499d..4c803372 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -173,3 +173,40 @@ impl Session { Ok(found) } } + +/// What [`recognize_file`] did to one file. +#[derive(Clone, Debug, PartialEq)] +pub struct FileResult { + /// The new file (incrementally saved), or the original when nothing was recognised. + pub bytes: Arc>, + pub pages: Vec, +} + +impl FileResult { + pub fn words(&self) -> usize { + self.pages.iter().map(|p| p.words.len()).sum() + } +} + +/// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `progress` +/// works as for [`OcrJob::run`]. +pub fn recognize_file( + name: &str, + bytes: Arc>, + password: Option<&str>, + settings: OcrSettings, + ocr: &Ocr, + progress: impl FnMut(usize, usize) -> bool, +) -> Result { + let mut s = Session::new(); + let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; + if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { + return Err(why); + } + let job = s.ocr_job(id, &[], settings).ok_or("the document could not be read")?; + let pages = job.run(ocr, progress); + if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { + return Ok(FileResult { bytes, pages }); + } + Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 37f03ff3..3c47b8dc 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -224,6 +224,7 @@ impl PrintCraftApp { "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "a11y.check" => self.start_accessibility_check(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), + "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; self.left = crate::LeftPanel::Tool("edit"); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 36c697d6..57af3ea3 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -15,6 +15,8 @@ pub enum FilePurpose { Combine, InsertPages, ReplacePages, + /// Scan & OCR ▸ Recognize text in multiple files. + Ocr, } /// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive). @@ -99,6 +101,11 @@ impl PrintCraftApp { self.pick_files(FilePurpose::Combine, true); } + /// Scan & OCR ▸ Recognize text ▸ In multiple files: ask for the PDFs. + pub fn ocr_files_dialog(&mut self) { + self.pick_files(FilePurpose::Ocr, true); + } + /// Ask for a PDF whose pages to insert after the selection (Organize ▸ Insert from file). pub fn insert_from_file_dialog(&mut self) { if self.active.is_none() { @@ -169,6 +176,7 @@ impl PrintCraftApp { self.start_replace(name, bytes); } } + FilePurpose::Ocr => self.ocr_files(files), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ccbdb483..e2063c2a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -300,6 +300,7 @@ pub struct PrintCraftApp { /// Scan & OCR: the Recognize Text choices, the running job, and (tests) run it inline. pub ocr_draft: ocr_ui::OcrDraft, pub ocr_run: Option, + pub ocr_batch: Option>>, pub ocr_sync: bool, pub a11y: a11y_ui::A11yState, pub a11y_skipped: std::collections::BTreeSet, @@ -438,6 +439,7 @@ impl PrintCraftApp { a11y_options: a11y_ui::A11yOptions::default(), ocr_draft: ocr_ui::OcrDraft::default(), ocr_run: None, + ocr_batch: None, ocr_sync: false, a11y: a11y_ui::A11yState::default(), a11y_skipped: Default::default(), diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 8f000673..b21e7088 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -45,6 +45,14 @@ pub struct OcrProgress { pub cancel: bool, } +/// Recognize text in multiple files: files done, total, and the summary once finished. +#[derive(Default)] +pub struct BatchProgress { + pub done: usize, + pub total: usize, + pub message: Option, +} + pub struct OcrRun { pub doc: DocId, pub progress: Arc>, @@ -173,6 +181,71 @@ impl PrintCraftApp { self.poll_ocr(); } + /// Recognize text in each file, writing the searchable copies into a folder the user picks + /// (the export folder override in tests), under the same names. + pub fn ocr_files(&mut self, files: Vec<(String, Vec)>) { + if self.ocr_batch.is_some() { + self.notify("Text recognition is already running"); + return; + } + let settings = OcrSettings { dpi: self.ocr_draft.dpi as f32, language: self.ocr_draft.language.clone(), ..Default::default() }; + let progress = Arc::new(Mutex::new(BatchProgress { total: files.len(), ..Default::default() })); + #[cfg(not(target_arch = "wasm32"))] + let dir = match &self.export_dir_override { + Some(d) => Some(std::path::PathBuf::from(d)), + None => rfd::FileDialog::new().set_title("Choose a folder for the searchable files").pick_folder(), + }; + #[cfg(not(target_arch = "wasm32"))] + let Some(dir) = dir else { return }; + let p = progress.clone(); + let work = move || { + let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); + match printcraft_engine::ocr::engine() { + Err(e) => failed.push(e), + Ok(ocr) => { + for (i, (name, bytes)) in files.into_iter().enumerate() { + if let Ok(mut s) = p.lock() { + s.done = i; + } + let r = printcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true); + let saved = r.and_then(|r| { + #[cfg(not(target_arch = "wasm32"))] + crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + crate::editing::download(&name, &r.bytes)?; + Ok(r.words()) + }); + match saved { + Ok(n) => { + ok += 1; + words += n; + } + Err(e) => failed.push(format!("{name}: {e}")), + } + } + } + } + let mut msg = format!("Recognized {words} words in {ok} file{}", if ok == 1 { "" } else { "s" }); + if !failed.is_empty() { + msg.push_str(&format!("; failed: {}", failed.join("; "))); + } + if let Ok(mut s) = p.lock() { + s.done = s.total; + s.message = Some(msg); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if self.ocr_sync { + work(); + } else { + std::thread::Builder::new().name("printcraft-ocr-files".into()).spawn(work).ok(); + } + #[cfg(target_arch = "wasm32")] + work(); + self.ocr_batch = Some(progress); + self.poll_ocr(); + } + /// Stop a running recognition (the pages read so far are kept). pub fn cancel_ocr(&mut self) { if let Some(r) = &self.ocr_run @@ -184,6 +257,25 @@ impl PrintCraftApp { /// Show progress; apply the result once the worker is done. pub(crate) fn poll_ocr(&mut self) { + if let Some(b) = self.ocr_batch.clone() { + let msg = b.lock().ok().map(|mut s| s.message.take().ok_or((s.done, s.total))); + match msg { + Some(Ok(m)) => { + self.ocr_batch = None; + self.notify(m); + } + Some(Err((done, total))) => { + let m = format!("Recognizing text… file {} of {}", (done + 1).min(total.max(1)), total.max(1)); + if self.toast.as_ref().is_none_or(|t| t.0 != m) { + self.notify(m); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + } + None => self.ocr_batch = None, + } + } let Some(run) = self.ocr_run.as_ref() else { return }; let (doc, progress) = (run.doc, run.progress.clone()); let Ok(mut s) = progress.lock() else { return }; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index b6f3c191..4e1ea840 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -44,8 +44,18 @@ fn harness() -> Harness<'static, PrintCraftApp> { /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) -const PICKERS: &[&str] = - &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace", "create.clipboard", "a11y.report"]; +const PICKERS: &[&str] = &[ + "file.open", + "page.combine", + "page.insert", + "file.save_as", + "create.file", + "create.images", + "page.replace", + "create.clipboard", + "a11y.report", + "ocr.recognize_batch", +]; #[test] fn every_registered_command_is_implemented() { diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 7decf1e4..bb3ee396 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -41,3 +41,25 @@ fn recognize_text_dialog_adds_searchable_text() { let id = app.active_ids().unwrap().1; assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text")); } + +#[test] +fn recognize_text_in_multiple_files_writes_searchable_copies() { + if !printcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let dir = std::env::temp_dir().join(format!("printcraft-ocr-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = PrintCraftApp::new(); + app.ocr_sync = true; + app.export_dir_override = Some(dir.to_string_lossy().into_owned()); + app.use_files(printcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]); + assert!(app.ocr_batch.is_none(), "finished"); + let toast = app.toast.clone().unwrap().0; + assert!(toast.starts_with("Recognized ") && toast.ends_with("in 2 files"), "{toast}"); + for name in ["one.pdf", "two.pdf"] { + let bytes = std::fs::read(dir.join(name)).unwrap(); + assert!(bytes.len() > scan().len(), "an incremental update was appended"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f6ad4cb4..2a9ddb8d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6339,11 +6339,11 @@ title = "Recognize text in multiple files" area = "I" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["ocr.recognize_batch"] +tools = ["ocr_recognize_files"] +evidence = ["crates/automation/tests/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"] +notes = "Pick PDFs, then an output folder; copies are saved incrementally under the same names, in the background with progress." [[feature]] id = "ocr.ocr-languages"