M11: tests for the soft mask in "the smaller wins", the byte totals and Group 3's options

- Eight-bit black-and-white text under a noisy soft mask: the grey
  settings win, as they shrink the mask far more than packing saves.
  Counting the packed image without its mask passed every test before.
- The packing test checks the report's byte totals exactly.
- Export reads Group 3 with end-of-line codes, with rows on a byte
  (EncodedByteAlign), with both, and mixed (/K 1, each row tagged
  one-dimensional) with end-of-line codes and without. Decoding /K 1 as
  one-dimensional, or ignoring EncodedByteAlign, now fails a test.
  /EndOfLine is passed to the decoder, but no input here tells it apart:
  the decoder accepts end-of-line codes either way, as ISO 32000-2
  (Table 11) requires.

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:02 +01:00
co-authored by Claude Opus 5.5
parent a4058c9242
commit d2c3bda6b5
2 changed files with 62 additions and 9 deletions
+31 -9
View File
@@ -1664,9 +1664,11 @@ fn ccitt_fax_images_export_as_their_picture() {
assert!(out.images.is_empty() && out.skipped.len() == 1, "columns other than the width: skipped");
}
/// Group 3 data: rows of run lengths, white first, coded one-dimensionally (ITU-T T.4) with no
/// end-of-line codes, from the few codes these runs need.
fn group_3(rows: &[&[u32]]) -> Vec<u8> {
/// Group 3 data: rows of run lengths, white first, coded one-dimensionally (ITU-T T.4), from the
/// few codes these runs need. Each row starts with an end-of-line code with `eol`, then with
/// `tag` a 1 bit (the next row one-dimensional, as `/K` above 0 has it). With `align` each row
/// starts on a byte, its end-of-line code too.
fn group_3(rows: &[&[u32]], eol: bool, tag: bool, align: bool) -> Vec<u8> {
let code = |white: bool, run: u32| match (white, run) {
(true, 16) => "101010",
(true, 32) => "00011011",
@@ -1674,7 +1676,19 @@ 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 {
while align && !bits.len().is_multiple_of(8) {
bits.push('0');
}
if eol {
bits.push_str("000000000001");
}
if tag {
bits.push('1');
}
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()
}
@@ -1726,11 +1740,19 @@ fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() {
};
let grey = |bits: i64| [("BitsPerComponent", Object::Int(bits)), ("ColorSpace", Object::name("DeviceGray"))];
let d = image(&[&grey(1)[..], &fax(0)].concat());
doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows))));
let out = extract_images(&doc, &[0], 0);
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
assert_eq!(exported_white(&out.images[0].data), picture, "Group 3");
// One-dimensional (/K 0) plain, with end-of-line codes, with each row on a byte, and with
// both; mixed (/K 1, each row tagged one-dimensional) with end-of-line codes and without.
for (k, eol, align) in [(0, false, false), (0, true, false), (0, false, true), (0, true, true), (1, true, false), (1, false, false)] {
let mut d = image(&[&grey(1)[..], &fax(k)].concat());
if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") {
parms.set(b"EndOfLine".to_vec(), Object::Bool(eol));
parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(align));
}
doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, align))));
let out = extract_images(&doc, &[0], 0);
assert!(out.skipped.is_empty(), "/K {k}, EndOfLine {eol}, EncodedByteAlign {align}: {:?}", out.skipped);
assert_eq!(exported_white(&out.images[0].data), picture, "/K {k}, EndOfLine {eol}, EncodedByteAlign {align}");
}
// A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate.
let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap();
+31
View File
@@ -671,6 +671,34 @@ fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings(
}
}
/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the
/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it.
/// Here clean text, which packs to well under its JPEG, under a noisy mask the grey settings
/// shrink by far more than the image's saving: the grey settings win.
#[test]
fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() {
let (w, h) = (1200u32, 1500u32);
let mut doc = Document::new_empty();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let noise: Vec<u8> = (0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 24) as u8
})
.collect();
let mask = mono_image(&mut doc, w, h, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]);
let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))];
let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey);
// 288 × 360 pt is 300 ppi, above where the grey settings downsample.
page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q");
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, img);
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(8), Some(&b"DCTDecode"[..])), "{:?}", s.dict);
assert!(stream(&doc, mask).raw.len() < (w * h) as usize / 2, "the mask resampled with it");
}
/// Eight-bit grey images of only black and white (as some scanners and converters write scans)
/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array
/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples
@@ -724,8 +752,11 @@ fn eight_bit_black_and_white_images_become_one_bit() {
assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is");
let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r));
let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum();
let r = optimize(&mut doc, &settings).unwrap();
assert_eq!(r.images_recompressed, 2);
let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum();
assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both");
for i in [plain, inverted] {
let s = stream(&doc, i);
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])));