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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:03 +01:00
co-authored by Claude Opus 5.5
parent 740bb874a4
commit 2cbbae458c
2 changed files with 90 additions and 4 deletions
+11 -4
View File
@@ -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::<Option<Vec<f64>>>()?),
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,
}
+79
View File
@@ -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<u32> = vec![1; 64];
let b: Vec<u32> = 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<u8> {
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",