From aa9a9105fc489ccd09333793e7b9d995a88ef6f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 21:07:16 -0400 Subject: [PATCH] fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero (#4571) * fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero * fix(redact): report AcroForm presence in scan() and format --- crates/redact/src/sanitize.rs | 19 ++++++++++++++++--- crates/redact/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 8625f99c2..202ece31c 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::Attachments) } Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), - Hidden::FormFields => pdfcraft_forms::fields(doc).len(), + Hidden::FormFields => { + let n = pdfcraft_forms::fields(doc).len(); + if n > 0 { + n + } else if cat.contains(b"AcroForm") { + 1 + } else { + 0 + } + } Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); @@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0; + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { + cat.remove(b"AcroForm"); + } _ => {} } - let n = count(h); + let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) }; if n > 0 { done.push((h, n)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index cf438d413..fa39853a7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2708,6 +2708,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } +/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no +/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save. +#[test] +fn removing_form_fields_removes_acroform_even_without_terminal_fields() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1)); + assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(!doc.full_save_required()); + + let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_eq!(done, [(Hidden::FormFields, 1)]); + assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); + assert!(doc.full_save_required()); +} + /// 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]