Page copies bring only the form fields on the pages copied (#6461)

This commit is contained in:
oracola91 authored and GitHub committed 2026-11-09 08:52:13 +08:00
1 parent 34f9bea0ad
commit d76bbd28f7
3 files changed
+178 -7

No files matched your search

+34 -3
View File
@@ -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<Item = ObjRef>) -> HashSet<ObjRef> {
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::<HashSet<_>>().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<ObjRef, ObjRef> = 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();
+73 -4
View File
@@ -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<ObjRef>,
/// 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<Rc<HashSet<ObjRef>>>,
}
impl<'a> Copier<'a> {
pub(crate) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> 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<ObjRef>) -> Result<Dict, OrganizeError> {
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<Object> = 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<Object> = 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<Object> {
@@ -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?);
+71
View File
@@ -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<String> = 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<String> = 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"));
}