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.
This commit is contained in:
Eric Kaden
2026-10-25 10:01:53 -04:00
committed by GitHub
parent b6fedc09f0
commit 3f5d8d06fe
8 changed files with 422 additions and 47 deletions
+22
View File
@@ -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");
+4 -1
View File
@@ -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 = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
@@ -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
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+130 -10
View File
@@ -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<String>,
/// The signatures the file holds, by the SOM path of the field signed.
signed: HashMap<String, Signed>,
/// The full names of the fields the file came with, which a generated name must not repeat.
taken: HashSet<String>,
/// 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<String, (ObjRef, Vec<ObjRef>)>,
}
/// 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<String, Signed> {
/// 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<String, Signed>, HashSet<String>) {
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<String, Signed> {
}
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<String, Signed> {
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<String, Signed> {
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<String> = 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>, 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<ObjRef> {
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<Written, XfaError> {
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<Written, XfaError>
};
// 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<Written, XfaError>
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<Written, XfaError>
}
})?;
}
let nodes: Vec<(ObjRef, Vec<ObjRef>)> = 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<Stripped, XfaError> {
out.image_fields += 1;
}
let private: Vec<Vec<u8>> =
[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| {
+40 -30
View File
@@ -586,13 +586,31 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
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<Object> {
acroform(doc).and_then(|(_, acro)| acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned())).unwrap_or_default()
};
let generated: std::collections::HashSet<pdfcraft_cos::ObjRef> = top(doc)
.iter()
.filter_map(Object::as_ref)
.filter(|r| doc.get(*r).as_dict().is_some_and(|d| d.contains(crate::pdf::SOM_KEY) || d.contains(crate::pdf::NODE_KEY)))
.collect();
let written = crate::pdf::write_form(doc, &form)?;
if !generated.is_empty()
&& let Some((acro_ref, mut acro)) = acroform(doc)
{
let kept: Vec<Object> = top(doc).into_iter().filter(|f| f.as_ref().is_none_or(|r| !generated.contains(&r))).collect();
acro.set(b"Fields".to_vec(), Object::Array(kept));
match acro_ref {
Some(r) => doc.set(r, Object::Dict(acro)),
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
}
// A generated field edited in this overlay is freed with its appearance and widget kids,
// so a replaced password widget's value or masked appearance cannot stay in the overlay.
// Signature fields, and the fields that name them by their SOM path, stay: the new
// layout's signature field took their value and appearance.
let free_ap = |doc: &mut Document, d: &Dict| {
if let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned())
&& let Some(n) = ap.get(b"N").and_then(Object::as_ref)
@@ -601,36 +619,28 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
doc.free(n);
}
};
for f in fields {
if let Some(r) = f.as_ref()
&& let Some(d) = doc.get(r).as_dict().cloned()
&& d.contains(crate::pdf::SOM_KEY)
{
free_ap(doc, &d);
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
for k in kids.iter().filter_map(Object::as_ref) {
if let Some(kd) = doc.get(k).as_dict().cloned() {
free_ap(doc, &kd);
}
if doc.is_edited(k.num) {
doc.free(k);
let signature = |d: &Dict| d.name(b"FT") == Some(b"Sig".as_slice()) || d.contains(crate::pdf::NODE_KEY);
for r in generated {
let Some(d) = doc.get(r).as_dict().cloned().filter(|d| !signature(d)) else { continue };
free_ap(doc, &d);
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
for k in kids.iter().filter_map(Object::as_ref) {
if let Some(kd) = doc.get(k).as_dict().cloned() {
if signature(&kd) {
continue;
}
free_ap(doc, &kd);
}
if doc.is_edited(k.num) {
doc.free(k);
}
}
if doc.is_edited(r.num) {
doc.free(r);
}
} else {
kept.push(f);
}
if doc.is_edited(r.num) {
doc.free(r);
}
}
acro.set(b"Fields".to_vec(), Object::Array(kept));
match acro_ref {
Some(r) => doc.set(r, Object::Dict(acro)),
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
}
}
let written = crate::pdf::write_form(doc, &form)?;
let mut warnings = form.warnings;
warnings.extend(written.warnings);
if written.usage_rights {
+217 -1
View File
@@ -1508,6 +1508,8 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
.collect()
}
/// Choice lists, a password, a signature and pictures in the template become their kinds of
/// widget, and their AcroForm fields and image XObjects once written.
#[test]
fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
let form = layout_xml(&fields_template("")).unwrap();
@@ -1570,7 +1572,11 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string");
assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box");
assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field");
assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice()));
// The signature field is named by its SOM path, under a generated field for each step above it.
assert!(fields.iter().all(|(name, _)| name != "sign"));
let named = full_names(&doc);
let sign = named.iter().find(|(name, _)| name == "form[0].page1[0].sign[0]").map(|(_, d)| d).expect("named by its SOM path");
assert_eq!(sign.name(b"FT"), Some(b"Sig".as_slice()));
let streams: Vec<pdfcraft_cos::Stream> = doc
.object_numbers()
.into_iter()
@@ -3009,6 +3015,213 @@ fn a_zero_caption_reserve_sizes_the_caption_as_none_does() {
assert!((x_of("withZero") - x_of("without")).abs() < 0.01, "as without a reserve: {} {}", x_of("withZero"), x_of("without"));
}
/// The terminal fields by full name, down the field tree.
fn full_names(doc: &Document) -> Vec<(String, Dict)> {
let root = doc.get(doc.root().unwrap());
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let mut todo: Vec<(Object, String)> =
doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().map(|f| (f.clone(), String::new())).collect();
let mut out = Vec::new();
while let Some((f, prefix)) = todo.pop() {
let d = doc.resolve(&f).as_dict().unwrap().clone();
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text()));
let name = match t {
Some(t) if prefix.is_empty() => t,
Some(t) => format!("{prefix}.{t}"),
None => prefix,
};
match d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
Some(kids) if kids.iter().any(|k| doc.resolve(k).as_dict().is_some_and(|k| k.contains(b"T"))) => {
todo.extend(kids.into_iter().map(|k| (k, name.clone())));
}
_ => out.push((name, d)),
}
}
out
}
/// A laid-out signature field is named by its SOM path, under a generated field for each step
/// above it, as other viewers lay a dynamic form out again and find its signature by that name.
/// Boxes in one subform share those fields; a new layout replaces them rather than adding more;
/// taking the XFA out leaves them as ordinary fields.
#[test]
fn signature_fields_are_named_by_their_som_path() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="body" layout="tb" w="7.5in">
<field name="name" w="3in" h="0.3in"><ui><textEdit/></ui></field>
<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>
<field name="other" w="3in" h="1in"><ui><signature/></ui></field>
</subform>
<subform name="end" layout="tb" w="7.5in">
<field name="last" w="3in" h="1in"><ui><signature/></ui></field>
</subform>
</subform>
</template>
</xdp:xdp>
"##;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
let report = render_into(&mut doc).unwrap();
assert_eq!(report.fields, 4, "name and the three signature fields");
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let check = |doc: &Document| {
let top: Vec<String> = pdfcraft_forms_free_fields(doc).into_iter().map(|(name, _)| name).collect();
assert_eq!(top.iter().filter(|n| *n == "form[0]").count(), 1, "one field for the root subform: {top:?}");
assert!(top.iter().any(|n| n == "name"), "other fields keep their names: {top:?}");
let named = full_names(doc);
for som in ["form[0].body[0].sign[0]", "form[0].body[0].other[0]", "form[0].end[0].last[0]"] {
let (_, d) = named.iter().find(|(name, _)| name == som).unwrap_or_else(|| panic!("{som} in {named:?}"));
assert_eq!(d.name(b"FT"), Some(b"Sig".as_slice()));
assert_eq!(fields_by_som(doc).get(som).map(String::as_str), Some(som), "the data finds it by its SOM path");
}
assert_eq!(named.len(), 4, "{named:?}");
};
check(&doc);
let body = |doc: &Document| {
let named = full_names(doc);
let parent = |som: &str| named.iter().find(|(name, _)| name == som).and_then(|(_, d)| d.get(b"Parent").and_then(Object::as_ref));
(parent("form[0].body[0].sign[0]"), parent("form[0].body[0].other[0]"))
};
let (a, b) = body(&doc);
assert!(a.is_some() && a == b, "the two boxes of one subform share its field");
// Laid out again: the same fields, not a second set.
let mut doc = doc;
let tpl = crate::script::template_of(&doc).unwrap();
rerender(&mut doc, &tpl).unwrap();
check(&doc);
// The XFA taken out: ordinary fields with the same names, without the private key.
strip(&mut doc).unwrap();
let named = full_names(&doc);
assert!(named.iter().any(|(name, _)| name == "form[0].end[0].last[0]"), "{named:?}");
let top = pdfcraft_forms_free_fields(&doc);
let node = top.iter().find(|(name, _)| name == "form[0]").map(|(_, d)| d.clone()).expect("the root subform's field");
assert!(!node.contains(NODE_KEY));
}
/// The terminal field named `full`, down the field tree.
fn field_ref(doc: &Document, full: &str) -> Option<pdfcraft_cos::ObjRef> {
let root = doc.get(doc.root()?);
let acro = doc.resolve(root.as_dict()?.get(b"AcroForm")?);
let mut todo: Vec<(pdfcraft_cos::ObjRef, String)> =
doc.resolve(acro.as_dict()?.get(b"Fields")?).as_array()?.iter().filter_map(Object::as_ref).map(|r| (r, String::new())).collect();
while let Some((r, prefix)) = todo.pop() {
let d = doc.get(r).as_dict()?.clone();
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())).unwrap_or_default();
let name = if prefix.is_empty() { t } else { format!("{prefix}.{t}") };
if name == full {
return Some(r);
}
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
todo.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, name.clone())));
}
None
}
/// A box signed on a laid-out form stays signed when the form is laid out again (as after a
/// script adds a row): the new layout's field, named by the same SOM path, holds the same
/// signature and is locked, and the old field is gone from the AcroForm.
#[test]
fn a_signature_on_a_laid_out_box_survives_a_new_layout() {
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
render_into(&mut doc).unwrap();
let som = "form[0].page1[0].sign[0]";
let old = field_ref(&doc, som).expect("the box");
// Signed as signing does it: a signature dictionary as the field's value, and a picture.
let mut sig = Dict::new();
sig.set(b"Type".to_vec(), Object::name("Sig"));
let sig = doc.add(Object::Dict(sig));
let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec())));
doc.update_dict(old, |d| {
d.set(b"V".to_vec(), Object::Ref(sig));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(picture));
d.set(b"AP".to_vec(), Object::Dict(ap));
})
.unwrap();
let tpl = crate::script::template_of(&doc).unwrap();
rerender(&mut doc, &tpl).unwrap();
let new = field_ref(&doc, som).expect("still named by its SOM path");
assert_ne!(new, old, "a field of the new layout");
let d = doc.get(new).as_dict().unwrap().clone();
assert_eq!(d.get(b"V").and_then(Object::as_ref), Some(sig), "the same signature");
let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref)));
assert_eq!(shown, Some(picture), "and the same picture");
assert!(matches!(&*doc.get(picture), Object::Stream(_)), "which is still in the file");
assert!(matches!(&*doc.get(sig), Object::Dict(_)), "as is the signature");
assert_eq!(d.int(b"Ff").unwrap_or(0) & 1, 1, "a signed box is not signed again");
let top = pdfcraft_forms_free_fields(&doc);
assert_eq!(top.iter().filter(|(name, _)| name == "form[0]").count(), 1, "the old fields are gone");
}
/// A box right in the form's root subform, signed and then laid out again, keeps its signature
/// and picture in the file: its field sits just under the one naming it, and the old fields that
/// give way to the new layout's (freed when this session made them) take nothing it still uses.
#[test]
fn a_signed_box_in_the_root_subform_keeps_its_picture_through_a_new_layout() {
let xdp = r#"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb"><pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>
</subform></template></xdp:xdp>"#;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
render_into(&mut doc).unwrap();
let som = "form[0].sign[0]";
let old = field_ref(&doc, som).expect("the box");
let sig = doc.add(Object::Dict(Dict::new()));
let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec())));
doc.update_dict(old, |d| {
d.set(b"V".to_vec(), Object::Ref(sig));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(picture));
d.set(b"AP".to_vec(), Object::Dict(ap));
})
.unwrap();
let tpl = crate::script::template_of(&doc).unwrap();
rerender(&mut doc, &tpl).unwrap();
let new = field_ref(&doc, som).expect("still named by its SOM path");
let d = doc.get(new).as_dict().unwrap().clone();
let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref)));
assert_eq!(shown, Some(picture));
assert!(matches!(&*doc.get(picture), Object::Stream(_)), "the picture is still in the file");
assert!(matches!(&*doc.get(sig), Object::Dict(_)), "and the signature");
}
/// A field of the same pass keeps a signature field named by its SOM path from repeating its
/// name, written before the box or after it: with a text field named `form[0]` (as a hostile
/// template may name one), the box keeps its own name and no two fields share one.
#[test]
fn a_signature_field_does_not_repeat_a_name_another_field_has() {
let text = r#"<field name="form[0]" w="3in" h="0.3in"><ui><textEdit/></ui></field>"#;
let sign = r#"<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>"#;
for (first, second) in [(text, sign), (sign, text)] {
let xdp = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="body" layout="tb" w="7.5in">{first}{second}</subform>
</subform>
</template>
</xdp:xdp>
"##
);
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
render_into(&mut doc).unwrap();
let mut top: Vec<String> = pdfcraft_forms_free_fields(&doc).into_iter().map(|(name, _)| name).collect();
assert!(top.iter().any(|n| n == "sign"), "the box keeps its own name: {top:?}");
top.sort();
let len = top.len();
top.dedup();
assert_eq!(top.len(), len, "no name twice: {top:?}");
}
}
/// A signature box the file already holds a signature for shows that signature, the file's own
/// objects, and is locked; the other box stays unsigned.
#[test]
fn a_signed_signature_box_shows_the_signature_the_file_holds() {
let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap();
@@ -3017,7 +3230,10 @@ fn a_signed_signature_box_shows_the_signature_the_file_holds() {
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
let laid: Vec<_> = fields.iter().map(|f| doc.resolve(f)).filter(|f| f.as_dict().unwrap().contains(SOM_KEY)).collect();
// The file has a field named `form[0]` already: the unsigned box keeps its own name rather
// than repeat that one.
let (signed, unsigned) = (laid[0].as_dict().unwrap(), laid[1].as_dict().unwrap());
assert_eq!(unsigned.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text()).as_deref(), Some("other"));
// The signature dictionary and the picture are the file's own objects, not copies.
let value = doc.resolve(signed.get(b"V").unwrap());
assert_eq!(value.as_dict().unwrap().name(b"Type"), Some(&b"Sig"[..]));
File diff suppressed because one or more lines are too long