fix: Issue #4017. Flattening removes empty fields it cannot draw (#4033)

* M6: flattening removes empty fields it cannot draw (#4017)

Flattening kept every widget without an appearance so that its value
was not lost with nothing drawn in its place (#1794). A field without a
value has nothing to lose, yet it stayed in /AcroForm /Fields and on the
page, so a flattened file could still be filled in.

A widget without an appearance now stays only when its field has a
value: its /V, or the nearest one inherited through /Parent. A missing
value, an empty string or an empty array is no value. Comments and
valued widgets are unchanged.

Fixes #4017

* M6: flattening keeps a widget whose value is past the parent limit (#4017)

has_value walks at most 64 levels up /Parent. When it ran out it said
"no value", so a widget whose /V sat on the 64th ancestor was removed.
Running out now counts as a value: one that wasn't checked must not be
removed.
This commit is contained in:
Ridwan
2026-10-23 10:42:08 -04:00
committed by GitHub
parent 4825b67d15
commit 9b5624afc1
2 changed files with 76 additions and 4 deletions
+27 -4
View File
@@ -37,6 +37,28 @@ fn appearance(doc: &Document, d: &Dict) -> Option<ObjRef> {
}
}
/// Whether a widget's field has a value: its `/V`, or the nearest one inherited through `/Parent`
/// (ISO 32000-2 §12.7.4.1). An empty string or array is no value. Past the depth limit the answer
/// is yes: a value that wasn't checked must not be removed.
fn has_value(doc: &Document, widget: &Dict) -> bool {
let mut d = widget.clone();
let mut seen = HashSet::new();
for _ in 0..64 {
if let Some(v) = d.get(b"V") {
return match &*doc.resolve(v) {
Object::Null => false,
Object::String(s) => !s.to_text().is_empty(),
Object::Array(a) => !a.is_empty(),
_ => true,
};
}
let Some(parent) = d.reference(b"Parent").filter(|p| seen.insert(*p)) else { return false };
let Some(next) = doc.get(parent).as_dict().cloned() else { return false };
d = next;
}
true
}
/// Algorithm 8.1: the matrix that maps the form's bounding box onto the annotation rectangle.
fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
let bbox = nums(doc, form.get(b"BBox")).filter(|b| b.len() == 4)?;
@@ -220,13 +242,14 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
// left on the page. A widget stays when it has no appearance to draw at all (none
// stored, or none for its selected state): removing it would lose the field's value
// with nothing in its place (#1794). One whose appearance draws nothing (an empty
// stream) shows nothing anywhere, so flattening still removes it.
// stream) shows nothing anywhere, so flattening still removes it, and so does one whose
// field has no value: there is nothing to lose (#4017).
if !drew {
let no_appearance = appearance(doc, d).is_none();
if no_appearance && let Some(r) = entry.as_ref() {
let keep = appearance(doc, d).is_none() && (!widget || has_value(doc, d));
if keep && let Some(r) = entry.as_ref() {
undrawn.insert(r);
}
if !widget || no_appearance {
if !widget || keep {
continue;
}
}
+49
View File
@@ -611,6 +611,55 @@ fn flattening_keeps_a_valued_widget_it_cannot_draw() {
assert_eq!(value(6), Some(Object::name("Yes")));
}
/// #4017: a widget with no appearance whose field has no value (none, or an empty string) has
/// nothing to lose, so flattening removes it instead of leaving a fillable field behind. One whose
/// value it inherits from its parent field still stays (#1794).
#[test]
fn flattening_removes_an_empty_widget_it_cannot_draw() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 9 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (no_value) /Rect [10 10 150 40] /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /V () /Rect [10 50 150 80] /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /Rect [10 90 150 120] /F 4 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /V (Ada) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn");
let doc = reopen(&doc);
let refs = |o: &Object| -> Vec<u32> { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() };
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [9], "only the widget with an inherited value stays on the page");
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [8], "and its field in the form");
}
/// #4017: when the walk up the parent fields reaches its limit before finding a value, the widget
/// stays: an unverified value must not be removed. Here `/V` sits on the 64th ancestor.
#[test]
fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() {
let page = stream("", " ");
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [69 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
page,
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 150 40] /F 4 >>".into(),
];
objs.extend((6..69).map(|n| format!("<< /Parent {} 0 R /Kids [{} 0 R] >>", n + 1, n - 1)));
objs.push("<< /FT /Tx /T (deep) /V (Ada) /Kids [68 0 R] >>".into());
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
flatten(&mut doc, &[0], false, true).unwrap();
let doc = reopen(&doc);
let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a).as_array().map_or(0, Vec::len));
assert_eq!(annots, Some(1), "the widget stays");
}
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
/// is flattened; the comment's pop-up still goes with it.
#[test]