From 68f8b2d5f78f583356c6cd4000567e4b261c3d7a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:55 +0200 Subject: [PATCH] fix(edit): flattening form fields also drops the ones whose widgets are on no page (fixes #1470) (#1480) --- crates/edit/src/flatten.rs | 32 ++++++++++++++++++++++++-------- crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 92cc56d36..7b1865ca8 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -249,33 +249,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = 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) -> 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, on_page: Option<&HashSet>) -> 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, depth: usize) -> bool { + fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, 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 = 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 = + 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 = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect(); + let survivors: Vec = 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)), diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f5b37776b..2ec5f2f49 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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 = 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