mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:35:21 +00:00
M12: XFA prototypes: a work budget for merging, and a SOM search past its budget fails the expansion
A SOM look-up cut short by its budget was cached as "not found", so later references warned that they named nothing and the template was only partly resolved. It now stops the expansion: the template is read unresolved with one warning, as the README says. Merging looked at every child of every prototype layer, also those that write nothing (proto, unchosen ui/value alternatives), so a prototype with many proto children used by many elements did quadratic work bounded only by output. Merging now charges children, attributes and namespaces against a 64 million budget before looking at them, and leaves out the skipped classes before indexing them; namespace look-ups use a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e85e0b7ab
commit
a51eb0ea0c
@@ -36,9 +36,11 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
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.
|
||||
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.
|
||||
- **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
|
||||
|
||||
+56
-7
@@ -39,6 +39,14 @@ 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;
|
||||
/// 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 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
|
||||
@@ -59,6 +67,7 @@ pub(crate) fn expand(template: Node) -> (Option<String>, Vec<String>) {
|
||||
ids: None,
|
||||
soms: HashMap::new(),
|
||||
search: MAX_SEARCH,
|
||||
work: MAX_WORK,
|
||||
active: HashMap::new(),
|
||||
missing: false,
|
||||
external: false,
|
||||
@@ -100,6 +109,8 @@ struct Writer<'a, 'd> {
|
||||
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,
|
||||
@@ -165,6 +176,8 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
}
|
||||
};
|
||||
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
|
||||
@@ -197,7 +210,11 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
return *found;
|
||||
}
|
||||
let found = self.resolve(expr);
|
||||
self.soms.insert(expr, found);
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -229,7 +246,11 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
return;
|
||||
}
|
||||
for c in at.children().filter(Node::is_element) {
|
||||
if self.search == 0 || out.len() > index {
|
||||
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;
|
||||
@@ -264,6 +285,21 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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) {
|
||||
@@ -286,6 +322,16 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
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;
|
||||
@@ -298,10 +344,11 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
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();
|
||||
let mut declared: HashSet<Option<&'a str>> = scope.iter().map(|(n, _)| *n).collect();
|
||||
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) {
|
||||
if name == Some("xml") || !declared.insert(name) {
|
||||
continue;
|
||||
}
|
||||
match name {
|
||||
@@ -345,11 +392,14 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
} 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(Node::is_element) {
|
||||
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;
|
||||
@@ -364,7 +414,7 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
if c.is_text() && i == text_layer {
|
||||
escape(c.text().unwrap_or_default(), false, &mut self.out);
|
||||
}
|
||||
if !c.is_element() {
|
||||
if !c.is_element() || left_out(&c) {
|
||||
continue;
|
||||
}
|
||||
let ctag = c.tag_name().name();
|
||||
@@ -373,8 +423,7 @@ impl<'a, 'd> Writer<'a, 'd> {
|
||||
*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)) {
|
||||
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();
|
||||
|
||||
@@ -227,3 +227,31 @@ fn hostile_references_neither_panic_nor_run_away() {
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user