mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
M12: XFA prototypes: resolve each element's reference once, cap SOM expressions at 1,024 bytes, and test expansion limits
A prototype shared by many elements was reached again from each of them and its own reference read again every time, so a long reference (1 MB) on a prototype used by 60,000 fields cost tens of gigabytes of hashing while the written template stayed small. Each element's reference is now resolved once. A SOM expression longer than 1,024 bytes names nothing (the longest in 198 local Designer templates is 109 bytes). Tests: a "billion laughs" template (ten levels, each prototype referencing the level below ten times) fails as a whole within the output cap and the form is read as written; a long Designer-style form resolves in full with no warning; the shared long reference; the SOM length limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
1 parent
16c9141f49
commit
7ba0ee5804
3 files changed
+115
-6
No files matched your search
@@ -35,12 +35,15 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
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, 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 the whole template is read
|
||||
unresolved, with one warning saying which; a cut loop or chain is a warning too.
|
||||
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
|
||||
|
||||
@@ -37,6 +37,9 @@ const MAX_LAYERS: usize = 32;
|
||||
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
|
||||
@@ -67,6 +70,7 @@ pub(crate) fn expand(template: Node) -> (Option<String>, Vec<String>) {
|
||||
limit,
|
||||
failed: None,
|
||||
ids: None,
|
||||
refs: HashMap::new(),
|
||||
soms: HashMap::new(),
|
||||
search: MAX_SEARCH,
|
||||
work: MAX_WORK,
|
||||
@@ -111,6 +115,9 @@ struct Writer<'a, 'd> {
|
||||
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.
|
||||
@@ -158,6 +165,18 @@ fn alternative(tag: &str, c: &Node) -> bool {
|
||||
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('#') {
|
||||
@@ -212,6 +231,9 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -315,3 +315,87 @@ fn inherited_attributes_keep_their_namespace_where_the_prefix_is_bound_again() {
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user