M11: the batch optimize step's ppi reaches black-and-white images

The batch step took Reduce File Size's monochrome settings unchanged, so
`ppi: 600` (or `--ppi 600`) kept colour and grey images at 600 ppi but
halved 600 ppi black-and-white scans to 300. Black-and-white images now
go to the larger of ppi and 300 ppi: a higher ppi keeps their
resolution, and a lower one doesn't take scanned text below 300. The
doc_batch description and docs/cli-batch.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:03 +01:00
co-authored by Claude Opus 5.5
parent e3b9e175b5
commit 832d74b504
4 changed files with 49 additions and 17 deletions
+8 -2
View File
@@ -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() {
+1 -1
View File
@@ -121,7 +121,7 @@ pub fn tools() -> Vec<ToolDef> {
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"},
+35 -11
View File
@@ -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<i64>, Option<i64>)> {
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<i64>, Option<i64>)> {
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<u8> {
+5 -3
View File
@@ -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,