mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b7b6e6fe09
commit
4d6812503d
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user