diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs index cd0bc982d8..8bfb9733fc 100644 --- a/crates/redact/src/globals.rs +++ b/crates/redact/src/globals.rs @@ -157,11 +157,14 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet) -> Result>| read.entry(*n).or_insert_with(|| content(doc, *n)).is_some_and(|c| contents.contains(&c)); - if names.iter().any(|n| dictionaries.contains(n) || copy(n, &mut read)) { - dictionaries.extend(names); + // Only what matched has to leave, a copy found here included. Another dictionary this also + // names was shared with nothing that left: what else uses it may keep it, whichever comes + // first in the file. + let matched: Vec = named(doc, s).into_iter().filter(|n| dictionaries.contains(n) || copy(n, &mut read)).collect(); + if !matched.is_empty() { + dictionaries.extend(matched); users.push(r); } } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 477d4bcb37..d7108e77ae 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -71,7 +71,7 @@ pub enum RedactError { /// could not be shown complete. #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] TooDeep, - /// `/JBIG2Globals` used by something removed would stay: a JBIG2 image shares them and can't be + /// `/JBIG2Globals` used by something removed would stay: something that names them can't be /// stored again without them, or something else refers to them. Either way the shapes they /// hold, those of marks under the area included, would stay. Counts what can't be stored without /// them: 0 when only something else keeps them. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 04a4ed1f9b..7512211326 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1688,6 +1688,48 @@ fn a_refused_redaction_counts_what_cant_be_stored() { } } +/// 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. +#[test] +fn another_dictionary_named_beside_it_stays_whatever_the_order() { + let scan = shared_scan(); + let other_dictionary = stream("", [jbig2_globals(b"OTHER_GLOBALS"), scan.symbols.clone()].concat()); + let only_other = shared_image("/DecodeParms << /JBIG2Globals 11 0 R >>"); + let chained = "/Type /XObject /Subtype /Image /Width 400 /Height 40 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/FlateDecode /JBIG2Decode] /DecodeParms [<< /JBIG2Globals 11 0 R >> << /JBIG2Globals 9 0 R >>]"; + let both_spellings = [ + (chained.to_string(), pdfcraft_filters::encode_flate(&scan.page)), + (format!("{} /DP << /JBIG2Globals 11 0 R >>", shared_image(SHARED)), scan.page.clone()), + ]; + let other_stays = |doc: &Document| { + let saved = reopen(doc); + saved.scan_objects().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.raw.windows(13).any(|w| w == b"OTHER_GLOBALS"))) + }; + for (both, data) in both_spellings { + // Image 8 is drawn on the second page, and image 10 is its alternate. + let only_other_first = SharedPages { + other: format!("{only_other} /Alternates [<< /Image 10 0 R >>]"), + extra: vec![stream(&both, data.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + let both_first = SharedPages { + other: format!("{both} /Alternates [<< /Image 10 0 R >>]"), + other_data: data.clone(), + extra: vec![stream(&only_other, scan.page.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + for (pages, only_other_at) in [(only_other_first, 8), (both_first, 10)] { + let mut doc = pages.build(&scan); + let untouched = doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1, "{both}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert!(other_stays(&doc), "{both}"); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)), untouched, "{both}"); + } + } +} + /// Remove Hidden Information leaves the same way: comments go, with what their appearances drew. #[test] fn removing_hidden_information_takes_a_shared_jbig2_dictionary_too() {