M11: decode_ccitt: tests for byte-aligned Group 3 read by the codes in its data

The rule came into decode_ccitt with the decoding it moved from Export ▸
Images (#4335's "Export reads byte-aligned Group 3 by the codes in its
data"), where the create test checks it through the export. Here it gets
tests of its own:
- 32 seeded text-like pictures, 8 to 207 samples wide, at k 0 and 1, in
  each layout (no codes, with end_of_line and without; codes, each
  starting on a byte or ending on one, with and without). Each decodes to
  its picture and, a byte short, to fewer rows, not to other rows read
  another way.
- Group 4 rows on bytes keep their aligned read, end_of_line or not.
- The any-input property varies end_of_line and encoded_byte_align too.

With the rule this replaced (rows on bytes read unaligned under
end_of_line, aligned otherwise) the test fails; cargo-mutants leaves no
mutant of the rule's lines alive. The filters README says how rows on
bytes are read.

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 5f3fde33d6
commit f98a5033e2
2 changed files with 139 additions and 3 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ decode_ccitt(data, &params, max_rows) -> Result<Vec<u8>, FilterError> // CCITT
| ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` |
| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
## Tests
+138 -2
View File
@@ -318,6 +318,135 @@ mod tests {
assert_eq!(CcittParams::default().columns, 1728, "Table 11");
}
/// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's
/// terminating codes (ITU-T T.4 Tables 2 and 3). 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). Fill
/// bits (0s) go where `fill` says.
fn group_3(rows: &[Vec<u32>], eol: bool, tag: bool, fill: Fill) -> Vec<u8> {
// The codes for runs 0 to 63, in order.
const WHITE: &str = concat!(
"00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ",
"0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ",
"00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ",
"00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ",
"01011011 01001010 01001011 00110010 00110011 00110100",
);
const BLACK: &str = concat!(
"0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ",
"0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ",
"000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ",
"000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ",
"000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ",
"000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ",
"000001100110 000001100111",
);
let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64");
let mut bits = String::new();
for row in rows {
// The end-of-line code is 12 bits.
let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 };
while fill != Fill::None && !(bits.len() + ends_code).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()
}
/// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a
/// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Fill {
None,
RowStart,
CodeEnd,
}
/// `rows` text-like rows of `columns` samples as runs, white first, from `seed`: long white runs
/// and short black ones; a row may start black.
fn text_like(seed: u64, columns: u32, rows: u32) -> Vec<Vec<u32>> {
let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut random = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % n) as u32
};
(0..rows)
.map(|_| {
let (mut runs, mut left) = (Vec::new(), columns);
while left > 0 {
let run = match runs.len() % 2 {
0 if runs.is_empty() => random(31),
0 => 1 + random(30),
_ => 1 + random(5),
};
runs.push(run.min(left));
left -= run.min(left);
}
runs
})
.collect()
}
/// Rows of runs, white first, packed as `decode_ccitt` gives them (white 1).
fn pack(runs: &[Vec<u32>], columns: u32) -> Vec<u8> {
let stride = (columns as usize).div_ceil(8);
let mut out = vec![0u8; stride * runs.len()];
for (y, row) in runs.iter().enumerate() {
let mut x = 0;
for (i, run) in row.iter().enumerate() {
if i % 2 == 0 {
(x..x + *run as usize).for_each(|x| out[y * stride + x / 8] |= 0x80 >> (x % 8));
}
x += *run as usize;
}
}
out
}
/// Group 3 rows on bytes in each layout, over many pictures, as how a misreading comes out
/// depends on the picture: no codes (as TIFF's Modified Huffman has it), and `end_of_line`
/// without them; codes, each starting on a byte or ending on one, `end_of_line` or not. Each
/// decodes to its picture, and a byte short to fewer rows, not to other rows read another way.
/// Group 4 rows on bytes keep their aligned read, `end_of_line` or not.
#[test]
fn group_3_rows_on_bytes_read_by_the_codes_in_the_data() {
for seed in 0..32u64 {
let (columns, rows) = (8 + (seed as u32 * 37) % 200, 2 + seed as u32 % 20);
let runs = text_like(seed, columns, rows);
let picture = pack(&runs, columns);
for k in [0, 1] {
for (codes, fill, end_of_line) in [
(false, Fill::RowStart, false),
(false, Fill::RowStart, true),
(true, Fill::RowStart, false),
(true, Fill::RowStart, true),
(true, Fill::CodeEnd, false),
(true, Fill::CodeEnd, true),
] {
let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align: true, ..CcittParams::default() };
let mut data = group_3(&runs, codes, k > 0, fill);
let case = format!("seed {seed}, {columns} × {rows}, k {k}, codes {codes}, fill {fill:?}, end_of_line {end_of_line}");
assert_eq!(decode_ccitt(&data, &p, rows).unwrap(), picture, "{case}");
data.pop();
assert!(decode_ccitt(&data, &p, rows).unwrap().len() < picture.len(), "{case}, a byte short");
}
}
}
// White rows, each the one code 1 (vertical, no change) and its fill.
for end_of_line in [false, true] {
let p = CcittParams { k: -1, columns: 64, end_of_line, encoded_byte_align: true, ..CcittParams::default() };
assert_eq!(decode_ccitt(&[0x80; 16], &p, 16).unwrap(), [0xFF; 8 * 16], "Group 4, end_of_line {end_of_line}");
}
}
proptest::proptest! {
/// Any bitmap, of noise or of runs, decodes to its own bits, through an independent decoder
/// and through `decode_ccitt`.
@@ -337,9 +466,16 @@ mod tests {
/// Whatever it is given, it returns.
#[test]
fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..40_000, rows in 0u32..80, k in -2i64..3) {
fn any_input_returns(
data in proptest::collection::vec(0u8..=255, 0..600),
columns in 0u32..40_000,
rows in 0u32..80,
k in -2i64..3,
end_of_line in proptest::bool::ANY,
encoded_byte_align in proptest::bool::ANY,
) {
let _ = encode_g4(&data, columns, rows);
let p = CcittParams { k, columns: i64::from(columns), ..CcittParams::default() };
let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align, ..CcittParams::default() };
if let Ok(out) = decode_ccitt(&data, &p, rows) {
proptest::prop_assert!(out.len() <= (columns as usize).div_ceil(8) * rows as usize);
}