diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 47cf714ec9..decb18d2b7 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024; struct Recipe<'a> { steps: Vec<&'a str>, images: optimize::ImageSettings, + mono: optimize::MonoSettings, ocr: ocr::OcrSettings, } @@ -38,12 +39,17 @@ impl<'a> Recipe<'a> { } } let mut images = optimize::ImageSettings::REDUCE; + let mut mono = optimize::MonoSettings::REDUCE; if let Some(ppi) = args.opt_num("ppi")? { if !(9.0..=1600.0).contains(&ppi) { return Err(Args::wrong("ppi", "9 to 1600")); } images.target_ppi = ppi; images.above_ppi = ppi * 1.5; + // Black-and-white images keep Reduce File Size's higher resolution, at which text + // stays legible, and are never taken below a higher `ppi` asked for. + mono.target_ppi = mono.target_ppi.max(ppi); + mono.above_ppi = mono.target_ppi * 1.5; } if let Some(q) = args.opt_int("quality")? { if !(1..=100).contains(&q) { @@ -54,7 +60,7 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) } fn accepts(&self, path: &Path) -> bool { @@ -88,7 +94,7 @@ impl<'a> Recipe<'a> { } "optimize" => { let id = session.open(name, None, bytes.clone(), None).map_err(failed)?; - let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() }; + let settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() }; let (candidate, _) = session.optimized_bytes(id, &settings, &[])?; session.close(id); if candidate.len() < bytes.len() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 79555f1548..8767b4dc46 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,7 +121,7 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ - t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") + t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, "out_dir": {"type":"string"}, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e68da784d6..d17627a951 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6525,17 +6525,7 @@ fn doc_optimize_averages_black_and_white_scans_down() { std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); - let sizes = |name: &str| -> Vec<(Option, Option)> { - let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); - written - .object_numbers() - .into_iter() - .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { - pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), - _ => None, - }) - .collect() - }; + let sizes = |name: &str| image_sizes(&dir.join(name)); let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}"); assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]); @@ -6549,6 +6539,40 @@ fn doc_optimize_averages_black_and_white_scans_down() { } } +/// The width and height of every image in the PDF at `path`. +fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> { + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect() +} + +/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of +/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever +/// `ppi` said, so `ppi: 600` halved 600 ppi scans. +#[test] +fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() { + let dir = workdir("batch-mono-ppi"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] { + let out = format!("out-{ppi:?}"); + let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out }); + if let Some(ppi) = ppi { + args["ppi"] = json!(ppi); + } + let report = ok(&mut a, "doc_batch", args); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}"); + } +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { diff --git a/docs/cli-batch.md b/docs/cli-batch.md index 0892791194..ad2982bb14 100644 --- a/docs/cli-batch.md +++ b/docs/cli-batch.md @@ -27,9 +27,11 @@ new engine importers become available here automatically. Without `convert`, only `.pdf` inputs are processed. Unsupported extensions are reported as skipped. - `optimize` uses Reduce File Size settings: JPEG quality 60, resampling to 150 ppi - above 225 ppi (black-and-white images stored as CCITT Group 4 when smaller), plus the - existing document cleanup. `--ppi` (9–1600) sets the target - and a threshold of 1.5 times that value; `--quality` (1–100) sets JPEG quality. + above 225 ppi (black-and-white images to 300 ppi above 450 ppi, stored as CCITT + Group 4 when smaller), plus the existing document cleanup. `--ppi` (9–1600) sets the + target and a threshold of 1.5 times that value; black-and-white images go to the + larger of `--ppi` and 300 ppi, so a lower value keeps scanned text legible and a + higher one keeps their resolution. `--quality` (1–100) sets JPEG quality. These settings can lose image detail. A candidate that is not smaller retains the bytes entering that step. This is not an images-only or lossless preset. - `ocr` uses the installed engine and its supported languages. `--dpi` (72–600,