fix(redact): remove the widgets flattening keeps when removing form fields

Removing form fields flattens them and then drops the interactive form,
but flattening keeps hidden and no-view widgets (they draw nothing) and
widgets with no appearance. The pages still listed them, so their values
and their parent fields' values survived the full rewrite. Every widget
left after flattening now leaves the pages and the structure tree, inline
annotations included.
This commit is contained in:
aiongg
2026-10-22 13:28:40 -04:00
committed by Dylan M. Taylor
parent ee010fbbe5
commit 3fe9fe218e
2 changed files with 43 additions and 3 deletions
+8 -3
View File
@@ -341,7 +341,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed_before = listed_annotations(doc);
if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 {
let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0;
if fields {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) {
@@ -354,10 +355,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
d.remove(b"AcroForm");
})?;
}
// Annotations: comments, attachments, links, actions.
// Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden
// ones, and ones with nothing to draw), which still hold their field's value and parent.
let drop_annot = |a: &Dict| -> bool {
let s = a.name(b"Subtype").unwrap_or(b"");
(which.contains(&Hidden::Comments) && is_comment(s))
|| (fields && s == b"Widget")
|| (which.contains(&Hidden::Attachments) && s == b"FileAttachment")
|| (which.contains(&Hidden::LinksActionsScripts) && s == b"Link")
};
@@ -374,7 +377,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
let mut kept = Vec::new();
for a in list {
let Some(r) = a.as_ref() else {
kept.push(a);
if !doc.resolve(&a).as_dict().is_some_and(drop_annot) {
kept.push(a);
}
continue;
};
let d = doc.get(r).as_dict().cloned().unwrap_or_default();
+35
View File
@@ -2514,3 +2514,38 @@ fn hidden_text_in_content_that_stops_decoding_is_refused() {
let out = sanitize(&mut doc);
assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}");
}
/// An array of references to objects `nums` (generation 0).
fn ref_array(nums: &[u32]) -> Object {
Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect())
}
/// An interactive form dictionary listing fields `nums`.
fn fields_dict(nums: &[u32]) -> Object {
let mut d = Dict::new();
d.set(b"Fields".to_vec(), ref_array(nums));
Object::Dict(d)
}
/// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them
/// and their values, and their parent field's value, out of the file.
#[test]
fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET",
"",
vec![
// 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget
b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(),
b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"),
],
);
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap();
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap();
remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap();
assert_no_secret(&doc, b"LEAK_");
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
}