M12: resolve XFA use/usehref prototypes before parsing the template

Designer style sheets give fields and draws their size, anchor, borders,
fills, fonts and caption placement through usehref references to elements
in a <proto> subform (XFA 3.3, chapter 6, "Prototypes"). The parser did
not follow them, so such forms lost their section bars, input boxes and
check-box placement.

The new crates/xfa/src/proto.rs writes the template again with every
reference into the same template merged in, and parse() reads that:
- references: usehref "#id", "#som(expr)" (with or without the legacy
  "."), and use "#id" or a SOM expression; usehref wins over use;
- rules from "Overriding Prototype Properties": own attributes, data
  content and children win; id and name are not inherited; children
  match by class and position, and a matched prototype child is the
  prototype of the element's own; a ui widget or value kind and rich
  text come from the nearest layer that has one;
- prototypes in other documents are not followed (no file or network
  access); a missing target, a loop (Example 6.8) or a long chain is
  left out with a warning;
- caps: 32 merged layers, 256 levels of nesting, 2 million elements of
  SOM search, and growth to 4x the template (1 MB floor, 64 MB ceiling);
  past a cap the template is read unresolved, with a warning.

The resolver is ported from the closed #1566 by EULArule, who invited
reuse, and changed: the name attribute is no longer inherited, use takes
SOM expressions, ids and SOM look-ups are indexed and cached under one
search budget, loops through ancestors are cut, the output cap is
relative, every fallback is a warning, and the saved form state is
still read from the original XDP.

Part of #3432.

Co-authored-by: EULA <90616501+EULArule@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
committed 2026-10-30 07:25:51 +08:00
1 parent 726ecf2625
commit 8e85e0b7ab
6 files changed
+663 -3

No files matched your search

