fix(redact): flattened widgets and far pop-ups leave the structure tree too (#2447)

Review follow-up on #2532:
- the structure-tree references dropped are those of every annotation no
  page lists any more after Sanitize, so widgets flattened with "form
  fields" go too, with their field value;
- the annotations to remove are collected on every page first, so a
  pop-up listed on another page than its parent goes with it;
- an element left with nothing to tag drops /Alt, /ActualText, /T and /E,
  which can repeat the URL or comment;
- the test's byte search saves without object streams, so it can see the
  strings it looks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-17 02:02:23 +08:00
co-authored by Claude Opus 5.5
parent b7b6e6fe09
commit 4d6812503d
3 changed files with 80 additions and 17 deletions
+19 -13
View File
@@ -266,6 +266,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?;
}
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet<ObjRef> {
pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect()
};
let listed_before = listed(doc);
if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
@@ -286,18 +292,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|| (which.contains(&Hidden::Attachments) && s == b"FileAttachment")
|| (which.contains(&Hidden::LinksActionsScripts) && s == b"Link")
};
// Every annotation taken off a page, for the structure tree below.
let mut gone: std::collections::HashSet<ObjRef> = std::collections::HashSet::new();
// The annotations to remove, on every page first, so a pop-up listed on another page than
// its parent goes with it.
let removed: std::collections::HashSet<ObjRef> = pdfcraft_model::pages(doc)
.iter()
.flat_map(|p| annots_of(doc, &p.dict))
.filter_map(|a| a.as_ref())
.filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot))
.collect();
for p in pdfcraft_model::pages(doc) {
let list = annots_of(doc, &p.dict);
let mut removed: Vec<ObjRef> = Vec::new();
for a in &list {
if let Some(r) = a.as_ref()
&& doc.get(r).as_dict().is_some_and(drop_annot)
{
removed.push(r);
}
}
let mut kept = Vec::new();
for a in list {
let Some(r) = a.as_ref() else {
@@ -307,7 +311,6 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
let d = doc.get(r).as_dict().cloned().unwrap_or_default();
let parent_gone = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed.contains(&x));
if removed.contains(&r) || (d.name(b"Subtype") == Some(b"Popup") && parent_gone) {
gone.insert(r);
continue;
}
if which.contains(&Hidden::LinksActionsScripts) && (d.contains(b"AA") || (d.name(b"Subtype") == Some(b"Widget") && d.contains(b"A"))) {
@@ -338,8 +341,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
}
})?;
}
// A tagged PDF's structure tree still points at the removed links and comments (#2447): drop
// those references, or the full rewrite keeps the annotations, URLs and text included.
// A tagged PDF's structure tree still points at the removed links, comments and flattened
// widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs,
// text and field values included.
let still = listed(doc);
let gone: std::collections::HashSet<ObjRef> = listed_before.difference(&still).copied().collect();
crate::tags::drop_object_refs(doc, &gone)?;
let off = hidden_layers(doc);
let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default();
+9 -2
View File
@@ -274,12 +274,19 @@ pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet<ObjRef>) -> Re
n += before - keep.len();
doc.update_dict(r, |e| {
if array {
e.set(b"K".to_vec(), Object::Array(keep));
} else if let Some(k) = keep.into_iter().next() {
e.set(b"K".to_vec(), Object::Array(keep.clone()));
} else if let Some(k) = keep.first().cloned() {
e.set(b"K".to_vec(), k);
} else {
e.remove(b"K");
}
// An element left with nothing to tag keeps no text describing what it tagged:
// a link's alternate text often repeats the URL, an annotation's its comment.
if keep.is_empty() {
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
e.remove(key);
}
}
})?;
}
Ok(n)
+52 -2
View File
@@ -653,7 +653,8 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() {
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(),
b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(),
b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
// the link element's alternate text repeats the URL
b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(),
widths(),
b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(),
@@ -661,8 +662,10 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() {
let mut doc = pdf(objs);
let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap();
assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}");
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
// Without object streams, whose compression would hide the strings from the search.
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(has(b"(See the site)"), "the search sees the saved objects");
assert!(!has(b"secret.example"), "the removed link's URL survived the save");
assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save");
// The tagged paragraph and its content stay; the elements lose only the references.
@@ -682,6 +685,53 @@ fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() {
assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays");
}
/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the
/// structure tree too (their field value with them), and a pop-up listed on another page than
/// its removed comment goes with it.
#[test]
fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() {
use crate::sanitize::{Hidden, remove_hidden};
let objs: Vec<Vec<u8>> = vec![
// 1 catalog with a form and a structure tree
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(),
// 3 page 1: a text field's widget and a comment
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(),
stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"),
FONT.replace("95 0 R", "12 0 R").into_bytes(),
// 6 the field and its widget, with an appearance
b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(),
// 7 comment, its pop-up (15) listed on page 2
b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(),
// 8 struct tree root, 9 document, 10 form element tagging the widget
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(),
b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"),
widths(),
// 13 page 2, listing the pop-up of page 1's comment
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(),
b"<< >>".to_vec(),
b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(),
// 16 an unrelated link on page 2 stays
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(),
];
let mut doc = pdf(objs);
remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap();
let page2 = pdfcraft_model::pages(&doc)[1].dict.clone();
let left: Vec<Object> = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default();
assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment");
let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1));
assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget");
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let saved = Document::open(Arc::new(bytes)).unwrap();
let field_left = saved.object_numbers().into_iter().any(|n| {
saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT"))
});
assert!(!field_left, "the flattened field didn't survive the save through the structure tree");
}
/// 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]