From e27d8d9cc17746a3628ee66c40aa03446312c789 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:58:46 +0200 Subject: [PATCH] Unlinking undrawn images matches what a reader draws A page's content streams are read as one, and forms named through a reference-only object or listed in a Type 3 font's own resources are read too, so the images they draw stay. A page tree that lists a node twice is left alone. The pass repeats while names go, so an image only an unused form drew now leaves with that form. --- crates/redact/src/interp/prune.rs | 76 +++++++++++++++++++++---------- crates/redact/src/tests.rs | 62 +++++++++++++++++++------ 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index eff76b50a9..829f7b8391 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -51,14 +51,18 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// Every page-tree node with a /Resources of its own, and the pages that draw through it (the /// nearest such node above a page, or the page itself). Cycles are cut as in -/// `pdfcraft_model::pages`. -fn resource_holders(doc: &Document) -> Vec<(ObjRef, Vec)> { +/// `pdfcraft_model::pages`. The flag is false when a node was reached twice (listed under two +/// parents, or in a cycle) or the walk stopped early: a page may then draw through a holder it +/// isn't listed under. +fn resource_holders(doc: &Document) -> (Vec<(ObjRef, Vec)>, bool) { let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return holders }; + let mut whole = true; + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return (holders, whole) }; let mut seen = HashSet::new(); let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; while let Some((node, mut holder)) = stack.pop() { if !seen.insert(node) || seen.len() > 1_000_000 { + whole = false; continue; } let obj = doc.get(node); @@ -75,19 +79,24 @@ fn resource_holders(doc: &Document) -> Vec<(ObjRef, Vec)> { h.1.push(node); } } - holders + (holders, whole) } /// Unlink the image and form XObjects nothing draws from the page-tree resource dictionaries /// that still name them: Delete Image and Replace Image take an image off the page but leave it /// listed there, and so in a full-rewrite save. A name stays when anything that draws through the /// dictionary uses it ([`names_drawn`]), and a dictionary whose use can't be told is left as it -/// is. A dictionary that loses an entry is replaced by a private copy on its node, as in -/// [`unlink_doomed`]. Returns how many names went. +/// is, as is every one when a page is listed twice in the page tree. A dictionary that loses an +/// entry is replaced by a private copy on its node, as in [`unlink_doomed`]. Returns how many +/// names went. pub(crate) fn unlink_undrawn(doc: &mut Document) -> Result { let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let (holders, whole) = resource_holders(doc); + if !whole { + return Ok(0); + } let mut n = 0; - for (node, users) in resource_holders(doc) { + for (node, users) in holders { let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue; }; @@ -102,15 +111,22 @@ pub(crate) fn unlink_undrawn(doc: &mut Document) -> Result { if listed.is_empty() { continue; } - let Some(drawn) = names_drawn(doc, &res, &users, &index) else { continue }; let mut pruned = false; - for name in listed.iter().filter(|name| !drawn.contains(*name)) { - xo.remove(name); + // A form that goes may have been all that drew another name: look again while names go. + for _ in 0..MAX_DEPTH { + let Some(drawn) = names_drawn(doc, &res, &users, &index) else { break }; + let mut gone = 0; + for name in listed.iter().filter(|name| !drawn.contains(*name)) { + gone += usize::from(xo.remove(name).is_some()); + } + if gone == 0 { + break; + } + n += gone; pruned = true; - n += 1; + res.set(b"XObject".to_vec(), Object::Dict(xo.clone())); } if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; } } @@ -139,14 +155,15 @@ fn names_drawn(doc: &Document, res: &Dict, pages: &[ObjRef], index: &HashMap streams.push(r), + Some(r) if matches!(&*doc.resolve(look), Object::Stream(_)) => streams.push(r), _ => streams.extend(doc.resolve(look).as_dict().into_iter().flat_map(|states| states.iter().filter_map(|(_, v)| v.as_ref()))), } } @@ -157,14 +174,23 @@ fn names_drawn(doc: &Document, res: &Dict, pages: &[ObjRef], index: &HashMap Vec { - let is_stream = |r: &ObjRef| matches!(&*doc.get(*r), Object::Stream(_)); +/// patterns, its Type 3 fonts' glyph procedures and its soft masks' groups. Then the /Resources +/// of those Type 3 fonts, which their glyphs look names up in first. An object that holds only a +/// reference stands for what it names, as the renderer reads it. +fn drawn_through(doc: &Document, res: &Dict) -> (Vec, Vec) { + let is_stream = |r: &ObjRef| matches!(&*doc.resolve(&Object::Ref(*r)), Object::Stream(_)); let mut found: Vec = Vec::new(); + let mut fonts: Vec = Vec::new(); for (_, x) in res_dict(doc, res, b"XObject").iter() { - found.extend(x.as_ref().filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))); + found.extend(x.as_ref().filter(|_| matches!(&*doc.resolve(x), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))); } for (_, p) in res_dict(doc, res, b"Pattern").iter() { - found.extend(p.as_ref().filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.int(b"PatternType") == Some(1)))); + found.extend(p.as_ref().filter(|_| matches!(&*doc.resolve(p), Object::Stream(s) if s.dict.int(b"PatternType") == Some(1)))); } for (_, f) in res_dict(doc, res, b"Font").iter() { let font = doc.resolve(f); let Some(font) = font.as_dict().filter(|f| f.name(b"Subtype") == Some(b"Type3")) else { continue }; let procs = font.get(b"CharProcs").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); found.extend(procs.iter().filter_map(|(_, v)| v.as_ref()).filter(is_stream)); + fonts.extend(font.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned())); } for (_, g) in res_dict(doc, res, b"ExtGState").iter() { let gs = doc.resolve(g); let mask = gs.as_dict().and_then(|g| g.get(b"SMask")).map(|m| doc.resolve(m)); found.extend(mask.as_ref().and_then(|m| m.as_dict()).and_then(|m| m.reference(b"G")).filter(is_stream)); } - found + (found, fonts) } /// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource @@ -220,7 +250,7 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); - let holders = resource_holders(doc); + let (holders, _) = resource_holders(doc); let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 1e789f7c05..69c9ab4bb4 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4228,31 +4228,56 @@ fn gray_image(data: &[u8]) -> Vec { } /// An image Delete Image took off the page is still listed in its resources: Remove Hidden -/// Information unlinks it, so the full rewrite leaves it out. Images a page still draws stay, -/// directly, through a form without /Resources, or on another page sharing the dictionary. +/// Information unlinks it, so the full rewrite leaves it out, as it does one only an unused form +/// draws. Images a page still draws stay, directly, through a form without /Resources, or on +/// another page sharing the dictionary, its name and `Do` in two content streams. #[test] fn images_no_page_draws_leave_with_hidden_information() { let bytes = hidden_information_removed(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), - b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [6 0 R 15 0 R] /Resources 7 0 R >>".to_vec(), stream("", b"q 50 0 0 50 20 20 cm /Shown Do Q /Fm Do"), - stream("", b"q 50 0 0 50 20 20 cm /Other Do Q"), - b"<< /XObject << /Shown 8 0 R /Deleted 9 0 R /Nested 10 0 R /Other 11 0 R /Fm 12 0 R >> >>".to_vec(), + stream("", b"q 50 0 0 50 20 20 cm /Other"), + b"<< /XObject << /Shown 8 0 R /Deleted 9 0 R /Nested 10 0 R /Other 11 0 R /Fm 12 0 R /Unused 13 0 R /Orphan 14 0 R >> >>".to_vec(), gray_image(b"SHOWNIMAGEBYTES"), gray_image(b"DELETEDIMAGEBYTES"), gray_image(b"NESTEDIMAGEBYTES"), gray_image(b"OTHERPAGEIMAGE"), stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 10 0 0 10 200 200 cm /Nested Do Q"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Orphan Do"), + gray_image(b"ORPHANIMAGEBYTES"), + stream("", b"Do Q"), ]); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(!has(b"DELETEDIMAGEBYTES"), "the deleted image survived the save"); + assert!(!has(b"ORPHANIMAGEBYTES"), "an image only an unused form draws survived the save"); for kept in [&b"SHOWNIMAGEBYTES"[..], b"NESTEDIMAGEBYTES", b"OTHERPAGEIMAGE"] { assert!(has(kept), "{} is drawn and must stay", String::from_utf8_lossy(kept)); } } +/// A page listed under two /Pages nodes draws through either one's resources, whichever a reader +/// comes to it from: neither loses the image it draws. +#[test] +fn a_page_listed_twice_keeps_both_parents_images() { + let bytes = hidden_information_removed(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Pages /Parent 2 0 R /Kids [5 0 R] /Count 1 /Resources << /XObject << /Im 7 0 R >> >> >>".to_vec(), + b"<< /Type /Pages /Parent 2 0 R /Kids [5 0 R] /Count 1 /Resources << /XObject << /Im 8 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 3 0 R /MediaBox [0 0 300 300] /Contents 6 0 R >>".to_vec(), + stream("", b"q 50 0 0 50 20 20 cm /Im Do Q"), + gray_image(b"FIRSTPARENTIMAGE"), + gray_image(b"SECONDPARENTIMAGE"), + ]); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + for kept in [&b"FIRSTPARENTIMAGE"[..], b"SECONDPARENTIMAGE"] { + assert!(has(kept), "{} is drawn and must stay", String::from_utf8_lossy(kept)); + } +} + /// Unlinking an image nothing draws is a removal of its own: with nothing else to remove, it is /// still applied, not reported as nothing to do. #[test] @@ -4271,16 +4296,17 @@ fn unlinking_alone_still_applies() { /// A name a pattern, a form, an appearance or a soft mask doesn't define is looked up in the /// page's resources, /Resources of its own or not: an image drawn only that way is still shown, -/// so it stays. One no content names goes. +/// so it stays, as one drawn by a form named through an object that holds only a reference. One +/// no content names goes. #[test] fn images_drawn_through_the_page_resources_stay() { let bytes = hidden_information_removed(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 5 0 R /Annots [6 0 R] >>".to_vec(), - stream("", b"q /Pattern cs /Pt scn 0 0 10 10 re f Q /Own Do /Loop Do q /Gs gs 0 0 5 5 re f Q"), - b"<< /XObject << /ByPattern 7 0 R /ByOwnForm 8 0 R /ByAppearance 9 0 R /ByMask 10 0 R /Deleted 11 0 R /Own 12 0 R /Loop 16 0 R >> \ - /Pattern << /Pt 13 0 R >> /ExtGState << /Gs << /SMask << /S /Luminosity /G 14 0 R >> >> >> >>" + stream("", b"q /Pattern cs /Pt scn 0 0 10 10 re f Q /Own Do /Loop Do q /Gs gs 0 0 5 5 re f Q /Chained Do"), + b"<< /XObject << /ByPattern 7 0 R /ByOwnForm 8 0 R /ByAppearance 9 0 R /ByMask 10 0 R /Deleted 11 0 R /Own 12 0 R /Loop 16 0 R \ + /Chained 17 0 R /ByChain 19 0 R >> /Pattern << /Pt 13 0 R >> /ExtGState << /Gs << /SMask << /S /Luminosity /G 14 0 R >> >> >> >>" .to_vec(), b"<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 15 0 R >> >>".to_vec(), gray_image(b"BYPATTERNIMAGE"), @@ -4294,16 +4320,19 @@ fn images_drawn_through_the_page_resources_stay() { stream("/Type /XObject /Subtype /Form /BBox [0 0 50 50]", b"/ByAppearance Do"), // Draws itself: read once. stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /Loop 16 0 R >> >>", b"/Loop Do"), + b"18 0 R".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/ByChain Do"), + gray_image(b"BYCHAINIMAGE"), ]); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(!has(b"DELETEDIMAGEBYTES"), "the deleted image survived the save"); - for kept in [&b"BYPATTERNIMAGE"[..], b"BYOWNFORMIMAGE", b"BYAPPEARANCEIMAGE", b"BYMASKIMAGE"] { + for kept in [&b"BYPATTERNIMAGE"[..], b"BYOWNFORMIMAGE", b"BYAPPEARANCEIMAGE", b"BYMASKIMAGE", b"BYCHAINIMAGE"] { assert!(has(kept), "{} is drawn and must stay", String::from_utf8_lossy(kept)); } } -/// A Type 3 glyph draws through the page's resources too: its image stays, and the dictionary -/// is still cleaned of what nothing draws. +/// A Type 3 glyph draws through the page's resources too, as does a form in the font's own +/// resources: their images stay, and the dictionary is still cleaned of what nothing draws. #[test] fn images_drawn_by_type3_glyphs_stay() { let bytes = hidden_information_removed(vec![ @@ -4311,15 +4340,18 @@ fn images_drawn_by_type3_glyphs_stay() { 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 5 0 R >>".to_vec(), stream("", b"BT /T3 12 Tf (a) Tj ET"), - b"<< /XObject << /ByGlyph 7 0 R /Deleted 8 0 R >> /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /XObject << /ByGlyph 7 0 R /Deleted 8 0 R /ByFontForm 11 0 R >> /Font << /T3 6 0 R >> >>".to_vec(), b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 9 0 R >> \ - /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >>" + /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources << /XObject << /Fx 10 0 R >> >> >>" .to_vec(), gray_image(b"BYGLYPHIMAGE"), gray_image(b"DELETEDIMAGEBYTES"), - stream("", b"10 0 d0 /ByGlyph Do"), + stream("", b"10 0 d0 /ByGlyph Do /Fx Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/ByFontForm Do"), + gray_image(b"BYFONTFORMIMAGE"), ]); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(!has(b"DELETEDIMAGEBYTES"), "the deleted image survived the save"); assert!(has(b"BYGLYPHIMAGE"), "the glyph's image is drawn and must stay"); + assert!(has(b"BYFONTFORMIMAGE"), "the image a form in the font's resources draws must stay"); }