mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M8: redaction keeps an image a form without resources still draws
When a redaction removes or replaces an image or form under a mark, it takes the name out of the page's resources unless the page still draws it. It looked only at the page's own `Do` operators. A form with no /Resources of its own draws through the page's, so when such a form outside the mark drew the same image, the page lost the name and the form drew nothing there. The page now keeps a name that a form drawing through its resources still draws, found as the cleanup of the fonts and properties these forms use already finds them. As that cleanup does, it refuses the page when such a form can't be read. On a page with fonts or properties to clean up that was already the case; on a page with neither, the name used to go. A rewritten form had the same fault one level down. It dropped the names it replaced before `prune_form_resources` ran. That function already keeps what a form drawing through these resources still draws, so the rewritten form now leaves the decision to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
f42fda7342
commit
7b1301b34f
5 files changed
+92
-15
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<u8>], content: &[Vec<u8>]) {
|
||||
/// 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<u8>], content: &[Vec<u8>]) -> 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user