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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 03:54:11 -04:00
parent b87cf39168
commit a7ffee01b5
14 changed files with 246 additions and 11 deletions

View File

@ -136,9 +136,8 @@ impl Automation {
}
impl Automation {
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
use printcraft_engine::ocr::OcrSettings;
let mut settings = OcrSettings::default();
fn ocr_settings(&self, a: &Args) -> Result<printcraft_engine::ocr::OcrSettings> {
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<Value> {
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<usize> = 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<Value> {
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)?;

View File

@ -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)?,

View File

@ -749,6 +749,17 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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")

View File

@ -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}");
}

View File

@ -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")),
],

View File

@ -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"),

View File

@ -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<Vec<u8>>,
pub pages: Vec<OcrPage>,
}
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<Vec<u8>>,
password: Option<&str>,
settings: OcrSettings,
ocr: &Ocr,
progress: impl FnMut(usize, usize) -> bool,
) -> Result<FileResult, String> {
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 })
}

View File

@ -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");

View File

@ -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),
}
}

View File

@ -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<ocr_ui::OcrRun>,
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
pub ocr_sync: bool,
pub a11y: a11y_ui::A11yState,
pub a11y_skipped: std::collections::BTreeSet<printcraft_engine::a11y::Rule>,
@ -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(),

View File

@ -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<String>,
}
pub struct OcrRun {
pub doc: DocId,
pub progress: Arc<Mutex<OcrProgress>>,
@ -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<u8>)>) {
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 };

View File

@ -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() {

View File

@ -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");
}
}

View File

@ -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"