From 4bd1e72788b8d0d60e9a60936cd270f3e847f058 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:13:15 +0300 Subject: [PATCH 1/3] Refuse an OCR batch whose inputs would write one output file The ocr_recognize_files tool wrote every input's searchable copy to the same name in the output folder. Two inputs with one file name (a/scan.pdf and b/scan.pdf) therefore wrote the same output: the second replaced the first while both results reported success. Reproduced on the unchanged tree with the OCR models installed: both results pointed at out/scan.pdf, and the folder held one file. The whole batch is now refused before anything happens. The output folder is not created, nothing is read, and the OCR engines are not loaded. The error names the colliding file names and says what to do. Names are compared the way the default filesystem compares them: without regard to case on Windows and macOS, exactly elsewhere, and on Windows a trailing dot or space is not part of the name. The compared name is the one the output is written under, after the separator replacement the writer already applies. A request without collisions runs as before. Not changed here: the batch dialog in crates/ui-egui (ocr_ui.rs) writes its outputs the same way and needs its own change. Checked: the three ocr_recognize_files tests in crates/automation/tests/automation.rs and the unit test shared_output_names_follow_the_default_filesystem, run with the models installed and without them. Each pinned line was reverted in turn and the test that pins it failed; the file was restored after each step. --- crates/automation/src/a11y.rs | 61 +++++++++++++++++++++++- crates/automation/tests/it/automation.rs | 54 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 21bde353e..833e5de9f 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -601,11 +601,22 @@ impl Automation { for p in &paths { self.resolve_for(p, crate::file_access::Access::Read, true)?; } + // Two inputs that would write the same output file overwrite each other while both report + // success, so the whole batch is refused here: before the folder is created, anything is + // read, or the OCR engines load. + let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); + let shared = shared_file_names(&targets); + if !shared.is_empty() { + return Err(ToolError::InvalidArgs(format!( + "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", + shared.join(", ") + ))); + } std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; let mut out = Vec::new(); for p in 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 name = input_file_name(p); 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| { pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) @@ -650,6 +661,36 @@ impl Automation { } } +/// The file name an input is written under: its own file name, or `document.pdf` when it has none. +fn input_file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) +} + +/// The output file names that two or more of `targets` share, each spelling listed once, in input +/// order. Names are compared the way the default filesystem compares them: without regard to case +/// on Windows and macOS and exactly elsewhere; on Windows a trailing dot or space is not part of +/// the name (`scan.pdf.` is `scan.pdf`). The folder is the same for every target, so only the +/// names are compared. +fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { + let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); + for target in targets { + let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let key = if cfg!(windows) { + name.trim_end_matches(['.', ' ']).to_lowercase() + } else if cfg!(target_os = "macos") { + name.to_lowercase() + } else { + name.clone() + }; + let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); + *count += 1; + if !spellings.contains(&name) { + spellings.push(name); + } + } + groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() +} + fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { @@ -1042,3 +1083,21 @@ mod review_html_tests { assert!(!html.contains("