M11: Export ▸ Images reads an image's /DecodeParms given by reference

Export ▸ Images and Save Image As decoded an image with
Stream::decoded, which reads /DecodeParms only when it is direct. An
image whose PNG predictor came by reference (the dictionary, or an
entry of an array) kept its row tags in the samples, and the picture
came out shifted. The same was true of an indexed image's lookup table
given as a stream, and of a JBIG2 image's /JBIG2Globals stream, which
then couldn't be read, so the image was skipped. All three now decode
through Document::with_direct_parms, as the optimizer does, and a test
exports each. A number given by reference inside a direct /DecodeParms
is still not read here; #4384's with_direct_parms reads those. Found by
CodeRabbit on #4805; the image decode was already this way on main.

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-26 13:03:31 +01:00
co-authored by Claude Opus 5.5
parent 32563ebff7
commit 34c8e57eeb
2 changed files with 80 additions and 8 deletions
+11 -8
View File
@@ -100,13 +100,15 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
}
}
/// The samples of image `s`, decoded: through its filters, or, for a JBIG2 image (its only
/// filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples each
/// starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given
/// by reference, or as an array's entry, read too (a predictor given so would otherwise be lost;
/// a number given by reference inside it still is), or, for a JBIG2 image
/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples
/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
fn decoded(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
match filters(s).as_slice() {
[f] if f == b"JBIG2Decode" => jbig2(doc, s),
_ => s.decoded().map_err(|e| e.to_string()),
_ => doc.with_direct_parms(s).decoded().map_err(|e| e.to_string()),
}
}
@@ -129,7 +131,7 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict {
/// decoding it without them.
fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() {
Some(Object::Stream(g)) => Some(g.decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?),
Some(Object::Stream(g)) => Some(doc.with_direct_parms(g).decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?),
_ => None,
};
let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?;
@@ -498,9 +500,10 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
};
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
Some(Object::Stream(t)) => {
t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
}
Some(Object::Stream(t)) => doc
.with_direct_parms(t)
.decoded_within(MAX_LOOKUP)
.map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?,
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
+69
View File
@@ -1319,6 +1319,75 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() {
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]);
}
/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms`
/// dictionary given by reference, or as an array's entry. So do an indexed image whose lookup
/// table is a stream with its `/DecodeParms` given by reference, and a JBIG2 image whose
/// `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a
/// direct `/DecodeParms`: the row tags stayed in the samples, and the picture came out shifted.
/// (A number given by reference inside a direct `/DecodeParms` is read once
/// `Document::with_direct_parms` reads those too.)
#[test]
fn images_whose_predictor_is_given_by_reference_export_as_their_picture() {
// PNG predictor parameters for `columns` one-byte samples a row.
let parms = |columns: i64| {
let mut p = Dict::new();
p.set(b"Predictor".to_vec(), Object::Int(15));
p.set(b"Colors".to_vec(), Object::Int(1));
p.set(b"BitsPerComponent".to_vec(), Object::Int(8));
p.set(b"Columns".to_vec(), Object::Int(columns));
p
};
// Two rows of four grey samples, each behind a PNG "None" row tag.
let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]);
let grey: Vec<u8> = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect();
for form in ["a reference", "an array of a reference"] {
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(4, 2, "DeviceGray", 8);
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let given = Object::Ref(doc.add(Object::Dict(parms(4))));
let decode_parms = if form == "a reference" { given } else { Object::Array(vec![given]) };
d.set(b"DecodeParms".to_vec(), decode_parms);
Stream::from_raw(d, predicted.clone())
});
assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}");
}
// An indexed image's lookup table, red then blue, as a stream behind a "None" row tag.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut t = Dict::new();
t.set(b"Filter".to_vec(), Object::name("FlateDecode"));
t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6)))));
let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255]))));
let mut d = image_dict(4, 1, "DeviceGray", 8);
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)]));
Stream::from_raw(d, vec![0, 1, 0, 1])
});
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]);
assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference");
// A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder
// ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the
// image is skipped.
let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut g = Dict::new();
g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64)))));
let tagged: Vec<u8> = [0].iter().chain(&segment).copied().collect();
let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged))));
let mut decode_parms = Dict::new();
decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let mut d = image_dict(16, 2, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms));
Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap())
});
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect();
assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference");
}
/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as
/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the