diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index b70cf23ab..7228d9ed6 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -210,6 +210,12 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> 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 { + [&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 else { return Ok(0); }; - let mut todo: Vec = 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 = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); let mut seen = HashSet::new(); let mut hits: Vec = 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 } 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 = match d.get(b"K").map(|k| doc.resolve(k)) { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a7e11bcaf..47b4d19da 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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![ + 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::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]