From 1ddec3fab6994d56330801dd75d77eaf355ea7b2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 12:45:11 +0100 Subject: [PATCH] M2.6: redaction keeps an image the page still draws behind parameters by reference Once a redaction removes an image or form under a mark, it takes the name out of the page's resources unless the page's content still draws it elsewhere. It read the page's streams again for that with `decoded`, which reads `/DecodeParms` only when direct, so a stream behind a predictor whose parameters are an indirect object seemed not to draw it, and the page lost the image there. It now looks in what it read for the redaction, with the parameters by reference, and decodes again only the streams it has just written, which have none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/lib.rs | 7 +++- crates/redact/src/tests.rs | 68 +++++++++++++++++++++++++++----------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index c865c78399..e2724b0ff0 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -704,7 +704,12 @@ fn redact_page_content( // longer draws so the removed image or form bytes cannot survive in the saved file. let page_content: Vec> = new_list .iter() - .map(|o| match &*doc.resolve(o) { + .zip(&list) + .zip(&data) + .map(|((now, was), read)| match &*doc.resolve(now) { + // Left as it was: what was read above, with its parameters by reference. + _ if now == was => read.clone(), + // Written above by `Stream::flate`, which leaves no `/DecodeParms`. Object::Stream(s) => s.decoded().unwrap_or_default(), _ => Vec::new(), }) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4bcacefbab..8b91d1128a 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -81,31 +81,36 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// `content` behind a PNG Up predictor whose `/DecodeParms` is an indirect object added to `doc`, +/// in a stream with `dict`'s entries too. +fn predicted(doc: &mut Document, dict: pdfcraft_cos::Dict, content: &[u8]) -> pdfcraft_cos::Stream { + use pdfcraft_cos::{Dict, Object, Stream}; + // One column: each byte a row of its own, tagged 2. + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); + let mut s = Stream::flate(Dict::new(), &rows); + for (k, v) in dict.iter() { + s.dict.set(k.to_vec(), v.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + /// Content whose `/DecodeParms` is given by reference is read through the reference, on the page /// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph /// was found under the mark, and the redaction left the text in the file and said it was done. #[test] fn content_with_parameters_given_by_reference_is_redacted() { - use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; - // PNG Up, one column: each byte a row of its own, tagged 2. - let predicted = |doc: &mut Document, mut dict: Dict| { - let mut rows = Vec::new(); - let mut above = 0u8; - for &b in b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET" { - rows.extend([2, b.wrapping_sub(above)]); - above = b; - } - let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); - dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); - let mut s = Stream::flate(Dict::new(), &rows); - for (k, v) in dict.iter() { - s.dict.set(k.to_vec(), v.clone()); - } - s - }; + use pdfcraft_cos::{Dict, ObjRef, Object}; + let secret = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET"; let marked = [[5.0, 90.0, 120.0, 115.0]]; let mut page = one_page(b"", "", vec![]); - let s = predicted(&mut page, Dict::new()); + let s = predicted(&mut page, Dict::new(), secret); page.set(ObjRef::new(4, 0), s); // The parameters take object 7 and the form 8. let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]); @@ -118,7 +123,7 @@ fn content_with_parameters_given_by_reference_is_redacted() { [(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(), ), ); - let s = predicted(&mut form, dict); + let s = predicted(&mut form, dict, secret); form.set(ObjRef::new(8, 0), s); for (mut doc, what) in [(page, "page"), (form, "form")] { assert_eq!(under(&mut doc, 0, &marked), 6, "{what}"); @@ -129,6 +134,31 @@ fn content_with_parameters_given_by_reference_is_redacted() { } } +/// An image removed under a mark stays in the page's resources while another of its content +/// streams, with `/DecodeParms` given by reference, still draws it. Read without the parameters, +/// that stream seemed not to draw it, and the page lost the image there. +#[test] +fn an_image_drawn_again_in_content_with_parameters_by_reference_stays() { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); + let mut doc = one_page(b"q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 7 0 R >>", vec![img]); + let again = predicted(&mut doc, Dict::new(), b"q 10 0 0 10 200 200 cm /Im1 Do Q"); + let again = doc.add(Object::Stream(again)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(again)]))) + .unwrap(); + mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let doc = reopen(&doc); + assert!(content(&doc, 0).contains("200 200 cm /Im1 Do"), "{}", content(&doc, 0)); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + // The save numbers objects afresh: the image is told by what it is. + let Some(Object::Stream(image)) = xo.reference(b"Im1").map(|r| (*doc.get(r)).clone()) else { panic!("no /Im1: {xo:?}") }; + assert_eq!((image.dict.name(b"Subtype"), image.decoded().ok()), (Some(&b"Image"[..]), Some(vec![255; 4]))); +} + /// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a /// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to /// lie past a mark that covers all of it on screen, and was kept.