Clear image pixels intersected by redaction marks (#3368)

This commit is contained in:
Earl Co
2026-10-19 02:48:29 -04:00
committed by GitHub
parent 66f1085688
commit 236ce9a064
3 changed files with 34 additions and 13 deletions
+13 -11
View File
@@ -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<usize> {
})
}
/// `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<Option<Stream>, ()> {
@@ -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;
+19
View File
@@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
}
}
#[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.
+2 -2
View File
@@ -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"