From 4d6812503ddd04aacfe3ab593ee580b4c1e7372e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:40:40 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/redact/src/sanitize.rs | 32 ++++++++++++--------- crates/redact/src/tags.rs | 11 +++++-- crates/redact/src/tests.rs | 54 +++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 75b8c89be..3a9935e6e 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -266,6 +266,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 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 { + 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 = 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 = 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 = 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 Result = 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(); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 16e2e37d1..a4ae54090 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -274,12 +274,19 @@ pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> 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) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index f9e08d35d..c3c4f8922 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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![ + // 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 = 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]