diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 636ed61991..6ec4d3bd92 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -10,7 +10,9 @@ //! - link and GoTo destinations that target a copied page point at the copy. Links to pages that //! were not copied are dropped, so they don't dangle; //! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the -//! destination `/AcroForm`; +//! destination `/AcroForm`. When only some of a document's pages are copied, a field brings only +//! the kids that lead to a widget on those pages: the rest of the form stays behind with its +//! values; //! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the //! source structure tree is not copied; //! - named destinations are resolved against the source's name tree and become explicit ones; @@ -18,6 +20,7 @@ //! `/OCProperties`, keeping their default on/off state. use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -34,8 +37,8 @@ pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, }; -use copy::SKIP_ON_PAGE; pub(crate) use copy::{Copier, copy_object}; +use copy::{MAX_COPY_DEPTH, SKIP_ON_PAGE}; use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; pub use fields::{combine_field_conflicts, field_name_conflicts}; @@ -171,6 +174,27 @@ struct Imported { renamed_fields: Vec<(String, String)>, } +/// The annotations on `pages` and, above each widget among them, its fields up to the top one. +/// A field copied with those pages keeps only these kids: its other kids sit on pages left behind, +/// and carry their own values and appearances. +fn fields_on(src: &Document, pages: impl IntoIterator) -> HashSet { + let mut kept = HashSet::new(); + for page in pages { + let page = src.get(page); + let Some(annots) = page.as_dict().and_then(|d| d.get(b"Annots")).map(|a| src.resolve(a)) else { continue }; + for a in annots.as_array().into_iter().flatten() { + let mut at = a.as_ref().or_else(|| src.resolve(a).as_dict().and_then(|d| d.reference(b"Parent"))); + // Up the field tree, as deep as a copy may go, and once: a node seen already has its + // fields above it in the set. + for _ in 0..MAX_COPY_DEPTH { + let Some(r) = at.filter(|r| kept.insert(*r)) else { break }; + at = src.get(r).as_dict().and_then(|d| d.reference(b"Parent")); + } + } + } + kept +} + /// `import_pages`, also returning what was copied from where. fn import_pages_mapped( dst: &mut Document, @@ -199,6 +223,9 @@ fn import_pages_mapped( (*page, new, inherited.clone()) }) .collect(); + if src_pages.iter().collect::>().len() < source.len() { + copier.kept = Some(Rc::new(fields_on(src, copier.pages.keys().copied()))); + } let mut annot_pages = HashMap::new(); let mut new_pages = Vec::new(); let mut done: HashMap = HashMap::new(); @@ -208,7 +235,11 @@ fn import_pages_mapped( // popup, a widget's place). Destinations still map to the pages copied. let repeat = done.contains_key(&page); let new = if repeat { dst.add(Object::Null) } else { new }; - let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone())); + let mut own = repeat.then(|| { + let mut c = Copier::new(src, HashMap::new(), copier.pages.clone()); + c.kept = copier.kept.clone(); + c + }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 5e4be54150..8fed2112b6 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,6 @@ -use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; +use std::rc::Rc; + +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. @@ -10,7 +12,7 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// Real documents stay well under this (a page's resources, fonts and descriptors, nested /// form XObjects and field trees are a few levels each); a chain of thousands of indirect /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; +pub(super) const MAX_COPY_DEPTH: usize = 256; /// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's /// appearance, placement and page. Field state (name, value, flags, default appearance) moves to @@ -56,11 +58,15 @@ pub(crate) struct Copier<'a> { pub(super) fields: Vec, /// Optional content groups copied: (source, destination). pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, + /// When only some pages are copied: the annotations on them and the fields above each, the + /// only kids a copied field keeps (see `fields_on`). `None` keeps every kid. Shared, so the + /// copiers made for a repeated page or an action don't each copy it. + pub(super) kept: Option>>, } impl<'a> Copier<'a> { pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new(), kept: None } } /// Copy an indirect object (once), returning its destination reference. @@ -93,6 +99,9 @@ impl<'a> Copier<'a> { if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { return Ok(o.clone()); } + if self.acts_on_none(o) { + return Ok(Object::Null); + } self.descend()?; let copied = match o { Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), @@ -112,6 +121,8 @@ impl<'a> Copier<'a> { fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + // A form action lists the fields it submits, resets or hides. + let lists_fields = matches!(d.name(b"S"), Some(b"SubmitForm" | b"ResetForm" | b"Hide")); let mut out = Dict::new(); for (k, v) in d.iter() { if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { @@ -122,6 +133,34 @@ impl<'a> Copier<'a> { out.set(k.clone(), v.clone()); continue; } + if is_field + && k.as_slice() == b"Kids" + && let Some(kept) = &self.kept + { + let kids: Vec = self + .src + .resolve(v) + .as_array() + .into_iter() + .flatten() + .filter(|kid| kid.as_ref().is_none_or(|r| kept.contains(&r))) + .cloned() + .collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(kids), None)?); + continue; + } + // Of some of the pages, an action lists only the fields on them: one left behind would + // come along with its value. Fields named by text stay. + if lists_fields + && matches!(k.as_slice(), b"Fields" | b"T") + && let Some(kept) = &self.kept + { + let listed = self.src.resolve(v); + let entries = listed.as_array().cloned().unwrap_or_else(|| vec![v.clone()]); + let entries: Vec = entries.into_iter().filter(|e| e.as_ref().is_none_or(|r| kept.contains(&r))).collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(entries), None)?); + continue; + } out.set(k.clone(), self.copy_value(dst, v, None)?); } if let Some(this) = this { @@ -136,6 +175,34 @@ impl<'a> Copier<'a> { Ok(out) } + /// Whether `o` is a reset or submit action that names only fields left behind (see `kept`). + /// Its copy would list none, and readers (ours too) may take an empty list for every field, + /// which is what an omitted one means (ISO 32000-2 §12.7.6). So it is not copied, nor are the + /// actions after it, as with a link to a page left behind. A list of fields to leave out + /// (Include/Exclude flag set) loses nothing. + fn acts_on_none(&self, o: &Object) -> bool { + let Some(kept) = &self.kept else { return false }; + let held; + let action = match o { + // One copied already is not such an action, and needn't be fetched again. + Object::Ref(r) if !self.map.contains_key(r) => { + held = self.src.resolve(o); + held.as_dict() + } + Object::Dict(d) => Some(d), + _ => None, + }; + let Some(a) = action.filter(|a| matches!(a.name(b"S"), Some(b"ResetForm" | b"SubmitForm"))) else { return false }; + if a.get(b"Flags").and_then(|f| self.src.resolve(f).as_int()).is_some_and(|f| f & 1 != 0) { + return false; + } + let Some(v) = a.get(b"Fields") else { return false }; + let listed = self.src.resolve(v); + // As `copy_dict` reads the list: a lone entry stands for a list of one. + let entries = listed.as_array().map_or(std::slice::from_ref(v), Vec::as_slice); + !entries.is_empty() && entries.iter().all(|e| e.as_ref().is_some_and(|r| !kept.contains(&r))) + } + /// Map a source destination (explicit array or reference to one) to the destination /// document; `None` if it targets a page that was not copied. pub(super) fn map_dest(&self, dest: &Object) -> Option { @@ -196,11 +263,13 @@ impl<'a> Copier<'a> { d.remove(b"A"); } } + } else if self.acts_on_none(&action) { + d.remove(b"A"); } else { // Other actions (URI, Launch, JavaScript…) carry no page references. The // copier lends its map out and takes it back with the new copies in it, // so actions sharing an object share its one copy. - let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let mut c = Copier { kept: self.kept.clone(), ..Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()) }; let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); self.map = c.map; d.set(b"A".to_vec(), copied?); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7eb677a84c..593efd2600 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4241,3 +4241,74 @@ fn an_outline_deeper_than_the_listing_is_exported_as_truncated() { crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); assert_eq!(crate::bookmarks(&target).len(), 33); } + +/// Does any object in `doc` hold the string value `/V (value)`? +fn holds_value(doc: &Document, value: &str) -> bool { + doc.scan_objects() + .into_iter() + .any(|(_, o)| o.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string()).is_some_and(|v| v.to_text() == value)) +} + +/// A field with a widget on each of two pages: extracting one page brings that widget and the field +/// above it, not the other kid with its value. +#[test] +fn extracting_a_page_leaves_the_rest_of_its_form_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [8 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [9 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /T (applicant) /Kids [8 0 R 9 0 R] >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Parent 7 0 R /P 3 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /Parent 7 0 R /P 4 0 R /Rect [0 0 50 20] >>".into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(holds_value(&part, "Ada")); + assert!(!holds_value(&part, "90000"), "the widget on the page left behind came along with its value"); + let form = part.resolve(catalog(&part).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let fields = part.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let applicant = part.resolve(&fields[0]).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(applicant.get(b"Kids").unwrap()).as_array().map(Vec::len), Some(1)); + // With every page, the whole form comes along, as before. + let both = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert!(holds_value(&both, "Ada") && holds_value(&both, "90000")); +} + +/// A form action lists fields by reference: extracting the page with the button doesn't bring +/// the fields it submits, resets or hides from a page left behind, with their values. A reset of +/// only those would list none, which reads as every field: it goes. +#[test] +fn a_form_action_brings_no_field_from_a_page_left_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R 8 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [7 0 R 9 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [8 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (send) /P 3 0 R /Rect [0 0 50 20] \ + /A << /S /SubmitForm /F (https://example.invalid/) /Fields [8 0 R (other)] \ + /Next << /S /ResetForm /Fields [8 0 R] /Next << /S /Hide /T 8 0 R >> >> >> >>" + .into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /P 4 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (clear) /P 3 0 R /Rect [0 30 50 50] \ + /A << /S /ResetForm /Fields [8 0 R] >> /AA << /U << /S /ResetForm /Flags 1 /Fields [8 0 R] >> >> >>" + .into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(!holds_value(&part, "90000"), "a field the action lists came along from the page left behind"); + let submit = part.resolve(annots(&part, 0)[0].get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(submit.get(b"Fields").unwrap()).as_array().map(Vec::len), Some(1), "the field named by text stays"); + assert!(submit.get(b"Next").is_none_or(|n| part.resolve(n).as_dict().is_none()), "the reset after the submit would clear every field"); + let clear = annots(&part, 0).remove(1); + assert!(clear.get(b"A").is_none(), "the clear button would clear every field"); + // A reset of every field but the one left behind still resets them all. + let aa = part.resolve(clear.get(b"AA").unwrap()).as_dict().cloned().unwrap(); + assert!(aa.get(b"U").is_some_and(|u| part.resolve(u).as_dict().is_some())); + // With every page, the actions still list it. + assert!(holds_value(&full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()), "90000")); +}