M11: Export ▸ Images decodes JBIG2 globals within 32 MiB of their own

An image's /JBIG2Globals were decoded with the 1 GiB any stream may decode
to, before the JBIG2 page's size is known, so the page's pixel limit didn't
bound them: a few kilobytes of Flate could make export allocate up to 1 GiB.
They now decode within MAX_GLOBALS (32 MiB); globals past it skip the image,
with the limit in the reason. The test covers one byte over and exactly the
limit, and the image's /DecodeParms direct, by reference and in an array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grzegorz Martens
2026-10-29 22:37:03 +01:00
co-authored by Claude Opus 5.5
parent 71875ce5a8
commit e18d1bfed3
4 changed files with 70 additions and 6 deletions
+3 -2
View File
@@ -27,8 +27,9 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included,
as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more
pixels than an exported image may have, is skipped; such a mask is left out and the image exported opaque, as JPX
and CCITT masks are.
pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the
page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image
exported opaque, as JPX and CCITT masks are.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
+10 -2
View File
@@ -125,15 +125,23 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict {
p.as_dict().cloned().unwrap_or_default()
}
/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder
/// shares between pages, themselves coded, so far less than any stream may decode to
/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image.
const MAX_GLOBALS: usize = 32 << 20;
/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
/// checked against the export's size limit, and within that limit itself: the decoder decodes the
/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
/// decoding it without them.
/// decoding it without them. They are decoded before the page's size is known, so the page's
/// limit can't bound them; they have [`MAX_GLOBALS`] of their own.
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(doc.with_direct_parms(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_within(MAX_GLOBALS).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}"))?;
+55
View File
@@ -1519,3 +1519,58 @@ fn jbig2_images_export_as_their_picture() {
let alpha: Vec<u8> = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect();
assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha);
}
/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have
/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to
/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes
/// in the file. Globals of exactly the limit are read, and the limit is the same through an
/// indirect `/DecodeParms`, in an array or not.
#[test]
fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
const LIMIT: usize = 32 << 20;
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
let jbig2 = 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; 4] } else { [0, 0, 0, 255] }).collect();
// Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a
// decoder skips; its 11-byte header gives the length of the data after it.
let globals = |len: usize| {
let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0];
g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes());
g.extend_from_slice(&[0, 0, 0, 1]);
g.resize(len, 0);
pdfcraft_filters::encode_flate(&g)
};
// The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or
// by reference in an array (2).
let page = |flated: &[u8], form: usize| {
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"));
let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
let parms = match form {
0 => Object::Dict(parms),
1 => Object::Ref(doc.add(Object::Dict(parms))),
_ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]),
};
let mut d = image_dict(16, 2, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
d.set(b"DecodeParms".to_vec(), parms);
Stream::from_raw(d, jbig2.clone())
});
doc
};
let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1));
assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len());
assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit");
for form in 0..3 {
assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}");
let out = extract_images(&page(&over, form), &[0], 0);
assert!(out.images.is_empty(), "globals over the limit, form {form}");
let why = &out.skipped.first().expect("the image is skipped").2;
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
+2 -2
View File
@@ -7164,8 +7164,8 @@ milestone = "M10"
status = "shipped"
commands = ["export.all_images"]
tools = ["doc_export_all_images"]
evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"]
notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."
evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"]
notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, or whose globals decode to more than 32 MiB, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."
[[feature]]
id = "create.export-html"