From cb1a04073171f3b2c2ab82eba1ee7df6f515a026 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 2 Nov 2026 09:55:48 +0100 Subject: [PATCH] M8: a shared JBIG2 dictionary is found in /FDecodeParms too /FDecodeParms are the parameters of data in another file, which a reader may draw instead of the stream's own. A dictionary that only a removed image named there stayed in the file with Ok. It is now read with /DecodeParms and /DP, and an image whose data is in another file counts as one that can't be stored without the dictionary: readers draw that file, not a copy stored here. images_restored counts what was stored again. A dictionary that names itself as its own globals (malformed) leaves with what used it but can't be stored, and was counted. The table's /Mask route gets a row in the routes test, as it was only measured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/README.md | 4 ++-- crates/redact/src/globals.rs | 20 +++++++++++++----- crates/redact/src/tests.rs | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index daaacc4442..95c0e07161 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -26,9 +26,9 @@ clear_marks(&mut doc, None)?; // remove marks without app no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), those of marks under the area included, so whatever left the document, by any route, takes its globals with it on the full rewrite: anything else the save would keep that names them, or a - byte-for-byte copy of them, is stored again without them, as a copy is (Flate, Group 4 or JBIG2, + copy of them (the same bytes once decoded), is stored again without them, as a copy is (Flate, Group 4 or JBIG2, whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, it's past - the size redaction clears, or it can't be decoded), or something else refers to them, `apply` + the size redaction clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply` refuses and changes nothing (`RedactError::SharedJbig2Globals`). `remove_hidden` takes them out the same way. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy nothing names, and an image that hasn't left keeps its globals with its pixels: one another page diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs index 8bfb9733fc..13bc851957 100644 --- a/crates/redact/src/globals.rs +++ b/crates/redact/src/globals.rs @@ -49,7 +49,8 @@ fn named(doc: &Document, s: &Stream) -> Vec { } } }; - for key in [b"DecodeParms".as_slice(), b"DP"] { + // `/FDecodeParms` too: those of data in another file, which a reader may draw instead. + for key in [b"DecodeParms".as_slice(), b"DP", b"FDecodeParms"] { let Some(parms) = s.dict.get(key).map(|p| followed(doc, p)) else { continue }; match &*parms { Object::Array(entries) => entries.iter().for_each(&mut read), @@ -94,8 +95,12 @@ fn content(doc: &Document, num: u32) -> Option { /// referred to. `None` for a stream that isn't JBIG2, whose decoder took no dictionary and whose /// samples another filter may give otherwise; for one past the size of an image redaction clears, /// or known to have more than one component, which JBIG2 is drawn as one of whatever the dictionary -/// says; and for one that can't be decoded. +/// says; for one whose data is in another file (`/F`), which readers draw instead of what is here; +/// and for one that can't be decoded. fn without_dictionary(doc: &Document, s: &Stream) -> Option { + if s.dict.contains(b"F") { + return None; + } let readable = readable(doc, s); let jbig2 = match readable.dict.get(b"Filter") { Some(Object::Array(a)) => a.iter().any(|f| f.as_name() == Some(b"JBIG2Decode".as_slice())), @@ -168,12 +173,15 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet) -> Result doc.set(r, Object::Stream(stored)), + Some(stored) => { + doc.set(r, Object::Stream(stored)); + stored_again += 1; + } None => stuck += 1, } } @@ -181,5 +189,7 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet) -> Result Vec<(&'static str, [f64; 4], SharedPages)> { COVERS, SharedPages { first: format!("{plain} /SMask 10 0 R"), extra: vec![stream(&shared_image(SHARED), scan.page.clone())], ..base() }, ), + ( + "a stencil mask", + COVERS, + SharedPages { + first: format!("{plain} /Mask 10 0 R"), + extra: vec![stream( + &shared_image(SHARED).replace("/BitsPerComponent 1 /ColorSpace /DeviceGray", "/ImageMask true"), + scan.page.clone(), + )], + ..base() + }, + ), + ( + "parameters for data in another file", + COVERS, + SharedPages { first: shared_image("/F (scan.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), ..base() }, + ), ( "an alternate", COVERS, @@ -1686,6 +1703,30 @@ fn a_refused_redaction_counts_what_cant_be_stored() { assert_eq!(text.starts_with("something else in the file refers to JBIG2 symbol data"), stuck == 0, "{other}: {text}"); assert_eq!(text.contains("can't be stored"), stuck > 0, "{other}: {text}"); } + // An image whose data is in another file can't be stored here, though what is here decodes: readers + // draw the other file, with the dictionary its own parameters name. + let elsewhere = format!("{} /F (page2.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)); + let mut doc = SharedPages { other: elsewhere, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + +/// A dictionary that names itself as its own globals (malformed) can't be stored without them, but +/// leaves with what used it, so the run goes on; it isn't counted as stored. +#[test] +fn a_dictionary_that_names_itself_isnt_counted_as_stored() { + let scan = shared_scan(); + let mut doc = SharedPages::new(&scan).build(&scan); + let dictionary = pdfcraft_cos::ObjRef::new(9, 0); + let Object::Stream(mut s) = (*doc.get(dictionary)).clone() else { panic!("object 9 is the dictionary") }; + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(dictionary)); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(dictionary, Object::Stream(s)); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); } /// An image that names the dictionary and another one is stored again without both, but the other