From 3f5d8d06fe1db56dee14b868fb7e957de884d7d0 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Sun, 25 Oct 2026 10:01:53 -0400
Subject: [PATCH] M9: name a laid-out XFA form's signature fields by their SOM
path, so other viewers find the signature (#4507)
* M9: name a laid-out XFA form's signature fields by their SOM path, so other viewers find the signature
* M9: doc comments on the functions the signature naming touches
* M9: say other viewers in the signature naming's comments
* M9: a new layout keeps a signature a generated signature box holds
* M9: the re-layout test checks the signature's picture is kept too
* M9: a signature field named by its SOM path doesn't repeat a name a field already has
* M9: every name a field of the pass takes is known before a signature field is named, in either order
* M9: the signed-box test's doc comment is back on its test
A merge had left it above the next test, which has its own.
---
crates/automation/tests/it/automation.rs | 22 +++
crates/engine/src/tests.rs | 5 +-
crates/xfa/README.md | 8 +-
crates/xfa/src/lib.rs | 2 +-
crates/xfa/src/pdf.rs | 140 +++++++++++++--
crates/xfa/src/script.rs | 70 ++++----
crates/xfa/src/tests.rs | 218 ++++++++++++++++++++++-
parity/acrobat-features.toml | 4 +-
8 files changed, 422 insertions(+), 47 deletions(-)
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 54c3d6272..565763709 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -5848,6 +5848,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
assert!(inked(60, 110), "the strokes are drawn");
}
+/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
+/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
+/// signed file reopens with the signature intact and on that field.
+#[test]
+fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
+ let dir = workdir("xfa-sign");
+ std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap();
+ let mut a = auto(&dir);
+ ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
+ let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
+ let som = "form[0].page1[0].sign[0]";
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}");
+ ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" }));
+ let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
+ let list = ok(&mut a, "sign_list", json!({ "doc": signed }));
+ let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}"));
+ assert_eq!(s["field"], som, "{s}");
+ assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
#[test]
fn digital_ids_signing_and_validation_through_tools() {
let dir = workdir("signing");
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index a9b5318e1..3a4d5d857 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -5667,6 +5667,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
+/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take
+/// values that scripts read, and are saved into the datasets.
#[test]
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
let data = "
";
@@ -5686,7 +5688,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
- assert_eq!(field(&s, "sign").kind, K::Signature);
+ // The signature field is named by its SOM path, as another viewer finds a signature.
+ assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature);
// Scripts read a choice list's saved value.
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
// Choosing another item recalculates; the list takes several values; a typed value is kept
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 23a6ef0d0..2d08a74bb 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -38,8 +38,11 @@ that warning and keeps the existing layout and accepted widget values.
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
format and keystroke actions from the picture clause), check boxes and radio groups (from
`exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their
- scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps
- its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
+ scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as
+ fields under one for each step above it) because other viewers lay a dynamic form out again
+ and find a signature by that name. A box whose path, or a part of it, the file already gives
+ another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to
+ generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
@@ -82,6 +85,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi
| `/PCSom` | generated fields | the field's SOM path, so values go back to the data |
| `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again |
| `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written |
+ | `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds |
| `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` |
## API sketch
diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs
index 41a250a54..7e63218c9 100644
--- a/crates/xfa/src/lib.rs
+++ b/crates/xfa/src/lib.rs
@@ -99,7 +99,7 @@ pub use data::{
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
-pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
+pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use picture::typed_date;
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text,
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index bc5b4d2da..23e21fc33 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -2,7 +2,7 @@
//! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance
//! streams are left empty here; the forms layer regenerates them from the field dictionaries.
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{literal, win_ansi};
@@ -21,6 +21,10 @@ pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout";
/// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the
/// field's SOM path.
pub const CLICK_KEY: &[u8] = b"PCXfaClick";
+/// Private key on the fields generated to name a signature field by its SOM path, one for each
+/// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the
+/// fields they hold.
+pub const NODE_KEY: &[u8] = b"PCXfaNode";
/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
/// markers). `None` for anything else.
@@ -264,6 +268,11 @@ struct Emitter<'a> {
warnings: Vec,
/// The signatures the file holds, by the SOM path of the field signed.
signed: HashMap,
+ /// The full names of the fields the file came with, which a generated name must not repeat.
+ taken: HashSet,
+ /// The fields above the signature fields named by their SOM paths, by the path they name:
+ /// each one's object and the fields it holds.
+ nodes: HashMap)>,
}
/// A signature field of the file that has been signed.
@@ -279,13 +288,16 @@ const MAX_FIELDS_SEARCHED: usize = 20_000;
/// Deepest field nesting followed.
const MAX_FIELD_DEPTH: usize = 32;
-/// The signed signature fields the document came with, by full name. Signing applications name
-/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a
-/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read.
-fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap {
+/// The fields the document came with: the full name of each, and the signed signature fields by
+/// SOM path. Signing applications name a dynamic form's fields by their SOM path
+/// (`form1[0].page1[0].sign[0]`), which is how a laid-out signature box finds its signature. A
+/// field an earlier layout pass generated gives no name, but a signature on it is kept, by its
+/// `/PCSom`, so a new layout keeps the box signed.
+fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, HashSet) {
let mut out = HashMap::new();
- let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out };
- let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out };
+ let mut names = HashSet::new();
+ let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) };
+ let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) };
let mut stack: Vec<(Object, String, bool, usize)> =
fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect();
let mut seen = 0usize;
@@ -296,7 +308,22 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap {
}
let field = doc.resolve(&field);
let Some(d) = field.as_dict() else { continue };
+ if d.contains(NODE_KEY) {
+ // Generated to name a signature field: only what it holds is looked at.
+ if depth < MAX_FIELD_DEPTH {
+ let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
+ stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1)));
+ }
+ continue;
+ }
if d.contains(SOM_KEY) {
+ let som = d.get(SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()));
+ if d.name(b"FT") == Some(b"Sig")
+ && let Some(som) = som
+ && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some())
+ {
+ out.entry(som).or_insert(Signed { value: value.clone(), appearance: d.get(b"AP").cloned() });
+ }
continue;
}
let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) {
@@ -304,6 +331,9 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap {
Some(t) => format!("{prefix}.{t}"),
None => prefix,
};
+ if !name.is_empty() {
+ names.insert(name.clone());
+ }
let is_sig = match d.name(b"FT") {
Some(ft) => ft == b"Sig",
None => parent_is_sig,
@@ -318,7 +348,7 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap {
stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1)));
}
}
- out
+ (out, names)
}
impl Emitter<'_> {
@@ -363,6 +393,9 @@ impl Emitter<'_> {
ff |= 1;
}
let mut top_level = true;
+ // The SOM path of the generated field above this one, for a signature field named by its
+ // SOM path.
+ let mut under: Option = None;
match &w.kind {
WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => {
d.set(b"FT".to_vec(), Object::name("Tx"));
@@ -464,6 +497,16 @@ impl Emitter<'_> {
}
ff |= 1;
}
+ if let Some((above, last)) = self.som_steps(&w.som)
+ && let Some(parent) = self.node(&above)
+ {
+ // Named by its SOM path, as fields under one for each step above it: other
+ // viewers lay a dynamic form out again and find a signature by that name.
+ d.set(b"T".to_vec(), Object::String(PdfString::text(&last)));
+ d.set(b"Parent".to_vec(), Object::Ref(parent));
+ top_level = false;
+ under = Some(above.join("."));
+ }
}
WidgetKind::Image => {
// A push button with nothing drawn: the forms layer treats one so marked as an
@@ -574,6 +617,12 @@ impl Emitter<'_> {
if top_level {
self.fields.push(r);
self.field_count += 1;
+ } else if let Some(path) = under {
+ if let Some((_, kids)) = self.nodes.get_mut(&path) {
+ kids.push(r);
+ }
+ self.taken.insert(w.som.clone());
+ self.field_count += 1;
} else if let WidgetKind::Radio { group, .. } = &w.kind
&& let Some((_, kids)) = self.radio_groups.get_mut(group)
{
@@ -582,6 +631,60 @@ impl Emitter<'_> {
Ok(Some(r))
}
+ /// The steps of SOM path `som` above the last, and the last, when a field can be named by the
+ /// path: two to [`MAX_FIELD_DEPTH`] steps, none empty, and no field of the file or of this
+ /// pass already named by the path or by a part of it. `None` keeps the field's own name.
+ fn som_steps(&self, som: &str) -> Option<(Vec, String)> {
+ let steps: Vec<&str> = som.split('.').collect();
+ if steps.len() < 2 || steps.len() > MAX_FIELD_DEPTH || steps.iter().any(|s| s.is_empty()) {
+ return None;
+ }
+ for i in 1..=steps.len() {
+ let path = steps.get(..i)?.join(".");
+ // A part already generated by this pass is shared; one the file has is not touched.
+ let generated = i < steps.len() && self.nodes.contains_key(&path);
+ if !generated && self.taken.contains(&path) {
+ return None;
+ }
+ }
+ let (last, above) = steps.split_last()?;
+ Some((above.iter().map(|s| s.to_string()).collect(), last.to_string()))
+ }
+
+ /// The generated field named by the SOM path of `steps`, made (with those above it) when this
+ /// pass has none yet; the topmost goes into the AcroForm's fields. `None` for no steps.
+ fn node(&mut self, steps: &[String]) -> Option {
+ let mut parent: Option<(ObjRef, String)> = None;
+ for step in steps {
+ let path = match &parent {
+ Some((_, above)) => format!("{above}.{step}"),
+ None => step.clone(),
+ };
+ let r = match self.nodes.get(&path) {
+ Some((r, _)) => *r,
+ None => {
+ let mut d = Dict::new();
+ d.set(b"T".to_vec(), Object::String(PdfString::text(step)));
+ d.set(b"Kids".to_vec(), Object::Array(Vec::new()));
+ d.set(NODE_KEY.to_vec(), Object::Bool(true));
+ if let Some((p, _)) = &parent {
+ d.set(b"Parent".to_vec(), Object::Ref(*p));
+ }
+ let r = self.doc.add(Object::Dict(d));
+ match parent.as_ref().and_then(|(_, above)| self.nodes.get_mut(above)) {
+ Some((_, kids)) => kids.push(r),
+ None => self.fields.push(r),
+ }
+ self.nodes.insert(path.clone(), (r, Vec::new()));
+ self.taken.insert(path.clone());
+ r
+ }
+ };
+ parent = Some((r, path));
+ }
+ parent.map(|(r, _)| r)
+ }
+
/// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs
/// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
/// for anything else.
@@ -908,6 +1011,9 @@ fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> {
out
}
+/// Write laid-out `form` into `doc`: its pages replace the document's, its widgets join the
+/// AcroForm's fields (a box the file holds a signature for keeps it), and the AcroForm is marked
+/// laid out. What was written, with any warnings, comes back.
pub fn write_form(doc: &mut Document, form: &Form) -> Result {
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
@@ -925,7 +1031,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result
};
// Pictures embedded by earlier passes, from the pages this pass replaces.
let images = earlier_images(doc, pages_ref);
- let signed = signed_fields(doc, &catalog);
+ let (signed, taken) = existing_fields(doc, &catalog);
let mut em = Emitter {
doc,
images,
@@ -935,7 +1041,17 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result
field_count: 0,
warnings: Vec::new(),
signed,
+ taken,
+ nodes: HashMap::new(),
};
+ // Every name a field of this pass takes at the top, wherever and in whatever order it is
+ // written: a signature field named by its SOM path must not repeat one.
+ for w in form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }) {
+ em.taken.insert(match &w.kind {
+ WidgetKind::Radio { group, .. } => group.clone(),
+ _ => w.name.clone(),
+ });
+ }
let mut kids = Vec::new();
for page in &form.pages {
kids.push(Object::Ref(em.page(page, pages_ref)?));
@@ -950,6 +1066,10 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result
}
})?;
}
+ let nodes: Vec<(ObjRef, Vec)> = em.nodes.values().cloned().collect();
+ for (node, kids) in nodes {
+ em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?;
+ }
let Emitter { doc, fields, field_count, warnings, .. } = em;
let count = kids.len();
doc.update_dict(pages_ref, |d| {
@@ -1174,7 +1294,7 @@ pub fn strip(doc: &mut Document) -> Result {
out.image_fields += 1;
}
let private: Vec> =
- [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
+ [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
if !private.is_empty() {
out.field_keys += private.len();
doc.update_dict(r, |d| {
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 80fb1db33..6946aa9b1 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -586,13 +586,31 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}
- // The AcroForm keeps only the fields PdfCraft did not generate. Any generated fields edited in
- // this overlay (along with their appearance streams and widget kids) are freed so a replaced
- // password widget's value or masked appearance cannot stay in the overlay.
+ // The fields an earlier pass generated give way to the new layout's, after it is written: it
+ // keeps a signature one of them holds. The AcroForm keeps every field PdfCraft did not generate.
let Some(root) = doc.root() else { return Err(XfaError::NotXfa) };
- if let Some((acro_ref, mut acro)) = acroform(doc) {
- let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
- let mut kept = Vec::new();
+ let top = |doc: &Document| -> Vec