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:
mtlcl410
2026-10-30 07:25:51 +08:00
co-authored by EULA Claude Opus 5.5
parent 726ecf2625
commit 8e85e0b7ab
6 changed files with 663 additions and 3 deletions
+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:?}");
}
File diff suppressed because one or more lines are too long