From 2cbbae458c189a278d58eede9c96bc2291946727 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 18:12:37 +0100 Subject: [PATCH] M11: decode arrays read as numbers however written; Retain keeps a Group 3 halftone - Packing compared an indexed image's decode array, and RGB's pairs, as written: [0 255.0], an entry by reference or a real among RGB's pairs stopped it, though they mean the same as integers. Each entry is resolved and read as a number now, as is_identity_decode and dark_is_one read them; RGB's pair is written back direct. - A test holds that Retain stores nothing again it doesn't resample: a Group 3 halftone above 450 ppi, which isn't averaged down, stays Group 3 byte for byte (the guard's || as && passed every test). From reviews with the pdfcraft-review skill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/optimize/src/images.rs | 15 ++++-- crates/optimize/tests/optimize.rs | 79 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 71fa945da4..a9e87e1afc 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -637,7 +637,13 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve return None; } let cs = d.get(b"ColorSpace")?; - let decode = d.get(b"Decode").map(|v| doc.resolve(v)); + // The decode array's numbers, each given directly or by reference, as integers or reals; `None` + // when it has something else. + let decode = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + None => None, + Some(Object::Array(v)) => Some(v.iter().map(|e| doc.resolve(e).as_f64()).collect::>>()?), + Some(_) => return None, + }; let mut packed = d.clone(); packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); // Components a pixel, bits a component, and a pixel's samples as 1 or 0. @@ -646,7 +652,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; match decode.as_deref() { None => {} - Some(Object::Array(v)) if v[..] == [Object::Int(0), Object::Int((1 << bpc) - 1)] => { + Some([d0, d1]) if *d0 == 0.0 && *d1 == f64::from((1u32 << bpc) - 1) => { packed.remove(b"Decode"); } Some(_) => return None, @@ -664,8 +670,9 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve } match decode.as_deref() { None => {} - Some(Object::Array(v)) if v.len() == 6 && v.chunks(2).all(|pair| Some(pair) == v.get(..2)) => { - packed.set(b"Decode".to_vec(), Object::Array(v.get(..2)?.to_vec())); + Some(v @ [d0, d1, ..]) if v.len() == 6 && v.chunks(2).all(|pair| pair == [*d0, *d1]) => { + let number = |n: f64| if n.fract() == 0.0 { Object::Int(n as i64) } else { Object::Real(n) }; + packed.set(b"Decode".to_vec(), Object::Array(vec![number(*d0), number(*d1)])); } Some(_) => return None, } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 528f43d5fe..28ccca1874 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -995,6 +995,83 @@ fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() { assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..]))); } +/// A decode array is read as numbers, whichever way they are written: an indexed image's default +/// as `[0 255.0]` or with an entry by reference, and RGB's inverting pairs with a real among them, +/// pack as when written as integers. +#[test] +fn decode_arrays_read_as_numbers_however_written() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object, decode: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + d.set(b"Decode".to_vec(), decode); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 255, 255])), + ]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let max = doc.add(Object::Int(255)); + let real = image(&mut doc, &indices, palette.clone(), Object::Array(vec![0.into(), Object::Real(255.0)])); + let referred = image(&mut doc, &indices, palette, Object::Array(vec![0.into(), Object::Ref(max)])); + let rgb = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let pairs = Object::Array(vec![1.into(), 0.into(), 1.into(), 0.into(), Object::Real(1.0), 0.into()]); + let inverted = image(&mut doc, &rgb, Object::name("DeviceRGB"), pairs); + page( + &mut doc, + &[("A", real), ("B", referred), ("C", inverted)], + "q 200 0 0 80 6 690 cm /A Do Q q 200 0 0 80 6 590 cm /B Do Q q 200 0 0 80 6 490 cm /C Do Q", + ); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for r in [real, referred] { + let s = stream(&doc, r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), None), "{r:?}"); + } + let s = stream(&doc, inverted); + let decode = Object::Array(vec![1.into(), 0.into()]); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), Some(&decode))); +} + +/// With Retain, a Group 3 halftone above 450 ppi, which isn't resampled, stays Group 3 byte for +/// byte: Retain stores nothing again that it doesn't resample. +#[test] +fn retain_keeps_a_group_3_halftone_as_it_is() { + let (w, h) = (64u32, 64u32); + let a: Vec = vec![1; 64]; + let b: Vec = std::iter::once(0).chain(std::iter::repeat_n(1, 64)).collect(); + let runs: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &a[..] } else { &b[..] }).collect(); + let g3 = group_3(&runs, false); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(0)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + // 7.68 pt for 64 samples is 600 ppi. + page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); + assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, fax).raw[..], g3[..]); +} + /// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses, /// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 ยง8.6.5.6), from a page's /// resources or a form's own, as grey would then go through DefaultGray, or no longer through @@ -1455,6 +1532,8 @@ fn monochrome_images_keep_what_they_should() { fn group_3(rows: &[&[u32]], mixed: bool) -> Vec { let code = |white: bool, run: u32| match (white, run) { (true, 0) => "00110101", + (true, 1) => "000111", + (false, 1) => "010", (true, 8) => "10011", (true, 16) => "101010", (true, 32) => "00011011",