From 750bbe27f1864bcb42b1cc1745256ae91d6db729 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:21 +0000 Subject: [PATCH] fix(redact): clean the structure tree after removing hidden content Removing hidden text or hidden layers rewrote page and Form content but left the structure tree alone, unlike applying redactions. An element's ActualText, Alt or expansion text still described the removed content, and a marked-content reference into a Form kept the original Form, with the removed text, in the full rewrite. The hidden-content pass now cleans the tags of what each page no longer shows and moves Form references to the rewritten copy, refusing when the tree can't be cleaned completely. --- crates/redact/src/sanitize.rs | 13 ++++++++++++- crates/redact/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 41ef96cccf..fea0449512 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -254,7 +254,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -270,6 +270,17 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4d2cc76017..5c0eece859 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2549,3 +2549,31 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert_no_secret(&doc, b"LEAK_"); assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } + +/// Removing hidden text cleans the structure tree as redaction does: the elements describing the +/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. +#[test] +fn removing_hidden_text_cleans_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", + ), + b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), + b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); +}