mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:17:44 -06:00
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:
co-authored by
Claude Opus 5.5
parent
98cbd38a8e
commit
3f543ca17f
@@ -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)) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user