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