From 616ddca371a49cee3a3f187b0d086e5058e7662d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:48:29 +0100 Subject: [PATCH] M8: images stored again without shared JBIG2 globals are stored as a cleared copy is An image that shared a redacted image's /JBIG2Globals is now stored again as the smallest of Flate, Group 4 or JBIG2 coded alone (smallest), like a cleared copy, rather than always Flate, so the other pages of a scan don't balloon either. The shared-globals test's second page is a ragged scan and checks it is no longer Flate and is smaller. --- crates/redact/README.md | 4 ++-- crates/redact/src/image.rs | 8 ++++++-- crates/redact/src/lib.rs | 12 ++++++------ crates/redact/src/tests.rs | 13 ++++++++----- parity/acrobat-features.toml | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index e590d0a03d..9c024d1b55 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,8 +25,8 @@ clear_marks(&mut doc, None)?; // remove marks without app when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), those of marks under the area included, so every other image that uses the globals of a removed - or replaced image is decoded and stored again in place as Flate without them - (`Report::images_restored`), and they leave the file on the full rewrite; one that can't be + or replaced image is decoded and stored again in place without them, as a copy is (Flate, + Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 802c7a47ec..a96f4715e6 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -204,7 +204,8 @@ fn stored_len(s: &Stream) -> usize { } /// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, -/// and written as a Flate image of the same samples, its other entries kept. `None` when it can't +/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest +/// (`smallest`), its other entries kept. `None` when it can't /// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than /// the image, or not a one-bit image of one component). Redaction does this to every image that /// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn @@ -231,5 +232,8 @@ pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { data.truncate(bytes); let mut dict = s.dict.clone(); dict.remove(b"Length"); - Some(Stream::flate(dict, &data)) + // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. + dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); + Some(smallest(dict, &data, w, h, true, true)) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index e1891ecfef..fad55c431f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -138,8 +138,8 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, - /// JBIG2 images, anywhere in the document, stored again as Flate without the `/JBIG2Globals` - /// they shared with a redacted image, so those globals leave the file. + /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the + /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, @@ -551,10 +551,10 @@ const MAX_JBIG2_WALK: usize = 100_000; /// of marks drawn only under the area too. The image itself is replaced or removed, but the globals /// would stay for as long as another image uses them. So every image that uses the globals of a /// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded -/// and stored again in place as Flate, without them, and a full save then leaves them out. An -/// image that can't be stored again refuses the redaction (nothing changes, as `apply` works on a -/// copy), but for a removed or replaced one, which is either unlinked or still drawn whole on -/// another page. Returns how many images that weren't removed or replaced were stored again. +/// and stored again in place without them, as a cleared copy is, and a full save then leaves them +/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` +/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn +/// whole on another page. Returns how many images that weren't removed or replaced were stored again. fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result { let mut globals = HashSet::new(); let mut seen = HashSet::new(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 22eac1296b..74b8407056 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1154,10 +1154,8 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { } } draw(&mut first, &SIGNATURE, 2, 10); - let mut second = vec![0xFFu8; stride * h]; - for x in (3..w - 6).step_by(7) { - draw(&mut second, &LETTER, 20, x); - } + // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate. + let second = ragged_scan(w, h); // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the // second page's segments are numbered past it. let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); @@ -1219,7 +1217,12 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { } let saved = reopen(&doc); assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); - assert_eq!(picture(&saved, 1, b"Im"), Some(second), "the second page keeps its picture"); + assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture"); + // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate. + let restored = named_stream(&saved, 1, b"Im").unwrap(); + let flate = pdfcraft_filters::encode_flate(&second); + assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len()); // The dictionary's data (after its 11-byte header) codes the signature's bitmap. let coded = &dictionary[11..]; for (r, object) in saved.scan_objects() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ebafe530be..8b923d6ce7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5825,7 +5825,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again as Flate without them, so they leave the file (one that can't be decoded refuses the redaction)" +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors"