mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 11:51:59 +00:00
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:
co-authored by
EULA
Claude Opus 5.5
parent
726ecf2625
commit
8e85e0b7ab
@@ -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
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' if attribute => out.push_str("""),
|
||||
'\n' if attribute => out.push_str(" "),
|
||||
'\r' if attribute => out.push_str(" "),
|
||||
'\t' if attribute => out.push_str("	"),
|
||||
_ => 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;
|
||||
@@ -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 = §ion.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
Reference in New Issue
Block a user