Merge #4662: M12: an XFA template's use/usehref prototypes are resolved before it is parsed (form.xfa-dynamic)

# Conflicts:
#	parity/acrobat-features.toml
This commit is contained in:
bflatastic committed 2026-11-09 18:23:20 +07:00
commit c17990f0ea
6 files changed
+972 -5

No files matched your search

+22
View File
@@ -27,6 +27,23 @@ that warning and keeps the existing layout and accepted widget values.
text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
required fields and exclusion groups (`<validate nullTest="error">`), 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, a SOM
expression longer than 1,024 bytes names nothing, SOM look-ups share a budget of 2 million
elements, each element's reference is resolved once however many elements reach it,
merging looks at no more than 64 million children, attributes and namespaces in all
(prototype children that write nothing, such as `proto`, count too), and the written
template may grow to 4 times its size (at least 1 MB, at most 64 MB). Past the nesting,
look-up, merge or growth budget expanding fails as a whole: no partly written template is
used, the template is read as it is written (unresolved), with one warning saying which
budget. A cut loop or chain is a warning too.
- **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
@@ -175,6 +192,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;
+17 -3
View File
@@ -978,8 +978,8 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
crate::MAX_XML_DEPTH
)));
}
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts()).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
let root = doc.root_element();
// The XDP's config packet has a <template> element too: the one we want is in the
// xfa-template namespace, or at least has a subform in it.
@@ -995,7 +995,21 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
} else {
root.children().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() {
+529
View File
@@ -0,0 +1,529 @@
//! 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;
/// Longest SOM expression evaluated, in bytes. Designer writes some tens of bytes
/// (`$template.#subform.designer__stylesheet.Heading[1]`); a longer one names nothing.
const MAX_SOM: usize = 1_024;
/// Most elements looked at to resolve all SOM references together.
const MAX_SEARCH: usize = 2_000_000;
/// Most children, attributes and namespaces looked at to merge all the layers together. Each
/// reference looks through its prototype's children again, also those that write nothing
/// (`proto`, and the `ui` and `value` alternatives not chosen), so the output budget alone does
/// not bound this. Real forms cost one unit for every 5.5 to 36 bytes written (the largest
/// measured, a 14 MB template, costs 2.5 million), so writing as much as the 64 MB output
/// budget allows costs some 12 million: this keeps a margin of five over that, and stops a
/// hostile template after about a second.
const MAX_WORK: usize = 64_000_000;
/// The namespace the `xml` prefix is always bound to.
const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
/// 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,
refs: HashMap::new(),
soms: HashMap::new(),
search: MAX_SEARCH,
work: MAX_WORK,
active: HashMap::new(),
missing: false,
external: false,
looped: false,
layers_cut: false,
};
w.element(&[template], &Scope::new(), 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),
}
}
/// Namespace bindings in scope where an element is written: URI by prefix (`None` for the
/// default namespace).
type Scope<'a> = HashMap<Option<&'a str>, &'a str>;
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>>>,
/// The prototype each element names, once resolved: a prototype shared by many elements
/// is reached again from each of them, and its reference (of any length) is read once.
refs: HashMap<NodeId, Option<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,
/// Children, attributes and namespaces left to look at while merging layers.
work: 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>> {
if let Some(found) = self.refs.get(&n.id()) {
return *found;
}
let found = self.find_reference(n);
// A look-up a budget cut short is not kept: expanding has failed.
if self.failed.is_none() {
self.refs.insert(n.id(), found);
}
found
}
fn find_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 {
// A budget ran out while looking: the template is read unresolved, with that warning.
_ if self.failed.is_some() => None,
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 expr.len() > MAX_SOM {
return None;
}
if let Some(found) = self.soms.get(expr) {
return *found;
}
let found = self.resolve(expr);
// A look-up the budget cut short has not found nothing: it is not cached, and the
// template is read unresolved (`step` set `failed`).
if self.failed.is_none() {
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 out.len() > index || self.failed.is_some() {
return;
}
if self.search == 0 {
self.failed = Some(format!("need more than {MAX_SEARCH} elements looked at to resolve their SOM references"));
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;
}
}
/// Take `n` from the work budget; past it, expanding stops.
fn charge(&mut self, n: usize) -> bool {
match self.work.checked_sub(n) {
Some(left) => {
self.work = left;
true
}
None => {
self.work = 0;
self.failed = Some(format!("need more than {MAX_WORK} children, attributes and namespaces looked at to merge them"));
false
}
}
}
/// Write the namespace bindings `layers` have that differ from those in `scope` around their
/// element, by prefix, and return the scope inside it: the nearest layer's binding of a prefix
/// wins, and a prefix bound around the element to another URI is bound again here (an XHTML
/// `body` inside a template whose default namespace is XFA's).
fn namespaces(&mut self, layers: &[Node<'a, 'd>], scope: &Scope<'a>) -> Scope<'a> {
let mut inner: Scope<'a> = scope.clone();
let mut decided: HashSet<Option<&'a str>> = HashSet::new();
for layer in layers {
for ns in layer.namespaces() {
let (name, uri) = (ns.name(), ns.uri());
if name == Some("xml") || !decided.insert(name) || scope.get(&name) == Some(&uri) {
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.insert(name, uri);
}
}
inner
}
/// Write the attributes of the element `layers` make, inside the namespace scope `inner`.
fn attributes(&mut self, layers: &[Node<'a, 'd>], inner: &Scope<'a>) {
// Attributes: the nearest layer that has one wins; `id` and `name` are not inherited.
// Attributes are told apart by namespace URI and local name. One whose layer's prefix is
// bound here to another URI (the nearest layer's binding won) is written with a fresh
// prefix, declared on this element.
let mut written: HashSet<(Option<&str>, &str)> = HashSet::new();
let mut fresh: HashMap<&str, String> = HashMap::new();
let mut next = 0usize;
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");
if inherited || reference || !written.insert((a.namespace(), a.name())) {
continue;
}
let name = match a.namespace() {
None => a.name().to_string(),
Some(XML_NS) => format!("xml:{}", a.name()),
Some(uri) => match layer.lookup_prefix(uri).filter(|p| !p.is_empty()) {
Some(p) if inner.get(&Some(p)) == Some(&uri) => format!("{p}:{}", a.name()),
_ => {
let p = match fresh.get(uri) {
Some(p) => p.clone(),
None => {
// `next` only grows, and skips the prefixes in scope here,
// so it passes at most `inner.len()` of them.
let p = loop {
let p = format!("ns{next}");
next = next.saturating_add(1);
if !inner.contains_key(&Some(p.as_str())) {
break p;
}
};
self.out.push_str(" xmlns:");
self.out.push_str(&p);
self.out.push_str("=\"");
escape(uri, true, &mut self.out);
self.out.push('"');
fresh.insert(uri, p.clone());
p
}
};
format!("{p}:{}", a.name())
}
},
};
self.out.push(' ');
self.out.push_str(&name);
self.out.push_str("=\"");
escape(a.value(), true, &mut self.out);
self.out.push('"');
}
}
}
/// 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: &Scope<'a>, 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;
}
}
// Charge what merging looks at before looking: each layer's children (counted no
// further than the budget left), attributes and namespaces, and the scope around it.
let mut cost = scope.len().saturating_add(layers.len());
for l in &layers {
let children = l.children().take(self.work.saturating_add(1)).count();
cost = cost.saturating_add(children).saturating_add(l.attributes().len()).saturating_add(l.namespaces().len());
}
if self.failed.is_some() || !self.charge(cost) {
return;
}
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);
let mut inner = self.namespaces(&layers, scope);
// An element in no namespace inside a default namespace undeclares it.
if top.tag_name().namespace().is_none() && inner.get(&None).is_some_and(|u| !u.is_empty()) {
self.out.push_str(" xmlns=\"\"");
inner.insert(None, "");
}
self.attributes(&layers, &inner);
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
};
// Prototypes take no part in the form; of the alternatives one is kept. A class left
// out is left out whole, so it need not be counted either.
let left_out = |c: &Node| c.tag_name().name() == "proto" || alternative(tag, c) && chosen != Some(c.tag_name().name());
// 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(|c| c.is_element() && !left_out(c)) {
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() || left_out(&c) {
continue;
}
let ctag = c.tag_name().name();
let n = {
let k = counts.entry(ctag).or_insert(0);
*k += 1;
*k - 1
};
if !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;
+401
View File
@@ -0,0 +1,401 @@
//! 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:?}");
}
#[test]
fn a_som_search_past_its_budget_leaves_the_template_unresolved_with_one_warning() {
// 300 references, each to a style 10,000 elements into the sheet: 3 million elements looked
// at, past the 2 million the look-ups share. The first ones resolve before the budget runs
// out, so the template is either resolved in full or not at all.
let styles: String = (0..10_000).map(|i| format!(r#"<field name="s{i}" w="1mm"/>"#)).collect();
let body: String =
(0..300).map(|i| format!(r##"<field name="f{i}" usehref="#som($template.#subform.designer__stylesheet.s{})"/>"##, 9_999 - i)).collect();
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
assert!(warnings.iter().any(|w| w.contains("SOM references") && w.contains("read without resolving")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("names nothing")), "an interrupted search is not a missing target: {warnings:?}");
assert_eq!(field(&t, "f0").common.w, None, "read as written");
}
#[test]
fn prototype_children_that_write_nothing_still_count_against_a_budget() {
// A prototype with 20,000 `proto` children, used by 20,000 fields: the children write
// nothing, but merging would look at 400 million of them.
let protos = "<proto/>".repeat(20_000);
let styles = format!(r#"<field id="P" w="1mm">{protos}</field>"#);
let body = r##"<field use="#P"/>"##.repeat(20_000).replacen("<field ", r#"<field name="first" "#, 1);
let started = std::time::Instant::now();
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
assert!(warnings.iter().any(|w| w.contains("children, attributes and namespaces") && w.contains("read without resolving")), "{warnings:?}");
assert_eq!(field(&t, "first").common.w, None, "read as written");
}
/// The template of `xml` written again by `expand`, parsed.
fn expanded(xml: &str) -> String {
let doc = roxmltree::Document::parse(xml).unwrap();
let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
let (out, warnings) = crate::proto::expand(template);
assert!(warnings.is_empty(), "{warnings:?}");
out.unwrap()
}
#[test]
fn namespaces_bound_again_inside_the_template_keep_their_own_uri() {
const XHTML: &str = "http://www.w3.org/1999/xhtml";
// Rich text in a prototype and in a plain element: XHTML's default namespace inside the
// template's (XFA's), and a prefix bound to one URI around an element and another inside.
let styles = r#"<draw id="Note" w="40mm"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>See <a href="https://example.com/">the guide</a></p></body></exData></value></draw>"#;
let body = r##"<draw name="note" x="0mm" y="0mm" use="#Note"/>
<draw name="plain" x="0mm" y="20mm" w="40mm" xmlns:q="urn:outer" q:a="1"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:q="urn:inner"><p q:b="2">Plain</p></body></exData></value></draw>
<draw name="none" x="0mm" y="40mm" w="40mm"><extras><free xmlns=""/></extras></draw>"##;
let xml = form(styles, body);
let out = expanded(&xml);
let doc = roxmltree::Document::parse(&out).unwrap();
let bodies: Vec<_> = doc.descendants().filter(|n| n.tag_name().name() == "body").collect();
assert_eq!(bodies.len(), 2, "{out}");
for b in &bodies {
assert_eq!(b.tag_name().namespace(), Some(XHTML), "{out}");
assert!(b.descendants().filter(|n| n.is_element()).all(|n| n.tag_name().namespace() == Some(XHTML)), "{out}");
}
let p = doc.descendants().find(|n| n.attribute(("urn:inner", "b")).is_some());
assert!(p.is_some(), "a prefix bound again keeps the inner URI: {out}");
assert!(doc.descendants().any(|n| n.attribute(("urn:outer", "a")) == Some("1")), "{out}");
let free = doc.descendants().find(|n| n.tag_name().name() == "free").unwrap();
assert_eq!(free.tag_name().namespace().unwrap_or_default(), "", "an undeclared default namespace stays undeclared: {out}");
// And the form still reads the rich text and its link.
let (t, warnings) = parse(&xml).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let Some(Node::Draw(note)) = nodes(&t).first() else { panic!() };
let Value::Rich(paras) = &note.value else { panic!("{:?}", note.value) };
assert!(
paras.paragraphs.iter().flat_map(|p| &p.runs).any(|r| r.text.contains("the guide") && r.link.as_deref() == Some("https://example.com/")),
"{paras:?}"
);
}
#[test]
fn inherited_attributes_keep_their_namespace_where_the_prefix_is_bound_again() {
// The element binds `q` to one URI and its prototype to another: the element's binding
// wins, so the prototype's `q:` attributes need another prefix to keep their own URI.
let styles = r#"<field id="P" xmlns:q="urn:prototype" q:kind="k" q:own="2" xml:lang="en" w="1in"/>"#;
let body = r##"<field name="f" xmlns:q="urn:instance" q:own="1" use="#P"/>"##;
let out = expanded(&form(styles, body));
let doc = roxmltree::Document::parse(&out).unwrap();
let f = doc.descendants().find(|n| n.attribute("name") == Some("f")).unwrap();
assert_eq!(f.attribute(("urn:instance", "own")), Some("1"), "{out}");
assert_eq!(f.attribute(("urn:prototype", "own")), Some("2"), "{out}");
assert_eq!(f.attribute(("urn:prototype", "kind")), Some("k"), "{out}");
assert_eq!(f.attribute(("urn:instance", "kind")), None, "{out}");
assert_eq!(f.attribute(("http://www.w3.org/XML/1998/namespace", "lang")), Some("en"), "{out}");
assert_eq!(f.attribute("w"), Some("1in"), "{out}");
}
/// `expand` on the template of `xml`, timed.
fn expand_timed(xml: &str) -> (Option<String>, Vec<String>, std::time::Duration) {
let doc = roxmltree::Document::parse(xml).unwrap();
let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
let started = std::time::Instant::now();
let (out, warnings) = crate::proto::expand(template);
(out, warnings, started.elapsed())
}
#[test]
fn billion_laughs_prototypes_fail_whole_within_the_output_cap() {
// Ten levels of prototypes, each holding ten references to the level below: a template of
// under 3 kB that would write 10^10 fields. Expanding fails as a whole, quickly, and
// nothing of it is used (no truncated template): the form is read as written.
let mut styles = String::from(r#"<subform id="L10"><field w="1mm"/></subform>"#);
for level in (1..10).rev() {
let refs = format!(r##"<subform use="#L{}"/>"##, level + 1).repeat(10);
styles.push_str(&format!(r#"<subform id="L{level}">{refs}</subform>"#));
}
let xml = form(&styles, r##"<subform name="bomb" use="#L1"/><field name="plain" w="1in"/>"##);
assert!(xml.len() < 3_000, "{}", xml.len());
let (out, warnings, took) = expand_timed(&xml);
assert!(took.as_secs() < 5, "{took:?}");
assert!(out.is_none(), "a failed expansion writes nothing for the parser to read");
assert!(warnings.iter().any(|w| w.contains("larger than 1 MB") && w.contains("read without resolving")), "{warnings:?}");
let (t, _) = parse(&xml).unwrap();
let Some(Node::Subform(bomb)) = nodes(&t).first() else { panic!() };
assert!(bomb.children.is_empty(), "read as written: {bomb:?}");
assert!(close(field(&t, "plain").common.w, 72.0));
}
#[test]
fn a_designer_style_sheet_on_a_long_form_resolves_in_full() {
// 3,000 fields and draws on Designer's style sheet, each through `#som(…)`: well inside
// every budget, so all of them take their style and nothing is warned about.
let styles: String = (0..40).map(|i| format!(r#"<field name="S{i}" h="{}mm"><font typeface="Arial" size="9pt"/></field>"#, i + 1)).collect();
let body: String = (0..3_000)
.map(|i| format!(r##"<field name="f{i}" w="20mm" usehref=".#som($template.#subform.designer__stylesheet.S{})"/>"##, i % 40))
.collect();
let xml = form(&styles, &body);
let (out, warnings, took) = expand_timed(&xml);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(out.is_some() && took.as_secs() < 5, "{took:?}");
let (t, warnings) = parse(&xml).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
for i in [0usize, 39, 1_234, 2_999] {
let f = field(&t, &format!("f{i}"));
assert!(close(f.common.h, (i % 40 + 1) as f64 * MM), "f{i}: {:?}", f.common);
assert_eq!(f.common.font.size, Some(9.0), "f{i}");
}
}
#[test]
fn a_long_reference_shared_by_many_elements_is_resolved_once() {
// One prototype whose own reference is a 1 MB `use` id, used by 60,000 fields: the output
// stays small, so only reading that reference again for every field (60 GB of text) could
// make this slow. A reference is resolved once per element.
let long = "x".repeat(1 << 20);
let styles = format!(r#"<field id="P" w="1mm" use="{long}"/><field id="{long}" h="2mm"/>"#);
let body = r##"<field use="#P"/>"##.repeat(60_000).replacen("<field ", r#"<field name="first" "#, 1);
let xml = form(&styles, &body);
let (out, warnings, took) = expand_timed(&xml);
assert!(took.as_secs() < 3, "{took:?}");
assert!(out.is_some(), "{warnings:?}");
let (t, _) = parse(&xml).unwrap();
let first = field(&t, "first");
assert!(close(first.common.w, MM) && close(first.common.h, 2.0 * MM), "{:?}", first.common);
}
#[test]
fn a_som_expression_past_its_length_limit_is_not_followed() {
// A SOM expression longer than any Designer writes is not evaluated: it is warned about as
// a reference naming nothing, and the rest of the template resolves.
let padded = format!("$template.{}#subform.designer__stylesheet.Q", " ".repeat(5_000));
let styles = r#"<field name="Q" h="2mm"/>"#;
let body = format!(
r##"<field name="long" w="1in" usehref="#som({padded})"/><field name="short" w="1in" usehref="#som($template.#subform.designer__stylesheet.Q)"/>"##
);
let (t, warnings) = parse(&form(styles, &body)).unwrap();
assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
assert_eq!(field(&t, "long").common.h, None);
assert!(close(field(&t, "short").common.h, 2.0 * MM));
}
+2 -2
View File
@@ -5447,8 +5447,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run", "form_focus"]
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/testsLine truncated
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. 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, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), 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 (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), 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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form; a push button, which keeps no value, is not), 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, accessLine truncated
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/testsLine truncated
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. 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, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), 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 (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), 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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form; a push button, which keeps no value, is not), 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 inLine truncated
[[feature]]
id = "form.xfa-formcalc"