M11: tests for packing in other one-component spaces, mixed Group 3 and Flate after resampling

Mutation testing found three behaviours no test held:
- eight-bit black-and-white in calibrated grey, an ICC profile of one
  component or a separation is packed, each keeping its colour space,
  and calibrated RGB of only 0 and 1 isn't (taking every array colour
  space for an indexed one passed every test);
- mixed Group 3 (/K 1, with end-of-line codes) is stored again as
  Group 4 (taking /K above 0 for Group 4 passed);
- a resampled Group 4 image of vertical stripes is stored as 1-bit Flate,
  which is smaller (ruling Flate out for any CCITT image passed).

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 MartensandClaude Opus 5.5 committed 2026-10-29 20:48:03 +01:00
1 parent 450c327c6b
commit cb1e573c08
1 file changed
+120 -32
+120 -32
View File
@@ -918,6 +918,83 @@ fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() {
assert!(render(&doc) == before, "the page looks the same");
}
/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated
/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated
/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is.
#[test]
fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() {
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| {
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);
doc.add(Object::Stream(Stream::flate(d, data)))
};
let mut white_point = Dict::new();
white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)]));
let mut profile = Dict::new();
profile.set(b"N".to_vec(), Object::Int(1));
let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new())));
let mut tint = Dict::new();
tint.set(b"FunctionType".to_vec(), Object::Int(2));
tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()]));
tint.set(b"N".to_vec(), Object::Int(1));
let spaces = [
Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]),
Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]),
Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]),
];
let grey = samples_of(&text, w, h, [&[0], &[255]], 8);
let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs));
let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]);
let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb);
let names = ["A", "B", "C", "D"];
// 200 × 80 pt is 432 ppi: below where monochrome downsampling starts.
let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect();
page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::<Vec<_>>(), &content);
let untouched = stream(&doc, cal_rgb).raw;
// Colour and grey settings that leave images alone, so only the packing acts.
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, cs) in packed.iter().zip(&spaces) {
let s = stream(&doc, *r);
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}");
}
assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB");
}
/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image
/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing.
#[test]
fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() {
let (w, h) = (1200u32, 480u32);
let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize];
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(-1));
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap())));
// 144 × 57.6 pt is 600 ppi.
page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 1, "{r:?}");
let s = stream(&doc, img);
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..])));
}
/// 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
@@ -1375,7 +1452,7 @@ fn monochrome_images_keep_what_they_should() {
/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first,
/// with ITU-T T.4's terminating codes for the few lengths used here.
fn group_3(rows: &[&[u32]]) -> Vec<u8> {
fn group_3(rows: &[&[u32]], mixed: bool) -> Vec<u8> {
let code = |white: bool, run: u32| match (white, run) {
(true, 0) => "00110101",
(true, 8) => "10011",
@@ -1386,12 +1463,20 @@ fn group_3(rows: &[&[u32]]) -> Vec<u8> {
(false, 32) => "000001101010",
_ => panic!("no code here for a run of {run}"),
};
let bits: String = rows.iter().flat_map(|row| row.iter().enumerate().map(|(i, run)| code(i % 2 == 0, *run))).collect();
let mut bits = String::new();
for row in rows {
// Mixed coding (/K above 0): an end-of-line code, then a 1 bit, the row one-dimensional.
if mixed {
bits.push_str("0000000000011");
}
row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run)));
}
bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect()
}
/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the
/// pixel on the page); with Retain it stays Group 3.
/// pixel on the page); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with
/// end-of-line codes) alike.
#[test]
fn group_3_fax_images_become_group_4() {
let (w, h) = (64u32, 64u32);
@@ -1411,38 +1496,41 @@ fn group_3_fax_images_become_group_4() {
}
bits.extend(packed);
}
let g3 = group_3(&runs);
let p = pdfcraft_filters::CcittParams { k: 0, columns: w.into(), ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "the Group 3 data is the picture");
for k in [0, 1] {
let g3 = group_3(&runs, k > 0);
let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line: k > 0, ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}: the Group 3 data is the picture");
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()));
parms.set(b"Rows".to_vec(), Object::Int(h.into()));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q");
let before = render(&doc);
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(k));
parms.set(b"EndOfLine".to_vec(), Object::Bool(k > 0));
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
parms.set(b"Rows".to_vec(), Object::Int(h.into()));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q");
let before = render(&doc);
let mut kept = doc.clone();
optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
assert_eq!(stream(&kept, fax).raw[..], g3[..], "Retain keeps Group 3");
let mut kept = doc.clone();
optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}: Retain keeps Group 3");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "{r:?}");
let s = stream(&doc, fax);
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "Group 4");
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "the same picture");
assert!(render(&doc) == before, "the page looks the same");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}: {r:?}");
let s = stream(&doc, fax);
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}: Group 4");
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}: the same picture");
assert!(render(&doc) == before, "/K {k}: the page looks the same");
}
}
#[test]