From 236ce9a064379854447fec59dcdeb61bcbea22bc Mon Sep 17 00:00:00 2001 From: Earl Co Date: Mon, 19 Oct 2026 08:48:29 +0200 Subject: [PATCH] Clear image pixels intersected by redaction marks (#3368) --- crates/redact/src/image.rs | 24 +++++++++++++----------- crates/redact/src/tests.rs | 19 +++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 23ef2559a..9a19ba2f0 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,5 +1,5 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel -//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). //! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the //! caller removes the whole image instead; that is fail-closed. @@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option { }) } -/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { @@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] }; let mut cleared = 0usize; for j in 0..h { - // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. - let v = 1.0 - (j as f64 + 0.5) / h as f64; - let (ax, ay) = ctm.apply(0.0, v); - let (bx, by) = ctm.apply(1.0, v); - let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; - if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + // Image space has its origin at the bottom left; row 0 is at the top. + let v0 = 1.0 - (j + 1) as f64 / h as f64; + let v1 = 1.0 - j as f64 / h as f64; + let row_box = ctm.bbox([0.0, v0, 1.0, v1]); + if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { - let u = (i as f64 + 0.5) / w as f64; - let (x, y) = ctm.apply(u, v); - if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + let u0 = i as f64 / w as f64; + let u1 = (i + 1) as f64 / w as f64; + // The bounding box may cover extra pixels for a rotated or skewed image; that + // conservative over-clear prevents a touched sample surviving in the file. + let pixel_box = ctm.bbox([u0, v0, u1, v1]); + if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a7e11bcaf..2dcc33602 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { } } +#[test] +fn redaction_clears_pixels_intersected_without_covering_their_centres() { + for (content, rect) in + [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])] + { + let mut doc = one_page( + content, + "/XObject << /Im 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])], + ); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im"), None); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234])); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9adae543c..38e5a8d26 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5704,8 +5704,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] -notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole" +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"] +notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole" [[feature]] id = "protect.redact-vectors"