From 82eedb66079f7134c6bec409595c8d00028a46cd Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 08:21:14 +0000 Subject: [PATCH] Integration: a retained-version optimize reports the later of header and catalog version (#3227) Object streams raise the header to 1.5 over a lower catalog /Version; pdf_version now reports the version viewers read. --- crates/automation/src/lib.rs | 4 +++- crates/automation/tests/it/automation.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 208370f159..3271b12492 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1297,7 +1297,9 @@ impl Automation { }) .flatten(); let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); - let pdf_version = catalog_version.as_deref().or(header_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); let o = &r.optimize; Ok(json!({ "path": path.to_string_lossy(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7e073b7579..0684e2266c 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5942,6 +5942,26 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream +/// needs is not what viewers read: the header is raised, and the later of the two is reported. +#[test] +fn retained_pdf_version_reports_the_header_raised_for_object_streams() { + let dir = workdir("optimize-retain-version"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.4"))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("cat14.pdf"), &bytes).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "cat14.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "out.pdf", "compatibility": "retain", "object_streams": true })); + let out = std::fs::read(dir.join("out.pdf")).unwrap(); + assert!(out.starts_with(b"%PDF-1.5\n") && out.windows(7).any(|w| w == b"/ObjStm"), "{r}"); + assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); +} + /// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for /// fewer than 250 bytes, before the code width grows. fn lzw_literals(data: &[u8]) -> Vec {