fix(edit): flattening form fields also drops the ones whose widgets are on no page (fixes #1470) (#1480)

This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-13 00:03:55 -04:00
1 parent 0d300c6a41
commit 68f8b2d5f7
2 files changed
+52 -8

No files matched your search

+24 -8
View File
@@ -249,33 +249,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
})?,
}
}
if !removed_widgets.is_empty() {
prune_fields(doc, &removed_widgets)?;
// A field whose widgets are on no page has nothing to draw, but flattening every field of the
// document must still take it out of the form, or other software keeps seeing one (#1470).
let orphans = matches!(which, Which::Comments { fields: true, .. }) && (0..all.len()).all(|i| pages.contains(&i));
if !removed_widgets.is_empty() || orphans {
let on_page: HashSet<ObjRef> = page_list(doc)
.iter()
.filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()))
.flatten()
.filter_map(|e| e.as_ref())
.collect();
prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?;
}
Ok(drawn)
}
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>) -> Result<(), EditError> {
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With
/// `on_page`, also the widgets (fields without kids) that are on no page.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>) -> Result<(), EditError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let af_ref = af.as_ref();
let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default();
let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
// Returns whether the node survives.
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, depth: usize) -> bool {
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>, depth: usize) -> bool {
if widgets.contains(&r) || depth > 64 {
return depth > 64;
}
let Some(d) = doc.get(r).as_dict().cloned() else { return true };
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true };
let survivors: Vec<Object> = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect();
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else {
return on_page.is_none_or(|p| p.contains(&r));
};
let survivors: Vec<Object> =
kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect();
if survivors.len() != kids.len() {
let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone())));
}
!survivors.is_empty()
}
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect();
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect();
if survivors.len() == fields.len() && widgets.is_empty() {
return Ok(()); // only looked for fields on no page, and there were none
}
af_dict.set(b"Fields".to_vec(), Object::Array(survivors));
match af_ref {
Some(r) => doc.set(r, Object::Dict(af_dict)),
+28
View File
@@ -234,6 +234,34 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// #1470: a form field whose widget is on no page (left behind by page extraction or combining)
/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`,
/// or other software keeps treating the flattened file as a form. A hidden widget on a page is
/// left alone, as before.
#[test]
fn flattening_fields_drops_the_ones_on_no_page() {
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 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>",
"<< /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 on the page is drawn");
let doc = reopen(&doc);
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
let left: Vec<pdfcraft_cos::ObjRef> = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect();
assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew