diff --git a/Cargo.lock b/Cargo.lock index f9280eff06..c6ee963a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4572,6 +4572,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..8d1515d8a4 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,4 +20,5 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 601700f257..4e26fbd1fc 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -45,6 +45,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { return None; } + // `Stream::decoded*` reads `/DecodeParms` only when it is direct: a predictor given by + // reference would be skipped, and the cleared bytes would then miss the covered pixels. + let s = &*doc.with_direct_parms(s); let supported = |n: &[u8]| { matches!( n, diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..6db9cec8c7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -549,6 +549,32 @@ fn redaction_clears_pixels_intersected_without_covering_their_centres() { } } +/// An image whose PNG predictor is given by reference has its covered pixel cleared, and only +/// that one: `Stream::decoded` reads only a direct `/DecodeParms`, so the row tags stayed in the +/// data, the cleared byte drifted off the covered pixel and the covered sample survived. +#[test] +fn an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared() { + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let mut doc = one_page( + b"q 40 0 0 20 0 0 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms 8 0 R", + predicted, + ), + b"<< /Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4 >>".to_vec(), + ], + ); + // The top row's second sample. + mark(&mut doc, 0, &[[12.0, 12.0, 18.0, 18.0]], ""); + 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"PCRedacted1"), Some(vec![10, 0, 30, 40, 50, 60, 70, 80])); +} + /// 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.