M11: Export ▸ Images reads byte-aligned Group 3 whichever way its fill falls

With /EncodedByteAlign and end-of-line codes, the fill may start each
code on a byte or end it on one (T.4's fill, as TIFF writes it); the
second was misread, and the image refused as short. With end-of-line
codes the decoder now reads unaligned, as the codes mark the rows either
way (probed black-box for /K 0, 1 and 2); codes laid out so under
/EndOfLine false are still misread. The Group 3 test covers both
layouts, /EncodedByteAlign given by reference, and a /BlackIs1 that
inverts the picture given by reference (with_direct_parms resolves both
before the decoder reads them).

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 2c7bbbae05
commit e8f3c6bc3e
2 changed files with 69 additions and 27 deletions
+30 -18
View File
@@ -196,25 +196,37 @@ fn ccitt(doc: &Document, s: &Stream, data: &[u8], p: Dict) -> Result<Vec<u8>, St
// As many rows as the image has, whatever /Rows says: one below /Height would cut the picture
// short, and rows past /Height aren't drawn.
let rows = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0);
let settings = hayro_ccitt::DecodeSettings {
columns,
rows,
end_of_block: flag(b"EndOfBlock", true),
end_of_line: flag(b"EndOfLine", false),
rows_are_byte_aligned: flag(b"EncodedByteAlign", false),
encoding: match k {
..0 => hayro_ccitt::EncodingMode::Group4,
0 => hayro_ccitt::EncodingMode::Group3_1D,
k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) },
},
invert_black: false,
};
let stride = (columns as usize).div_ceil(8);
let mut out =
Rows { out: Vec::new(), row: vec![0; stride], x: 0, columns: columns as usize, rows: rows as usize, black_is_1: flag(b"BlackIs1", false) };
// Rows decoded before damaged data are kept; too few rows are refused by the caller.
let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings));
Ok(out.out)
let decode = |aligned: bool| {
let settings = hayro_ccitt::DecodeSettings {
columns,
rows,
end_of_block: flag(b"EndOfBlock", true),
end_of_line: flag(b"EndOfLine", false),
rows_are_byte_aligned: aligned,
encoding: match k {
..0 => hayro_ccitt::EncodingMode::Group4,
0 => hayro_ccitt::EncodingMode::Group3_1D,
k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) },
},
invert_black: false,
};
let mut out = Rows {
out: Vec::new(),
row: vec![0; stride],
x: 0,
columns: columns as usize,
rows: rows as usize,
black_is_1: flag(b"BlackIs1", false),
};
// Rows decoded before damaged data are kept; too few rows are refused by the caller.
let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings));
out.out
};
// Byte-aligned rows with end-of-line codes may have the fill end each code on a byte (T.4's
// fill, as TIFF writes it) rather than start it on one; read unaligned, the codes mark the rows
// either way. Codes laid out so under `/EndOfLine false` are misread.
Ok(decode(flag(b"EncodedByteAlign", false) && !flag(b"EndOfLine", false)))
}
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
+39 -9
View File
@@ -1666,9 +1666,9 @@ fn ccitt_fax_images_export_as_their_picture() {
/// 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> {
/// `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: &[&[u32]], eol: bool, tag: bool, fill: Fill) -> Vec<u8> {
let code = |white: bool, run: u32| match (white, run) {
(true, 16) => "101010",
(true, 32) => "00011011",
@@ -1678,7 +1678,9 @@ fn group_3(rows: &[&[u32]], eol: bool, tag: bool, align: bool) -> Vec<u8> {
};
let mut bits = String::new();
for row in rows {
while align && !bits.len().is_multiple_of(8) {
// 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 {
@@ -1692,6 +1694,15 @@ fn group_3(rows: &[&[u32]], eol: bool, tag: bool, align: bool) -> Vec<u8> {
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,
}
/// What Export ▸ Images claims for CCITT and for parameters given by reference, each checked
/// against the same picture stored another way: Group 3 one-dimensional data (`/K 0`) exports as
/// its picture; a CCITT soft mask and a CCITT stencil `/Mask` export as the same masks in Flate;
@@ -1742,16 +1753,35 @@ fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() {
// 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)] {
// Byte-aligned with end-of-line codes either way: each code starting on a byte, or ending on
// one (T.4's fill, as TIFF writes it). /EncodedByteAlign is given by reference once, and so is
// a /BlackIs1 that inverts the picture.
let inverted: Vec<bool> = picture.iter().map(|white| !white).collect();
for (k, eol, fill, align_by_reference, black_is_1) in [
(0, false, Fill::None, false, false),
(0, true, Fill::None, false, false),
(0, false, Fill::RowStart, true, false),
(0, true, Fill::RowStart, false, false),
(0, true, Fill::CodeEnd, false, false),
(1, true, Fill::None, false, false),
(1, false, Fill::None, false, false),
(1, true, Fill::CodeEnd, false, false),
(0, false, Fill::None, false, true),
] {
let mut d = image(&[&grey(1)[..], &fax(k)].concat());
let align = Object::Bool(fill != Fill::None);
let align = if align_by_reference { Object::Ref(doc.add(align)) } else { align };
let inverts = Object::Ref(doc.add(Object::Bool(black_is_1)));
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));
parms.set(b"EncodedByteAlign".to_vec(), align);
parms.set(b"BlackIs1".to_vec(), inverts);
}
doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, align))));
doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, fill))));
let case = format!("/K {k}, EndOfLine {eol}, fill {fill:?}, BlackIs1 {black_is_1}");
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}");
assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped);
assert_eq!(exported_white(&out.images[0].data), if black_is_1 { inverted.clone() } else { picture.clone() }, "{case}");
}
// A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate.