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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 12:37:35 +01:00
1 parent 91968610c3
commit 2d672d9517
2 files changed
+55 -20

No files matched your search

+6 -1
View File
@@ -820,7 +820,12 @@ fn redact_page_content(
// longer draws so the removed image or form bytes cannot survive in the saved file.
let page_content: Vec<Vec<u8>> = 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(),
})
+49 -19
View File
@@ -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::<Dict>()));
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>()));
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.