M8: redaction drops OBJRs of elements reached only through the parent or ID tree (#2447)

Follow-up to #2532 (review by aiongg): the structure-tree walk started
only at the root's /K, so an element listed only in /ParentTree or
/IDTree kept its object reference to a removed link or comment, and the
root kept that annotation reachable in a full rewrite. The walk now
also starts from both trees and follows their nodes (/Nums, /Names,
/Kids), written in place or as their own objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-19 00:09:17 +08:00
co-authored by Claude Opus 5.5
parent 98cbd38a8e
commit 3f543ca17f
2 changed files with 44 additions and 1 deletions
+13 -1
View File
@@ -210,6 +210,12 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet<ObjRef>) -> bool {
.is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r)))
}
/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values
/// alike; the walk ignores numbers and strings) and its `/Kids`.
fn tree_node_entries(d: &Dict) -> Vec<Object> {
[&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect()
}
/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their
/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the
/// reference keeps the annotation, its URL or text included, in a full rewrite after the page
@@ -236,12 +242,16 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
else {
return Ok(0);
};
let mut todo: Vec<Object> = root.get(b"K").cloned().into_iter().collect();
// Elements are reached from the root's /K, and also from the parent tree and the ID tree:
// one listed only there (left behind by an editor, say) still points at its annotation.
let mut todo: Vec<Object> = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect();
let mut seen = HashSet::new();
let mut hits: Vec<ObjRef> = Vec::new();
while let Some(k) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a),
// A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids.
Object::Dict(d) => todo.extend(tree_node_entries(&d)),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
@@ -261,6 +271,8 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
}
let Some(d) = object.as_dict() else { continue };
if !d.contains(b"S") {
// A parent tree or ID tree node: its values may be elements.
todo.extend(tree_node_entries(d));
continue;
}
let kids: Vec<Object> = match d.get(b"K").map(|k| doc.resolve(k)) {
+31
View File
@@ -842,6 +842,37 @@ fn a_structure_tree_too_large_to_walk_is_removed_whole() {
assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more");
}
/// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from
/// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full
/// rewrite. Both trees are walked too, written in place or as their own objects.
#[test]
fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 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] >>".to_vec(),
// 4: the removed link; 8: the removed comment
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(),
// 5: the root; its /K reaches only the empty document element 6. The parent tree is
// written in place, the ID tree's kid (9) as its own object.
b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
// 7: a link element only the parent tree lists
b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(),
b"<< /Names [(note1) 10 0 R] >>".to_vec(),
// 10: an annotation element only the ID tree lists
b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(),
];
let gone: std::collections::HashSet<ObjRef> = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into();
let mut doc = pdf(objs);
assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go");
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
for secret in [&b"secret.example"[..], b"hushhush"] {
assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret));
}
}
/// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia
/// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D).
#[test]