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 = "
JapanEnglish\nSpanish
"; @@ -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 { + 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 = 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 = 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 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 { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index cb106de84..39a1c9348 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -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 = 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##" + + + +"##; + 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 = 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 { + 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#" +"#; + 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#""#; + let sign = r#""#; + for (first, second) in [(text, sign), (sign, text)] { + let xdp = format!( + r##" + + + +"## + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let mut top: Vec = 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"[..])); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dc890fb21..c9737046e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc"