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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-02 09:57:33 +01:00
1 parent 14e5eec341
commit cb1a040731
3 files changed
+58 -7

No files matched your search

+2 -2
View File
@@ -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
+15 -5
View File
@@ -49,7 +49,8 @@ fn named(doc: &Document, s: &Stream) -> Vec<u32> {
}
}
};
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<u64> {
/// 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<Stream> {
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<u32>) -> Result<usize
users.push(r);
}
}
let mut stuck = 0;
let (mut stored_again, mut stuck) = (0, 0);
for &r in &users {
let object = doc.get(r);
let Object::Stream(s) = &*object else { continue };
match without_dictionary(doc, s) {
Some(stored) => 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<u32>) -> Result<usize
if dictionaries.iter().any(|n| after.contains(n)) {
return Err(crate::RedactError::SharedJbig2Globals(stuck));
}
Ok(users.len())
// What couldn't be stored has left with the dictionaries, a dictionary that names itself say:
// it isn't counted.
Ok(stored_again)
}
+41
View File
@@ -1401,6 +1401,23 @@ fn ways_out(scan: &SharedScan) -> 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