mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 14:08:23 -06:00
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:
co-authored by
Claude Opus 5.5
parent
71875ce5a8
commit
e18d1bfed3
@@ -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.
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user