M11: the optimizer reads a catalog /Version given by reference

When the optimizer stores an image as JBIG2 (PDF 1.4), it raises the
catalog's /Version for an older file. It read /Version only when it was
direct: a catalog saying 1.7 by reference looked like no version at all,
and was set to /1.4, lowering the document. It is now resolved first.
Found in pre-push review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
This commit is contained in:
Grzegorz Martens
2026-10-26 13:54:56 +01:00
co-authored by Claude Opus 5.5
parent e76c4f5613
commit a88c8afb73
2 changed files with 29 additions and 3 deletions
+4 -3
View File
@@ -662,12 +662,13 @@ pub(crate) fn run(
}
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
/// or more already, gets `/Version /1.4` in its catalog (ISO 32000-2 §7.7.2). A full save with
/// object streams writes a 1.5 header anyway.
/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2
/// §7.7.2). A full save with object streams writes a 1.5 header anyway.
fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> {
let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..];
let Some(root) = doc.root() else { return Ok(()) };
let catalog = doc.get(root).as_dict().and_then(|c| c.name(b"Version").map(<[u8]>::to_vec));
let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned());
let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?;
}
+25
View File
@@ -522,6 +522,31 @@ fn black_and_white_images_become_jbig2() {
assert!(render(&jbig2) == before, "the page looks the same");
}
/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says
/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one
/// whose catalog says 1.3 by reference gets 1.4.
#[test]
fn a_catalog_version_given_by_reference_is_read() {
for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] {
let mut doc = Document::new_empty();
let (w, h) = (1200, 480);
let text = bilevel(w, h, false);
let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q");
let version = doc.add(Object::name(catalog));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap();
let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
bytes[..8].copy_from_slice(b"%PDF-1.3");
let mut doc = Document::open(Arc::new(bytes)).unwrap();
let r = optimize(&mut doc, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap();
assert_eq!(r.images_recompressed, 1);
let root = doc.root().unwrap();
let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap();
assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference");
}
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();