From 2eddb1bc987c3b105a6f3f612b5e77adce809332 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 2 Nov 2026 12:18:35 +0100 Subject: [PATCH] M8: a copy is found whatever way its parameters are given content() decoded a dictionary through with_direct_parms, which reads /DecodeParms and its entries one reference deep, while named() follows the chain. So a copy of the dictionary whose parameters came through a reference to a reference decoded to other bytes, wasn't known for a copy, and stayed in the file with Ok. readable() now gives /DecodeParms, each entry and each value through followed, /JBIG2Globals aside, so a copy decodes as a reader that follows references decodes it. With the copy known, the image naming it has to be stored again, and PdfCraft decodes that image's symbols with the copy's parameters one reference deep, so it can't: the run is refused, where it went on with the copy in the file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/globals.rs | 25 +++++++++++++++++++++++- crates/redact/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs index c11388b64a..b095059a7e 100644 --- a/crates/redact/src/globals.rs +++ b/crates/redact/src/globals.rs @@ -68,7 +68,27 @@ fn direct(doc: &Document, v: &Object) -> Object { } } -/// `s` with what `decoded_image` and `decoded_within` read as given made direct: its size and filters. +/// `/DecodeParms` as a reader takes them, through any references: the array or dictionary, each +/// entry, and each value made direct, but `/JBIG2Globals`, which stays the reference it is. +fn direct_parms(doc: &Document, v: &Object) -> Object { + let entry = |e: &Object| match &*followed(doc, e) { + Object::Dict(d) => { + let mut made = d.clone(); + for (key, value) in d.iter().filter(|(key, _)| key.as_slice() != b"JBIG2Globals") { + made.set(key.to_vec(), (*followed(doc, value)).clone()); + } + Object::Dict(made) + } + other => other.clone(), + }; + match &*followed(doc, v) { + Object::Array(a) => Object::Array(a.iter().map(entry).collect()), + other => entry(other), + } +} + +/// `s` with what `decoded_image` and `decoded_within` read as given made direct: its size, filters +/// and their parameters. fn readable(doc: &Document, s: &Stream) -> Stream { let mut readable = s.clone(); for key in [b"Width".as_slice(), b"Height", b"Filter"] { @@ -76,6 +96,9 @@ fn readable(doc: &Document, s: &Stream) -> Stream { readable.dict.set(key.to_vec(), direct(doc, v)); } } + if let Some(v) = s.dict.get(b"DecodeParms") { + readable.dict.set(b"DecodeParms".to_vec(), direct_parms(doc, v)); + } readable } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 171138c3a9..48d00da332 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1791,6 +1791,44 @@ fn a_dictionary_that_names_itself_isnt_counted_as_stored() { assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); } +/// A copy of the dictionary stored with a predictor is found whatever way its parameters are given, +/// through a chain of references among them, as a reader that follows them decodes it. Through a +/// chain, PdfCraft can't decode the image with the copy's symbols to store it again, so the run is +/// refused; it never goes on with the copy left in the file. +#[test] +fn a_copy_is_found_however_its_parameters_are_given() { + let scan = shared_scan(); + let globals = [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat(); + // PNG rows of one byte, each with no prediction: a byte for the row's tag, then the byte. + let predicted: Vec = globals.iter().flat_map(|b| [0u8, *b]).collect(); + let flate = pdfcraft_filters::encode_flate(&predicted); + let parms = b"<< /Predictor 12 /Columns 1 >>".to_vec(); + for (spelling, extra, stored) in [ + ("/DecodeParms << /Predictor 12 /Columns 1 >>", vec![], true), + ("/DecodeParms 11 0 R", vec![parms.clone()], true), + ("/DecodeParms 11 0 R", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms [11 0 R]", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms << /Predictor 11 0 R /Columns 1 >>", vec![b"12 0 R".to_vec(), b"12".to_vec()], false), + ] { + let mut objects = vec![stream(&format!("/Filter /FlateDecode {spelling}"), flate.clone())]; + objects.extend(extra.clone()); + let other = shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"); + let mut doc = SharedPages { other, extra: objects, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let at = format!("{spelling} {}", extra.len()); + match apply(&mut doc, None) { + Ok(report) => { + assert!(stored, "{at}: went on"); + assert_eq!(report.images_restored, 1, "{at}"); + let kept: std::collections::HashSet = pdfcraft_cos::reachable(&doc).into_iter().map(|r| r.num).collect(); + assert!(!kept.contains(&10) && !kept.contains(&9), "{at}: the copy or the dictionary stays"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{at}"); + } + Err(e) => assert!(!stored && matches!(e, RedactError::SharedJbig2Globals(1)), "{at}: {e:?}"), + } + } +} + /// An image that names the dictionary and another one is stored again without both, but the other /// was shared with nothing that left: an image that uses only it stays as it was, whichever of the /// two comes first in the file, and so does that dictionary.