+17
View File
@@ -27,6 +27,18 @@ that warning and keeps the existing layout and accepted widget values.
text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
leaders, `columnWidths` and `colSpan`.
- **Prototypes** (`proto`, XFA 3.3 chapter 6, "Prototypes"): before parsing, every `use` and
`usehref` reference into the same template is resolved and the template is written again
with it merged in, so Designer style sheets (`usehref=".#som($template.#subform.styles.X)"`)
give their fields and draws size, anchor, borders, fills, fonts and captions. The element's
own attributes, content and children win; `id` and `name` are not inherited; children match
by class and position, a matched prototype child being the prototype of the element's own;
a `ui` widget or `value` kind and rich text are taken from the nearest layer that has one.
References are `#id`, `#som(…)` (or `.#…`) and, for `use`, a SOM expression. Loops are cut
where they close, chains merge at most 32 layers, nesting stops at 256 elements, SOM look-ups
share a budget of 2 million elements, and the written template may grow to 4 times its size
(at least 1 MB, at most 64 MB); past a budget the template is read unresolved. Each case is a
warning.
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
container without a width is as wide as its content; flow across content areas and pages with
page masters chosen by occurrence; a table's header row repeats after a break; presence
@@ -159,6 +171,11 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
file's data is held as a tree and written with the packet's own namespace prefix, never as
the file's markup, and every rewrite of the packet is parsed back before it is written; the
engine then lays a dynamic form out again from it and runs its calculations.
- **Prototypes in other documents** (`usehref="file.xdp#…"`) are not followed: that would
mean reading files or the network on opening a form. The reference is left out with a
warning. SOM references cover names, `#class` and `[n]`, not `[*]`, `..` or relative forms;
traversal declarations are merged like other children, not knitted together as "Resolving
Prototypes with Traversals" describes.
- Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font
metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and
+1
View File
@@ -16,6 +16,7 @@ pub mod packets;
pub mod parse;
pub mod pdf;
pub mod picture;
mod proto;
pub mod script;
pub mod text;
+15 -1
View File
@@ -740,7 +740,21 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
} else {
root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no <template> element".into()))?
};
let mut warnings = Vec::new();
// Prototype references (`use`, `usehref`) are resolved first: the parser reads the template
// written again with them merged in. The saved state below still comes from `doc`.
let (expanded, mut warnings) = crate::proto::expand(template);
let expanded_doc;
let template = match expanded.as_deref().map(|x| roxmltree::Document::parse_with_options(x, opts)) {
Some(Ok(d)) => {
expanded_doc = d;
expanded_doc.root_element()
}
Some(Err(e)) => {
warnings.push(format!("the template with its prototypes resolved could not be read again ({e}); it is read without them"));
template
}
None => template,
};
let sf = children(template, "subform").next().ok_or_else(|| XfaError::Malformed("the template has no root subform".into()))?;
let mut root_sf = parse_subform(sf, &mut warnings, 0);
if root_sf.page_set.is_none() {
+399
View File
@@ -0,0 +1,399 @@
//! Prototypes (XFA 3.3, chapter 6, "Prototypes"): an element with `use` or `usehref` inherits
//! the attributes, data content and children of the element it names, and overrides what it
//! declares itself. Designer's style sheets work this way: a field names its style, and its
//! size, border, font and caption placement are the style's.
//!
//! [`expand`] writes the template again with every reference resolved, so the parser reads
//! plain elements. Prototypes are resolved once, when the template is loaded, as the
//! specification says ("Prototypes", the note before "Defining Prototypes").
//!
//! Rules ("Overriding Prototype Properties"): every attribute but `id`, `name`, `use` and
//! `usehref` is inherited unless the element has its own; the prototype's data content is
//! inherited unless the element has non-empty content of its own; children are matched by
//! class and then by position among that class, and a matched prototype child is in turn the
//! prototype of the element's child. A `ui` or `value` holds one alternative (one widget, one
//! kind of content), so the nearest layer's choice is kept and only that one is merged; rich
//! text (`exData`) is content and is taken whole from the nearest layer that has any.
//!
//! Only references into the same template are followed: `usehref` with no document (or the
//! legacy `.`), as `#id` or `#som(expression)`, and `use` as `#id` or a SOM expression. A
//! reference to another document, to nothing, or back into itself is left out, with a warning.
//! The SOM subset is the one Designer writes: `$template.#subform.styles.Heading[1]`, each
//! step a name or a `#class` with an optional index, looking through `proto` elements and
//! nameless subforms.
use std::collections::{HashMap, HashSet};
use roxmltree::{Node, NodeId};
/// Deepest element nesting written. A prototype may contain a reference to an element that
/// contains it (Example 6.8): such loops are cut, and this bounds what remains.
const MAX_DEPTH: usize = 256;
/// Most layers (an element and the prototypes it inherits from) merged into one element.
const MAX_LAYERS: usize = 32;
/// Most bytes written: at least `MIN_OUTPUT`, at most `GROWTH` times the template and never
/// more than `MAX_OUTPUT`. Each reference repeats its prototype, so a hostile template could
/// otherwise grow without bound.
const MAX_OUTPUT: usize = 64 << 20;
const MIN_OUTPUT: usize = 1 << 20;
const GROWTH: usize = 4;
/// Most elements looked at to resolve all SOM references together.
const MAX_SEARCH: usize = 2_000_000;
/// The template written again with its prototype references resolved (`None` when it has
/// none, or when resolving them would pass a budget: the template is then read as it is), and
/// the warnings for what was left out.
pub(crate) fn expand(template: Node) -> (Option<String>, Vec<String>) {
let referencing = |n: &Node| {
n.is_element() && (n.attribute("use").is_some_and(|u| !u.trim().is_empty()) || n.attribute("usehref").is_some_and(|u| !u.trim().is_empty()))
};
if !template.descendants().any(|n| referencing(&n)) {
return (None, Vec::new());
}
let limit = template.range().len().saturating_mul(GROWTH).clamp(MIN_OUTPUT, MAX_OUTPUT);
let mut w = Writer {
template,
out: String::new(),
limit,
failed: None,
ids: None,
soms: HashMap::new(),
search: MAX_SEARCH,
active: HashMap::new(),
missing: false,
external: false,
looped: false,
layers_cut: false,
};
w.element(&[template], &[], 0);
let mut warnings = Vec::new();
if w.external {
warnings.push("a prototype reference (usehref) points to another document; it is not followed".into());
}
if w.missing {
warnings.push("a prototype reference (use or usehref) names nothing of its kind in the template; the element is read without it".into());
}
if w.looped {
warnings.push("a prototype reference leads back to itself; the loop is cut there".into());
}
if w.layers_cut {
warnings.push(format!("a chain of prototypes is longer than {MAX_LAYERS}; the farthest are left out"));
}
match w.failed {
Some(why) => {
warnings.push(format!("the template's prototypes {why}; it is read without resolving them"));
(None, warnings)
}
None => (Some(w.out), warnings),
}
}
struct Writer<'a, 'd> {
template: Node<'a, 'd>,
out: String,
limit: usize,
/// Why expanding stopped, for the warning.
failed: Option<String>,
/// The template's elements by `id`, built on the first `#id` reference.
ids: Option<HashMap<&'a str, Node<'a, 'd>>>,
/// SOM references already resolved, by their text.
soms: HashMap<&'a str, Option<Node<'a, 'd>>>,
/// Elements left to look at for SOM references.
search: usize,
/// The elements being written around the current one: inheriting from one is a loop.
active: HashMap<NodeId, u32>,
missing: bool,
external: bool,
looped: bool,
layers_cut: bool,
}
fn escape(s: &str, attribute: bool, out: &mut String) {
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' if attribute => out.push_str("&quot;"),
'\n' if attribute => out.push_str("&#10;"),
'\r' if attribute => out.push_str("&#13;"),
'\t' if attribute => out.push_str("&#9;"),
_ => out.push(c),
}
}
}
/// Containers a SOM step looks through: they have no name of their own.
fn transparent(n: Node) -> bool {
n.tag_name().name() == "proto" || n.tag_name().name() == "subform" && n.attribute("name").is_none_or(str::is_empty)
}
/// Does `n` have content of its own: non-blank text, or (`elements`) any child element?
fn has_content(n: &Node, elements: bool) -> bool {
n.children().any(|c| elements && c.is_element() || c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))
}
/// In a `ui` or a `value`, is `c` one of the alternatives of which only one is meant?
fn alternative(tag: &str, c: &Node) -> bool {
c.is_element() && (tag == "value" || tag == "ui" && !matches!(c.tag_name().name(), "picture" | "extras"))
}
impl<'a, 'd> Writer<'a, 'd> {
/// The element `n` names as its prototype. `usehref` wins over `use` when both are set.
fn reference(&mut self, n: Node<'a, 'd>) -> Option<Node<'a, 'd>> {
let set = |name: &str| n.attribute(name).map(str::trim).filter(|v| !v.is_empty());
let found = if let Some(href) = set("usehref") {
match href.split_once('#') {
Some((doc, fragment)) if matches!(doc.trim(), "" | ".") => {
let fragment = fragment.trim();
match fragment.strip_prefix("som(").and_then(|s| s.strip_suffix(')')) {
Some(expr) => self.som(expr.trim()),
None => self.by_id(fragment),
}
}
_ => {
self.external = true;
return None;
}
}
} else {
let r = set("use")?;
match r.strip_prefix('#') {
Some(id) => self.by_id(id.trim()),
// A SOM expression; the specification's own Example 6.5 writes a bare id.
None => self.som(r).or_else(|| self.by_id(r)),
}
};
match found {
Some(t) if t == n => {
self.looped = true;
None
}
Some(t) if t.tag_name().name() == n.tag_name().name() => Some(t),
_ => {
self.missing = true;
None
}
}
}
fn by_id(&mut self, id: &str) -> Option<Node<'a, 'd>> {
let template = self.template;
let ids = self.ids.get_or_insert_with(|| {
let mut ids = HashMap::new();
for n in template.descendants().filter(Node::is_element) {
if let Some(id) = n.attribute("id") {
ids.entry(id).or_insert(n);
}
}
ids
});
ids.get(id).copied()
}
/// A SOM expression from the template down, such as `$template.#subform.styles.Heading[1]`.
fn som(&mut self, expr: &'a str) -> Option<Node<'a, 'd>> {
if let Some(found) = self.soms.get(expr) {
return *found;
}
let found = self.resolve(expr);
self.soms.insert(expr, found);
found
}
fn resolve(&mut self, expr: &str) -> Option<Node<'a, 'd>> {
let mut steps = expr.split('.').map(str::trim).peekable();
while steps.peek().is_some_and(|s| matches!(*s, "$template" | "template" | "template[0]" | "xfa" | "$xfa" | "xfa[0]")) {
steps.next();
}
let mut at = self.template;
for step in steps {
let (name, index) = match step.split_once('[') {
Some((name, index)) => (name.trim(), index.strip_suffix(']')?.trim().parse::<usize>().ok()?),
None => (step, 0),
};
if name.is_empty() || name == "#" {
return None;
}
let mut matches = Vec::new();
self.step(at, name, index, &mut matches, 0);
at = *matches.get(index)?;
}
(at != self.template).then_some(at)
}
/// The children of `at` called `name` (or of class `#name`), looking through nameless
/// containers, until the one at `index` is found.
fn step(&mut self, at: Node<'a, 'd>, name: &str, index: usize, out: &mut Vec<Node<'a, 'd>>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in at.children().filter(Node::is_element) {
if self.search == 0 || out.len() > index {
return;
}
self.search -= 1;
let hit = match name.strip_prefix('#') {
Some(class) => c.tag_name().name() == class,
None => c.attribute("name") == Some(name),
};
if hit {
out.push(c);
} else if transparent(c) {
self.step(c, name, index, out, depth + 1);
}
}
}
/// `n` and the prototypes it inherits from, nearest first.
fn chain(&mut self, n: Node<'a, 'd>, layers: &mut Vec<Node<'a, 'd>>) {
let mut seen = HashSet::from([n.id()]);
layers.push(n);
let mut at = n;
while let Some(t) = self.reference(at) {
if !seen.insert(t.id()) || self.active.contains_key(&t.id()) {
self.looped = true;
return;
}
if layers.len() >= MAX_LAYERS {
self.layers_cut = true;
return;
}
layers.push(t);
at = t;
}
}
/// Write the element that `stack` makes together: `stack[0]` over `stack[1]` over …, each
/// an element of the same class. `scope` holds the namespaces already declared around it.
fn element(&mut self, stack: &[Node<'a, 'd>], scope: &[(Option<&'a str>, &'a str)], depth: usize) {
if self.failed.is_some() {
return;
}
if depth > MAX_DEPTH {
self.failed = Some(format!("nest deeper than {MAX_DEPTH} elements"));
return;
}
if self.out.len() > self.limit {
self.failed = Some(format!("would make it larger than {} MB", self.limit >> 20));
return;
}
// Every layer may itself inherit from a prototype.
let mut layers = Vec::new();
for l in stack {
self.chain(*l, &mut layers);
if layers.len() >= MAX_LAYERS {
break;
}
}
let Some(&top) = layers.first() else { return };
for l in &layers {
*self.active.entry(l.id()).or_insert(0) += 1;
}
let tag = top.tag_name().name();
let prefix =
|n: Node, ns: Option<&str>| ns.and_then(|ns| n.lookup_prefix(ns)).filter(|p| !p.is_empty()).map(|p| format!("{p}:")).unwrap_or_default();
let qname = format!("{}{tag}", prefix(top, top.tag_name().namespace()));
self.out.push('<');
self.out.push_str(&qname);
// Namespaces in scope in any layer that the elements written around this one lack.
let mut inner: Vec<(Option<&'a str>, &'a str)> = scope.to_vec();
for layer in &layers {
for ns in layer.namespaces() {
let (name, uri) = (ns.name(), ns.uri());
if name == Some("xml") || inner.iter().any(|(n, _)| *n == name) {
continue;
}
match name {
Some(name) => {
self.out.push_str(" xmlns:");
self.out.push_str(name);
}
None => self.out.push_str(" xmlns"),
}
self.out.push_str("=\"");
escape(uri, true, &mut self.out);
self.out.push('"');
inner.push((name, uri));
}
}
// Attributes: the nearest layer that has one wins; `id` and `name` are not inherited.
let mut written: HashSet<String> = HashSet::new();
for (i, layer) in layers.iter().enumerate() {
for a in layer.attributes() {
let inherited = i > 0 && matches!(a.name(), "id" | "name");
let reference = a.namespace().is_none() && matches!(a.name(), "use" | "usehref");
let name = format!("{}{}", prefix(*layer, a.namespace()), a.name());
if inherited || reference || !written.insert(name.clone()) {
continue;
}
self.out.push(' ');
self.out.push_str(&name);
self.out.push_str("=\"");
escape(a.value(), true, &mut self.out);
self.out.push('"');
}
}
self.out.push('>');
// Rich text is taken whole from the nearest layer that has any.
let cut = if tag == "exData" { layers.iter().position(|l| has_content(l, true)).map_or(layers.len(), |i| i + 1) } else { layers.len() };
let merged = layers.get(..cut).unwrap_or(&layers);
// Data content: the nearest layer's that has any (empty content overrides nothing).
let text_layer = merged.iter().position(|l| has_content(l, false)).unwrap_or(0);
let chosen = if matches!(tag, "ui" | "value") {
merged.iter().find_map(|l| l.children().find(|c| alternative(tag, c)).map(|c| c.tag_name().name()))
} else {
None
};
// The deeper layers' children by (class, position among that class).
let index = |l: &Node<'a, 'd>| {
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut by: HashMap<(&'a str, usize), Node<'a, 'd>> = HashMap::new();
for c in l.children().filter(Node::is_element) {
let k = counts.entry(c.tag_name().name()).or_insert(0);
by.insert((c.tag_name().name(), *k), c);
*k += 1;
}
by
};
let below: Vec<_> = merged.iter().skip(1).map(index).collect();
let mut done: HashSet<(&str, usize)> = HashSet::new();
for (i, layer) in merged.iter().enumerate() {
let mut counts: HashMap<&str, usize> = HashMap::new();
for c in layer.children() {
if c.is_text() && i == text_layer {
escape(c.text().unwrap_or_default(), false, &mut self.out);
}
if !c.is_element() {
continue;
}
let ctag = c.tag_name().name();
let n = {
let k = counts.entry(ctag).or_insert(0);
*k += 1;
*k - 1
};
// Prototypes take no part in the form; of the alternatives one is kept.
if ctag == "proto" || alternative(tag, &c) && chosen != Some(ctag) || !done.insert((ctag, n)) {
continue;
}
let stack: Vec<Node<'a, 'd>> = std::iter::once(c).chain(below.iter().skip(i).filter_map(|b| b.get(&(ctag, n)).copied())).collect();
self.element(&stack, &inner, depth + 1);
}
}
self.out.push_str("</");
self.out.push_str(&qname);
self.out.push('>');
for l in &layers {
if let Some(k) = self.active.get_mut(&l.id()) {
*k = k.saturating_sub(1);
if *k == 0 {
self.active.remove(&l.id());
}
}
}
}
}
#[cfg(test)]
mod tests;
+229
View File
@@ -0,0 +1,229 @@
//! Prototypes (`use`, `usehref`): synthetic templates only.
use crate::layout::{BorderShape, Item, WidgetKind};
use crate::model::{Node, Placement, Template, Ui, Value};
use crate::{layout_xml, parse};
const MM: f64 = 72.0 / 25.4;
/// A form whose root subform holds `styles` in a Designer-style sheet (a named subform inside
/// `proto`) and positions `body` on one page.
fn form(styles: &str, body: &str) -> String {
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="form1" 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" w="190mm" h="250mm">{body}</subform>
<proto><subform name="designer__stylesheet">{styles}</subform></proto>
</subform></template></xdp:xdp>"##
)
}
fn close(a: Option<f64>, b: f64) -> bool {
a.is_some_and(|a| (a - b).abs() < 0.01)
}
/// The nodes of the body subform.
fn nodes(t: &Template) -> &[Node] {
let Some(Node::Subform(b)) = t.root.children.first() else { panic!("no body subform: {:?}", t.root.children) };
&b.children
}
fn field<'t>(t: &'t Template, name: &str) -> &'t crate::model::Field {
nodes(t)
.iter()
.find_map(|n| match n {
Node::Field(f) if f.common.name.as_deref() == Some(name) => Some(f.as_ref()),
_ => None,
})
.unwrap_or_else(|| panic!("no field {name}"))
}
const STYLES: &str = r#"
<draw name="Heading" w="190.5mm" h="6mm" anchorType="middleLeft"><border><edge presence="hidden"/><fill><color value="153,153,153"/></fill></border><font typeface="Arial" size="12pt" weight="bold"/></draw>
<field name="Numeric" h="5mm"><ui><numericEdit><border><edge stroke="lowered"/></border></numericEdit></ui><font typeface="Arial" size="9pt"/></field>
<field name="Checkbox" h="6mm" anchorType="middleCenter"><ui><checkButton size="3.5278mm"><border><edge/></border></checkButton></ui><caption placement="right" reserve="30mm"><font typeface="Arial" size="9pt"/></caption><font typeface="Arial" size="9pt"/><items><integer>1</integer><integer>0</integer></items></field>"#;
const BODY: &str = r##"
<draw name="Section" x="0mm" y="10mm" usehref=".#som($template.#subform.designer__stylesheet.Heading)"><value><text>Section</text></value></draw>
<field name="Count" x="0mm" y="30mm" w="30mm" usehref="#som($template.#subform.designer__stylesheet.Numeric)"><ui><numericEdit/></ui></field>
<field name="Agree" x="20mm" y="50mm" w="40mm" usehref=".#som($template.#subform.designer__stylesheet.Checkbox[0])"><caption><value><text>Agree</text></value></caption></field>"##;
#[test]
fn fields_and_draws_take_size_border_fill_caption_and_font_from_a_style_sheet() {
let xml = form(STYLES, BODY);
let (t, warnings) = parse(&xml).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let Some(Node::Draw(section)) = nodes(&t).first() else { panic!("{:?}", nodes(&t)) };
let c = &section.common;
assert_eq!(c.name.as_deref(), Some("Section"));
assert!(close(c.w, 190.5 * MM) && close(c.h, 6.0 * MM), "{c:?}");
assert_eq!((c.anchor.h, c.anchor.v), (0.0, 0.5));
assert_eq!(c.border.as_ref().and_then(|b| b.fill).map(|f| f.0), Some([0.6; 3]));
assert_eq!((c.font.typeface.as_deref(), c.font.bold), (Some("Arial"), Some(true)));
assert_eq!(section.value, Value::Text("Section".into()));
let count = field(&t, "Count");
assert_eq!(count.ui, Ui::NumericEdit);
assert!(close(count.common.w, 30.0 * MM) && close(count.common.h, 5.0 * MM));
assert_eq!(count.ui_border.as_ref().map(|b| b.visible_edges()), Some([true; 4]));
assert_eq!(count.common.font.typeface.as_deref(), Some("Arial"));
let agree = field(&t, "Agree");
assert_eq!(agree.ui, Ui::CheckButton);
assert!(close(agree.check_size, 3.5278 * MM) && close(agree.common.h, 6.0 * MM));
assert_eq!((agree.common.anchor.h, agree.common.anchor.v), (0.5, 0.5));
let cap = agree.caption.as_ref().expect("caption");
assert_eq!((cap.placement, cap.value.clone()), (Placement::Right, Value::Text("Agree".into())));
assert_eq!(cap.font.typeface.as_deref(), Some("Arial"));
assert_eq!(agree.items, vec!["1".to_string(), "0".to_string()]);
// Laid out: the heading's grey bar, the numeric field's box and the check box left of its
// caption. The style sheet itself lays nothing out.
let laid = layout_xml(&xml).unwrap();
let items = &laid.pages.first().expect("a page").items;
assert!(items.iter().any(|i| matches!(i, Item::Fill { rect, color } if *color == [0.6; 3] && (rect.w - 190.5 * MM).abs() < 0.01)), "{items:?}");
let widgets: Vec<_> = items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).collect();
assert_eq!(widgets.len(), 2, "{widgets:?}");
let count = widgets.iter().find(|w| w.name.contains("Count")).expect("Count");
assert_eq!(count.border.shape, BorderShape::Full);
let check = widgets.iter().find(|w| matches!(w.kind, WidgetKind::CheckBox { .. })).expect("check box");
let caption = items.iter().find_map(|i| if let Item::Text(s) = i { (s.text == "Agree").then_some(s.x) } else { None }).expect("caption");
assert!(check.rect.x + check.rect.w <= caption, "box {:?}, caption at {caption}", check.rect);
}
#[test]
fn what_an_element_declares_overrides_what_it_inherits() {
let styles = r#"<field id="P" name="Proto" w="50mm" h="10mm"><font typeface="Courier" size="14pt" weight="bold"/><border><edge thickness="2pt"/><edge thickness="1pt"/></border><value><text>proto</text></value></field>
<field id="Q" w="20mm"/><field id="R"><ui><numericEdit/></ui><value><decimal/></value></field>
<draw id="T"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Proto <b>bold</b></p><p>second</p></body></exData></value></draw>"#;
let body = r##"<field name="f" use="#P" h="7mm"><font size="9pt"/><border><edge thickness="3pt"/></border><value><text>own</text></value></field>
<field use="#P"/>
<field name="g" use="#P" usehref="#Q"/>
<field name="t" use="#R"><ui><textEdit/></ui><value><text>7</text></value></field>
<draw name="rich" use="#T"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Own</p></body></exData></value></draw>"##;
let (t, warnings) = parse(&form(styles, body)).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let f = field(&t, "f");
assert!(close(f.common.h, 7.0 * MM) && close(f.common.w, 50.0 * MM), "{:?}", f.common);
assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size, f.common.font.bold), (Some("Courier"), Some(9.0), Some(true)));
// Edges match by position: the first overridden, the second inherited (Example 6.12).
let edges = f.common.border.as_ref().expect("border").edges.map(|e| e.thickness);
assert_eq!(edges, [3.0, 1.0, 3.0, 1.0]);
assert_eq!(f.value, Value::Text("own".into()));
assert_eq!(f.common.id, None, "a prototype's id is its own");
// Neither the name nor the id is inherited, but the content is.
let Some(Node::Field(unnamed)) = nodes(&t).get(1) else { panic!() };
assert_eq!((unnamed.common.name.as_deref(), unnamed.value.clone()), (None, Value::Text("proto".into())));
// usehref wins over use.
assert!(close(field(&t, "g").common.w, 20.0 * MM));
// A ui holds one widget and a value one kind of content: the nearer one replaces the other.
let t_field = field(&t, "t");
assert_eq!((t_field.ui.clone(), t_field.numeric, t_field.value.clone()), (Ui::TextEdit, false, Value::Text("7".into())));
// Rich text is content: the nearer layer's is taken whole.
let Some(Node::Draw(rich)) = nodes(&t).get(4) else { panic!() };
let Value::Rich(r) = &rich.value else { panic!("{:?}", rich.value) };
assert_eq!(r.plain(), "Own");
}
#[test]
fn prototypes_inherit_from_prototypes() {
// Example 6.5: a font prototype using another, the second by a bare id.
let styles = r##"<font id="HELV" typeface="Helvetica" size="10pt"/>
<font id="HELV-RED" use="HELV"><fill><color value="255,0,0"/></fill></font>
<field id="A" w="40mm"/><field id="B" use="#A" h="8mm"/><field id="C" usehref="#B"><font use="#HELV-RED"/></field>"##;
let body = r##"<field name="f" usehref=".#C"/>"##;
let (t, warnings) = parse(&form(styles, body)).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let f = field(&t, "f");
assert!(close(f.common.w, 40.0 * MM) && close(f.common.h, 8.0 * MM), "{:?}", f.common);
assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size), (Some("Helvetica"), Some(10.0)));
assert_eq!(f.common.font.color.map(|c| c.0), Some([1.0, 0.0, 0.0]));
}
#[test]
fn prototype_loops_end_with_a_warning() {
let styles = r##"<field id="A" use="#B" w="10mm"/><field id="B" use="#A" h="12mm"/>
<subform name="Client" id="ClientSubform" w="50mm"><subform name="ClientName" use="#ClientSubform"/></subform>"##;
let body = r##"<field name="self" id="S" use="#S" w="1in"/>
<field name="cycle" use="#A"/>
<subform name="client" use="#ClientSubform"/>"##;
let (t, warnings) = parse(&form(styles, body)).unwrap();
assert!(warnings.iter().any(|w| w.contains("leads back to itself")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("longer than")), "a loop is cut where it closes: {warnings:?}");
assert!(close(field(&t, "self").common.w, 72.0));
let cycle = field(&t, "cycle");
assert!(close(cycle.common.w, 10.0 * MM) && close(cycle.common.h, 12.0 * MM), "{:?}", cycle.common);
// Example 6.8: the inner subform does not take its parent in again.
let Some(Node::Subform(client)) = nodes(&t).get(2) else { panic!() };
assert!(close(client.common.w, 50.0 * MM));
let Some(Node::Subform(inner)) = client.children.first() else { panic!("{:?}", client.children) };
assert_eq!(inner.common.name.as_deref(), Some("ClientName"));
assert!(inner.children.is_empty() && inner.common.w.is_none(), "{inner:?}");
}
#[test]
fn a_reference_to_nothing_or_another_document_is_left_as_it_is() {
let styles = r#"<field id="F" w="99mm"/>"#;
let body = r##"<field name="nowhere" w="1in" usehref=".#som($template.#subform.designer__stylesheet.Missing)"/>
<field name="noid" w="1in" use="#nope"/>
<field name="far" w="1in" usehref="https://example.com/styles.xdp#F"/>
<field name="bad" w="1in" usehref="#som($template.#subform[*])"/>
<draw name="other" w="1in" use="#F"/>"##;
let (t, warnings) = parse(&form(styles, body)).unwrap();
assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("another document")), "{warnings:?}");
for name in ["nowhere", "noid", "far", "bad"] {
assert!(close(field(&t, name).common.w, 72.0), "{name}");
}
let Some(Node::Draw(other)) = nodes(&t).get(4) else { panic!() };
assert!(close(other.common.w, 72.0), "a prototype is of the referencing element's class");
}
#[test]
fn an_expansion_past_its_budget_leaves_the_template_unresolved_with_a_warning() {
// 200 references to a 20 kB prototype would write 4 MB from a template of a few kB.
let styles = format!(r#"<draw id="Big" w="10mm"><value><text>{}</text></value></draw>"#, "x".repeat(20_000));
let body: String = (0..200).map(|i| format!(r##"<draw name="d{i}" use="#Big"/>"##)).collect();
let started = std::time::Instant::now();
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
assert!(started.elapsed().as_secs() < 10);
assert!(warnings.iter().any(|w| w.contains("larger than 1 MB")), "{warnings:?}");
let Some(Node::Draw(d)) = nodes(&t).first() else { panic!() };
assert_eq!((d.common.w, &d.value), (None, &Value::Empty), "read as written");
}
#[test]
fn hostile_references_neither_panic_nor_run_away() {
let refs = [
"#som(",
"#som()",
"#som($template.[)",
"#som($template.#subform[99999999999999999999999])",
"#som(.)",
"#som(#)",
"#",
".#",
"#som($template.#subform.designer__stylesheet.ü[0].x[1])",
"#som(xfa.template.#subform[0].#proto)",
"#\u{feff}",
];
let body: String = refs
.iter()
.enumerate()
.map(|(i, r)| format!(r#"<field name="f{i}" w="1in" usehref="{r}"/><field name="u{i}" w="1in" use="{r}"/>"#))
.collect();
let (t, _) = parse(&form("", &body)).unwrap();
assert!(close(field(&t, "f0").common.w, 72.0));
// Prototypes six levels deep, each holding ten references to the next: a million elements.
let mut styles = String::from(r#"<subform id="P6"><field w="1mm"/></subform>"#);
for level in (1..6).rev() {
let next = level + 1;
let refs: String = (0..10).map(|_| format!(r##"<subform use="#P{next}"/>"##)).collect();
styles.push_str(&format!(r#"<subform id="P{level}">{refs}</subform>"#));
}
let started = std::time::Instant::now();
let (_, warnings) = parse(&form(&styles, r##"<subform name="big" use="#P1"/>"##)).unwrap();
assert!(started.elapsed().as_secs() < 10);
assert!(warnings.iter().any(|w| w.contains("larger than")), "{warnings:?}");
}
+2 -2
View File
@@ -5308,8 +5308,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/testsLine truncated
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_scriptLine truncated
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/testsLine truncated
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 (prototypes first: use/usehref references to #id or #som(...) in the same template, as Designer style sheets write them, are merged in by the XFA 3.3 chapter 6 rules, the element's own attributes and children winning, with loops cut and chain length, nesting, look-up and merge work and growth capped, each with a warning; prototypes in other documents are not followed, and SOM references cover names, #class and [n] only; 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 bLine truncated
[[feature]]
id = "form.xfa-formcalc"