From e9fc4cadcf43309a9e43a23a53b79a54a23d4610 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Tue, 20 Oct 2026 20:05:01 +0530 Subject: [PATCH] M8: applying redactions drops structure-tree references to the comments and fields it removes (#2874) Redaction removed annotations and form fields under a mark from the pages but left the structure tree's /OBJR references to them, so a full rewrite of a tagged PDF kept the comment's /Contents and the field's /V. apply now drops references to every annotation no page lists afterwards (widgets of a removed field on other pages included), as Sanitize does since #2532, and fails closed with RedactError::Tags when an element can't be edited. --- crates/redact/README.md | 6 ++- crates/redact/src/lib.rs | 16 ++++++- crates/redact/src/sanitize.rs | 9 ++-- crates/redact/src/tests.rs | 83 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 7cd5ded1e4..e4fc5eb818 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -42,7 +42,11 @@ the document. Reduce the marked area, disable repeated text, or apply fewer page This limits appearance generation; it is not a limit on the input PDF or the whole process's memory. Structure cleanup resolves named MCIDs and indirect content references. Form content -references are updated to the cleaned copy; unchanged sibling descriptions remain. +references are updated to the cleaned copy; unchanged sibling descriptions remain. The structure +tree's object references (`/OBJR`) to the annotations and form fields `apply` removes are dropped, +as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed comment or field value. +A tree where that can't be done (an element written in place, or one too large to walk) makes +`apply` fail with `RedactError::Tags` rather than leave the annotation reachable. Replacement descriptions are removed only from marked-content spans changed by cleanup. Unchanged spans retain their descriptions; unused property entries are retired during redaction diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index fdac37dbec..c59343c692 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -127,7 +127,8 @@ pub struct Report { pub annotations: usize, pub fields: usize, pub replacement_text: usize, - /// Structure elements that lost alternate/actual text or emptied marked content. + /// Structure elements that lost alternate/actual text or emptied marked content, and + /// structure references to removed annotations. pub tags: usize, /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. @@ -153,6 +154,12 @@ pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec { page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() } +/// Every annotation the pages list. Compared before and after a removal, what is no longer listed +/// leaves the structure tree too ([`tags::drop_object_refs`]). +pub(crate) fn listed_annotations(doc: &Document) -> HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() +} + /// Every redaction mark in the document, page by page. pub fn marks(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -351,6 +358,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); + let listed_before = listed_annotations(doc); for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -523,6 +531,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = listed_before.difference(&listed_annotations(doc)).copied().collect(); + report.tags += tags::drop_object_refs(doc, &gone)?; + // 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout // rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the // redacted value in the file and lets a later relayout put it back. Once redactions are diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1940e376a8..f5dbc503df 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -279,10 +279,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 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); + let listed_before = listed_annotations(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) { @@ -355,7 +352,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); crate::tags::drop_object_refs(doc, &gone)?; let off = hidden_layers(doc); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index da1ecf12ab..16208d8856 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -828,6 +828,89 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); } +/// Issue #2874: applying redactions removes the comment and the form field under a mark, and +/// with them the structure tree's object references (`/OBJR`) to them, so a full rewrite of a +/// tagged PDF can't keep the comment's text or the field's value. The field's widget on another +/// page goes with it; a comment outside the mark keeps its reference. +#[test] +fn applied_redactions_drop_structure_tree_references_to_what_they_remove() { + 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] >> /MarkInfo << /Marked true >> /StructTreeRoot 9 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 16 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a label, a text field's widget and a comment under the mark, a comment outside + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R 8 0 R 15 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "14 0 R").into_bytes(), + // 6 the field, 7 its widget on page 1 (17 is its widget on page 2), 8 the comment + b"<< /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Kids [7 0 R 17 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 3 0 R /Rect [50 225 200 240] /StructParent 1 /AP << /N 13 0 R >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 9 struct tree root, 10 document, 11 form element (direct OBJRs to both widgets), + // 12 annotation element (OBJR to the comment), 18 annotation element for the kept comment + b"<< /Type /StructTreeRoot /K 10 0 R /ParentTree << /Nums [1 11 0 R 2 12 0 R 3 18 0 R 4 11 0 R] >> /ParentTreeNextKey 5 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 9 0 R /K [11 0 R 12 0 R 18 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 10 0 R /Pg 3 0 R /Alt (FIELDSECRETVALUE) /K [<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >> << /Type /OBJR /Obj 17 0 R /Pg 16 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 8 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(), + // 15 the comment outside the mark + b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [250 10 270 30] /StructParent 3 /Contents (Kept note) >>".to_vec(), + // 16 page 2, with the field's second widget + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [17 0 R] /StructParents 4 >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 16 0 R /Rect [50 50 200 65] /StructParent 4 /AP << /N 13 0 R >> >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 15 0 R /Pg 3 0 R >>] >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[40.0, 190.0, 230.0, 245.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.annotations, report.fields), (1, 1), "{report:?}"); + assert!(report.tags >= 3, "the three references to the comment and both widgets are counted: {report:?}"); + // 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"(Name:)") && has(b"(Kept note)"), "the search sees the saved objects"); + assert!(!has(b"COMMENTSECRETNOTE"), "the redacted comment's text survived the save through the structure tree"); + assert!(!has(b"FIELDSECRETVALUE"), "the redacted field's value survived the save through the structure tree"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let widget_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"))); + assert!(!widget_left, "neither of the field's widgets survived the save"); + // The kept comment is still tagged. + let kept = saved.object_numbers().into_iter().any(|n| { + saved.get(ObjRef::new(n, saved.generation(n))).as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| { + saved.resolve(x).as_dict().and_then(|d| d.get(b"Obj")).and_then(Object::as_ref).is_some_and(|r| { + saved.get(r).as_dict().and_then(|d| d.get(b"Contents")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "Kept note") + }) + }), + _ => false, + }) + }); + assert!(kept, "the comment outside the mark keeps its structure reference"); +} + +/// #2874: a structure element written in place that tags a comment under a mark can't be edited +/// without rewriting what holds it, so applying is refused rather than leaving the comment +/// reachable (fail-closed, as in Sanitize). +#[test] +fn applying_redactions_refuses_an_in_place_element_tagging_a_removed_comment() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 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] /Annots [4 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 0 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + b"<< >>".to_vec(), + // 6 the root's only kid, an annotation element written in place + b"<< /Type /StructTreeRoot /K [<< /Type /StructElem /S /Annot /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[190.0, 190.0, 230.0, 230.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); +} + /// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a /// reference the walk never reached could keep a removed annotation. Then it is an error, and /// nothing is changed, rather than success with references left behind (removing only the tree diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..6e0802794a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5740,8 +5740,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed"] -notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; untouched tags stay" +evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed", "crates/redact/src/tests.rs::applied_redactions_drop_structure_tree_references_to_what_they_remove"] +notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; object references (OBJR) to the comments and form fields redaction removes are dropped too (#2874); untouched tags stay" [[feature]] id = "protect.redaction-verification"