diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b0a7257cb3..acadca26f0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1053,11 +1053,11 @@ fn xobject( let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. - if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { + // `prune_form_resources` below then drops the XObjects this form no longer draws, the + // ones it replaced among them, keeping those a form drawing through these resources + // still draws. + if !inner.xobjects.is_empty() { let mut xo = res_dict(doc, &cleaned_res, b"XObject"); - if let Some(data) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); - } for (name, reference) in &inner.xobjects { xo.set(name.clone(), Object::Ref(*reference)); } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 188af03abe..8dc32d6db5 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -3,21 +3,25 @@ use super::{ overlaps, page_streams, res_dict, }; -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { +/// Drop every `replaced` name of the XObject resources `xo` that the rewritten `content` no +/// longer draws, so a full-rewrite save stops reaching the original image or form it named. A +/// name still drawn is kept: by a surviving `Do` (the same image shown again outside a mark), or +/// by a form without /Resources of its own, which draws through `xo` too. Fails, as the cleanup of +/// the fonts and properties these forms draw with does, when that can't be told: such a form +/// won't decode, or the parser can't read all of it or of `content`. +pub(crate) fn prune(doc: &Document, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { - return; + return Ok(()); } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(xo.clone()))]); + let ops = resource_ops(doc, &res, content)?; + let drawn: HashSet<&[u8]> = ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name).collect(); for n in replaced { - if !still_drawn(n) { + if !drawn.contains(n.as_slice()) { xo.remove(n); } } + Ok(()) } /// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6d323004ad..737a41a9e5 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -825,7 +825,7 @@ fn redact_page_content( _ => Vec::new(), }) .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); + interp::prune(doc, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..7941268576 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -418,7 +418,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed form or image bytes cannot survive in the saved file. - crate::interp::prune(&mut xo, &out.replaced, &page_content); + crate::interp::prune(doc, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..dd4908cbe4 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1570,6 +1570,79 @@ fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); } +/// The page draws an image under the mark and, outside it, a form without /Resources of its own +/// that draws the same image through the page's: the page keeps the name, whether the form lies +/// wholly outside the mark or is entered and left as it was. +#[test] +fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { + for bbox in ["[0 0 100 100]", "[0 0 300 300]"] { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream(&format!("/Type /XObject /Subtype /Form /BBox {bbox}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox}"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox}: the form still draws it"); + } +} + +/// The same one level down: a form with resources of its own draws the image under the mark and, +/// outside it, a form without any that draws it again through the first form's. +#[test] +fn an_image_a_nested_form_draws_again_through_its_callers_resources_is_kept() { + let mut doc = one_page( + b"/FmOwn Do", + "/XObject << /FmOwn 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/PCRedacted1 Do")); + let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); + assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do")); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.get(b"Im1").and_then(Object::as_ref).expect("the rewritten form still names /Im1"); + let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN"); +} + +/// A form without /Resources that won't decode may draw the image under the mark through the +/// page's: the redaction is refused. A page with fonts or properties to clean up refused it +/// already; this one has neither. +#[test] +fn a_form_that_cannot_be_read_refuses_the_redaction_of_an_image_it_may_draw() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /FmBroken 5 0 R /Im1 6 0 R >> >> >>" + .to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBroken Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /DCTDecode", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ]); + let Object::Stream(broken) = &*doc.get(ObjRef::new(5, 0)) else { panic!("the form is a stream") }; + assert!(broken.decoded().is_err(), "the fixture's form must not decode"); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_))))); +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test]