diff --git a/crates/create/README.md b/crates/create/README.md index 05395656a3..8cab2a1ea6 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -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. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b46f74570f..f7fd7f122f 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -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, 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}"))?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 98fbfd7eee..9bff0c311c 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1519,3 +1519,58 @@ fn jbig2_images_export_as_their_picture() { let alpha: Vec = 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 = (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}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69a05410de..7bdf89ecad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"