collective

Browse / github.com/storytold/pdfcraft / patch

M12: an XFA form's scripts can call the script objects of their subform

M12: an XFA form's scripts can call the script objects of their subform ## What this changes An XFA form can keep named JavaScripts under a subform's `<variables>`: `<script name="LOVUtils" contentType="application/x-javascript">`. Its scripts call them by name (`LOVUtils.check(x)`). The template parser ignored `<variables>`, so every such call failed with `ReferenceError: LOVUtils is not defined`, and with it the form's initialize and calculate scripts. In the pdf.js XFA test corpus the failing names include `LOVUtils`, `validator`, `FormInit`, `myfunctions`, `layoutUtils`, `messages`, `Block` and `ColorFieldsValidation`. - The parser now reads them (JavaScript

Status
open
Score
0.00
Adopters
0 other operators
Author
hikashop-nicolas (imported from pull request #4948, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+420 −6 in 12 files
Review
pending
Shared
2026-10-28
Id
91e9de4ecfb6168d84d667af

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/tests.rs crates/engine/src/xfa.rs crates/js/src/run.rs crates/js/src/tests.rs crates/js/src/xfa.rs crates/js/src/xfa/run.rs crates/js/src/xfa_session.rs crates/xfa/src/fixtures.rs crates/xfa/src/lib.rs crates/xfa/src/model.rs crates/xfa/src/parse.rs crates/xfa/src/script.rs

diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -6177,6 +6177,20 @@ fn repeated_xfa_print_buttons_run_their_click_script() {     } } +/// A script object (`<variables><script name="Calc">`) is called by its name from the scripts of its subform and below,+/// from a deeper object to a shallower one, and only the ones a script uses are defined for it.+#[test]+fn xfa_scripts_call_the_script_objects_of_their_subform() {+    let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::script_objects_template()));+    let mut s = Session::new().with_clock(|| 1_700_000_000);+    let id = s.open("objects.pdf", None, bytes, None).expect("opens");+    let value = |n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}"));+    assert_eq!(value("total"), vec!["25".to_string()]);+    assert_eq!(value("label"), vec!["Hello Ada 7".to_string()]);+    let out = s.take_js_output(id);+    assert!(out.errors.is_empty(), "{:?}", out.errors);+}+ #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {     let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs--- a/crates/engine/src/xfa.rs+++ b/crates/engine/src/xfa.rs@@ -661,7 +661,20 @@ impl<'a, 'b> Runner<'a, 'b> {         self.check_live();         let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };         let root = to_js(&self.live().root);+        // a JavaScript event may call the script objects of its subform, which are defined in front of it+        let prelude = if ev.formcalc {+            String::new()+        } else {+            match pdfcraft_xfa::script_objects_for(self.tpl, &ev.som, &ev.script) {+                Ok(prelude) => prelude,+                Err(why) => {+                    self.error(format!("{} ({}): {why}", ev.som, ev.activity));+                    return Ok(None);+                }+            }+        };         let script = Script {+            prelude,             source: ev.script.clone(),             formcalc: ev.formcalc,             event,diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs--- a/crates/js/src/run.rs+++ b/crates/js/src/run.rs@@ -36,6 +36,11 @@ pub(crate) fn refuse(source: &str) -> Option<String> {     if source.len() > MAX_SCRIPT_BYTES {         return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024));     }+    refuse_nesting(source)+}++/// [`refuse`] for code with a length limit of its own: only its nesting is checked.+pub(crate) fn refuse_nesting(source: &str) -> Option<String> {     let too_deep = || Some("the script is nested too deeply to run safely".to_string());     let b = source.as_bytes();     let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize);diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs--- a/crates/js/src/tests.rs+++ b/crates/js/src/tests.rs@@ -1412,6 +1412,7 @@ mod session {     /// default limits and a 3 s timeout.     fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script {         Script {+            prelude: String::new(),             source: source.into(),             formcalc,             event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },@@ -1463,6 +1464,30 @@ mod session {         assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());     } +    /// The script objects a form keeps are defined in front of the script, count against their own bound rather+    /// than the script's size, and a broken or oversized one fails only the scripts that use it.+    #[test]+    fn a_scripts_prelude_is_defined_before_it_and_bounded_on_its_own() {+        let mut s = XfaSession::new(XfaDoc { page_count: 1, ..Default::default() });+        let mut with = script("Calc.twice(21)", false, "form1[0].page1[0].total[0]", "calculate");+        with.prelude = "var Calc = { twice: function (x) { return x * 2; } };\n".into();+        assert_eq!(s.run(with).result.as_deref(), Some("42"));+        // far past the size a script may have, but within what script objects may add+        let mut big = script("Big.n", false, "form1[0].page1[0].total[0]", "calculate");+        big.prelude = format!("var Big = {{ n: 7 }};\n// {}\n", "x".repeat(300 * 1024));+        let o = s.run(big);+        assert_eq!(o.result.as_deref(), Some("7"), "{o:?}");+        let mut huge = script("1", false, "form1[0].page1[0].total[0]", "calculate");+        huge.prelude = " ".repeat(crate::xfa_session::MAX_PRELUDE_BYTES + 1);+        let o = s.run(huge);+        assert!(o.error.as_deref().is_some_and(|e| e.contains("script objects are too large")), "{o:?}");+        // a script object that does not parse fails the script, not the session+        let mut broken = script("1", false, "form1[0].page1[0].total[0]", "calculate");+        broken.prelude = "var Bad = (function () { ;return { ;\n".into();+        assert!(s.run(broken).error.is_some());+        assert_eq!(s.run(script("1 + 1", false, "form1[0].page1[0].total[0]", "calculate")).result.as_deref(), Some("2"));+    }+     /// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's     /// time limit still runs and returns its result, and the session stays alive.     #[test]diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs--- a/crates/js/src/xfa.rs+++ b/crates/js/src/xfa.rs@@ -32,5 +32,5 @@ pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som}; pub(crate) use run::run_within_limits; #[cfg(not(target_arch = "wasm32"))] pub(crate) use run::{ENGINE_START_LIMIT, warm_up};-pub(crate) use run::{run_here, run_within};+pub(crate) use run::{run_here_with, run_within}; pub use run::{run_xfa, run_xfa_within};diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs--- a/crates/js/src/xfa/run.rs+++ b/crates/js/src/xfa/run.rs@@ -126,6 +126,20 @@ pub(crate) fn warm_up() {  /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {+    run_here_with(script, "", event, doc, root, limits, started)+}++/// [`run_here`] with `prelude`, the form's script objects, defined first. They are part of the form, so they are not held+/// to the size a script may have (their own bound is the caller's), only to its nesting.+pub(crate) fn run_here_with(+    script: &str,+    prelude: &str,+    event: &XfaEvent,+    doc: &XfaDoc,+    root: XfaNode,+    limits: Limits,+    started: &dyn Fn(),+) -> XfaOutcome {     warm_up();     let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() {         Ok(ctx) => ctx,@@ -141,6 +155,12 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo         install(&mut ctx, event, doc, current)?;         // Ready for the script: its time starts now, the set-up above not charged to it.         started();+        if !prelude.is_empty() {+            if let Some(why) = crate::run::refuse_nesting(prelude) {+                return Err(boa_engine::JsNativeError::range().with_message(why).into());+            }+            ctx.eval(Source::from_bytes(prelude.as_bytes()))?;+        }         ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?;         // Direct eval inside a function called on the node: `this` is the node and the         // completion value is the script's last expression (what a calculate script yields).diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs--- a/crates/js/src/xfa_session.rs+++ b/crates/js/src/xfa_session.rs@@ -30,9 +30,15 @@ pub fn engines_started() -> u64 {     ENGINES_STARTED.with(std::cell::Cell::get) } +/// The most a script's script objects may add to it.+pub const MAX_PRELUDE_BYTES: usize = 2 * 1024 * 1024;+ /// What one script run needs. #[derive(Clone, Debug)] pub struct Script {+    /// The script objects the source calls, defined in front of it (read for what they are: part of the form, not a+    /// script to size up on its own, though bounded by [`MAX_PRELUDE_BYTES`]).+    pub prelude: String,     pub source: String,     pub formcalc: bool,     pub event: XfaEvent,@@ -64,7 +70,11 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {         started();         return XfaOutcome { error: Some(why), ..Default::default() };     }-    crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started)+    if s.prelude.len() > MAX_PRELUDE_BYTES {+        started();+        return XfaOutcome { error: Some(format!("the form's script objects are too large ({} KiB)", s.prelude.len() / 1024)), ..Default::default() };+    }+    crate::xfa::run_here_with(&s.source, &s.prelude, &s.event, doc, s.root, s.limits, started) }  #[cfg(not(target_arch = "wasm32"))]diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs--- a/crates/xfa/src/fixtures.rs+++ b/crates/xfa/src/fixtures.rs@@ -739,3 +739,39 @@ if (this.rawValue == "1") { "##     .to_string() }++/// A form with script objects (`<variables><script name="...">`): `Calc` on the root subform, `Names` on the `page`+/// subform, and fields that call them from an initialize script. `total` uses `Calc.twice` and the variable `Calc.step`;+/// `label` uses `Names.greet`, which itself uses `Calc`.+pub fn script_objects_template() -> String {+    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="form" layout="tb">+ <pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>+ <variables>+  <script name="Calc" contentType="application/x-javascript">+// a comment that says function not_a_function+var step = 5;+function twice(x) { return x * 2 + step; }+  </script>+  <script name="Unused" contentType="application/x-javascript">function never() { throw new Error("not defined unless used"); }</script>+ </variables>+ <subform name="page" layout="tb">+  <variables>+   <script name="Names" contentType="application/x-javascript">function greet(n) { return "Hello " + n + " " + Calc.twice(1); }</script>+   <script name="Formcalc" contentType="application/x-formcalc">$ = 1</script>+  </variables>+  <field name="total" w="2in" h="0.3in"><ui><textEdit/></ui>+   <event activity="initialize"><script contentType="application/x-javascript">this.rawValue = Calc.twice(10);</script></event>+  </field>+  <field name="label" w="2in" h="0.3in"><ui><textEdit/></ui>+   <event activity="initialize"><script contentType="application/x-javascript">this.rawValue = Names.greet("Ada");</script></event>+  </field>+ </subform>+</subform>+</template>+</xdp:xdp>+"##+    .to_string()+}diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs--- a/crates/xfa/src/lib.rs+++ b/crates/xfa/src/lib.rs@@ -103,7 +103,7 @@ pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, exis pub use picture::typed_date; pub use script::{     FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text,-    fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,+    fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, script_objects_for, set_overrides, template_of, };  #[derive(Debug, thiserror::Error)]diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs--- a/crates/xfa/src/model.rs+++ b/crates/xfa/src/model.rs@@ -416,10 +416,20 @@ impl Default for Occur {     } } +/// A named JavaScript the form keeps under a subform's `<variables>` (a "script object"): its functions are called+/// through its name, from any script of that subform or below.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct ScriptObject {+    pub name: String,+    pub text: String,+}+ #[derive(Clone, Debug, Default, PartialEq)] pub struct Subform {     pub common: Common,     pub layout: Layout,+    /// `<variables><script name="…">`: JavaScript objects the subform's scripts call by name.+    pub script_objects: Vec<ScriptObject>,     pub children: Vec<Node>,     /// `<event activity="…"><script>` on the subform (initialize, calculate, …).     pub scripts: Vec<Script>,diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs--- a/crates/xfa/src/parse.rs+++ b/crates/xfa/src/parse.rs@@ -7,8 +7,8 @@ use roxmltree::Node as XmlNode; use crate::XfaError; use crate::model::{     Access, Anchor, Area, Border, Caption, ChoiceList, Color, Common, Corner, Draw, Edge, ExclGroup, Field, FontSpec, HAlign, Layout, Margin, Node,-    Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Rect, RichText, Run, Script, Stroke, Subform, Template, Ui, VAlign,-    Value,+    Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Rect, RichText, Run, Script, ScriptObject, Stroke, Subform,+    Template, Ui, VAlign, Value, };  /// Deepest element nesting followed (templates are shallow; hostile ones are not).@@ -558,6 +558,29 @@ fn parse_script(s: XmlNode, activity: &str) -> Script { }  /// `<event activity="…"><script>…</script></event>` children.+/// The JavaScript objects under `<variables>`: `<script name="LOVUtils" contentType="application/x-javascript">`.+fn parse_script_objects(n: XmlNode) -> Vec<ScriptObject> {+    let mut out = Vec::new();+    for vars in children(n, "variables").take(8) {+        for s in children(vars, "script").take(64) {+            let Some(name) = s.attribute("name").map(str::trim).filter(|v| is_js_identifier(v)) else { continue };+            if !s.attribute("contentType").unwrap_or("").to_ascii_lowercase().contains("javascript") {+                continue;+            }+            let text = text_of(s);+            if !text.trim().is_empty() {+                out.push(ScriptObject { name: name.to_string(), text });+            }+        }+    }+    out+}++fn is_js_identifier(s: &str) -> bool {+    let mut chars = s.chars();+    chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')+}+ fn parse_events(n: XmlNode) -> Vec<Script> {     let mut scripts = Vec::new();     for ev in children(n, "event").take(64) {@@ -640,6 +663,7 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subfor         layout: Layout::parse(n.attribute("layout")),         children: children_out,         scripts: parse_events(n),+        script_objects: parse_script_objects(n),         page_set,         occur: parse_occur(child(n, "occur")),         break_before_page: break_to_page("breakBefore")diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs--- a/crates/xfa/src/script.rs+++ b/crates/xfa/src/script.rs@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap}; use pdfcraft_cos::{Dict, Document, Object, PdfString};  use crate::data::{DataNode, data_path, parse_datasets};-use crate::model::{Access, Field, Node, Presence, Script, SomIndices, Template};+use crate::model::{Access, Field, Node, Presence, Script, ScriptObject, SomIndices, Subform, Template}; use crate::{Report, XfaError};  /// Private key on the AcroForm: presence scripts set, by SOM path.@@ -737,3 +737,260 @@ pub fn refresh_value(live: &mut FormNode, tpl: &Template, data: Option<&DataNode pub fn data_of(doc: &Document) -> Option<DataNode> {     crate::read_packets(doc).ok().flatten().and_then(|p| parse_datasets(&p.xdp)) }++/// JavaScript that defines the script objects a script at `som` calls by name: `LOVUtils.check(x)` finds the+/// `<variables><script name="LOVUtils">` of the subform the script sits in, or of one above it. Only the objects+/// the script uses (and those they use) are defined, since every script runs in a fresh context. Empty when it uses none.+/// `Err` when the objects it needs add up to more than [`MAX_SCRIPT_OBJECT_BYTES`].+pub fn script_objects_for(tpl: &Template, som: &str, script: &str) -> Result<String, String> {+    // The subforms from the root down to the one holding the script, as the SOM names them (unnamed ones in between too).+    let mut chain: Vec<&Subform> = vec![&tpl.root];+    for part in som.split('.').skip(1) {+        let name = part.split('[').next().unwrap_or(part);+        let Some(&last) = chain.last() else { break };+        let mut found = Vec::new();+        if !subform_path(&last.children, name, &mut found) {+            break;+        }+        chain.extend(found);+    }+    // Inner objects hide outer ones of the same name.+    let mut objects: Vec<&ScriptObject> = Vec::new();+    for sf in &chain {+        for o in &sf.script_objects {+            objects.retain(|x| x.name != o.name);+            objects.push(o);+        }+    }+    if objects.is_empty() {+        return Ok(String::new());+    }+    let mentions = |text: &str, name: &str| -> bool {+        text.match_indices(name).any(|(i, _)| {+            let before = text[..i].chars().next_back();+            let after = text[i + name.len()..].chars().next();+            let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$');+            !word(before) && !word(after)+        })+    };+    let mut used: Vec<&ScriptObject> = Vec::new();+    let mut bytes = 0usize;+    let mut texts = vec![script];+    while let Some(text) = texts.pop() {+        for o in &objects {+            if !used.iter().any(|u| std::ptr::eq(*u, *o)) && mentions(text, &o.name) {+                bytes = bytes.saturating_add(o.text.len());+                if bytes > MAX_SCRIPT_OBJECT_BYTES {+                    return Err(format!(+                        "the form's script objects are too large ({} KiB; the limit is {} KiB)",+                        bytes / 1024,+                        MAX_SCRIPT_OBJECT_BYTES / 1024+                    ));+                }+                used.push(o);+                texts.push(&o.text);+            }+        }+    }+    // Defined in the order they were declared.+    used.sort_by_key(|u| objects.iter().position(|o| std::ptr::eq(*o, *u)));+    let mut out = String::new();+    for o in used {+        let names = declared_names(&o.text);+        let members: Vec<String> = names.iter().map(|n| format!("{n}: typeof {n} === 'undefined' ? undefined : {n}")).collect();+        out.push_str(&format!("var {} = (function () {{\n{}\n;return {{ {} }};\n}})();\n", o.name, o.text, members.join(", ")));+    }+    Ok(out)+}++/// The most the script objects a script needs may add up to.+pub const MAX_SCRIPT_OBJECT_BYTES: usize = 2 * 1024 * 1024;++/// The functions and variables a script declares at its top level or below (`function f`, `var v`, `let l`, `const c`), found by+/// reading the code without its comments and strings.+fn declared_names(text: &str) -> Vec<String> {+    const RESERVED: &[&str] = &[+        "function",+        "var",+        "let",+        "const",+        "if",+        "else",+        "for",+        "while",+        "do",+        "switch",+        "case",+        "default",+        "return",+        "break",+        "continue",+        "new",+        "delete",+        "typeof",+        "void",+        "in",+        "instanceof",+        "this",+        "null",+        "true",+        "false",+        "try",+        "catch",+        "finally",+        "throw",+        "with",+        "class",+        "extends",+        "super",+        "yield",+        "await",+        "undefined",+        "arguments",+        "eval",+    ];+    let b = text.as_bytes();+    let mut names: Vec<String> = Vec::new();+    let mut i = 0;+    let mut declaring = false;+    // inside a `var a = 1, b = f(2, 3);` list: the bracket depth it sits at, so a comma at that depth names the next one+    let (mut depth, mut list): (i32, Option<i32>) = (0, None);+    while i < b.len() && names.len() < 256 {+        let c = b[i];+        match c {+            b'/' if b.get(i + 1) == Some(&b'/') => {+                while i < b.len() && b[i] != b'\n' {+                    i += 1;+                }+            }+            b'/' if b.get(i + 1) == Some(&b'*') => {+                i += 2;+                while i < b.len() && !b[i..].starts_with(b"*/") {+                    i += 1;+                }+                i += 2;+            }+            b'\'' | b'"' | b'`' => {+                i += 1;+                while i < b.len() && b[i] != c {+                    i += if b[i] == b'\\' { 2 } else { 1 };+                }+                i += 1;+                declaring = false;+            }+            c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => {+                let start = i;+                while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') {+                    i += 1;+                }+                let word = &text[start..i];+                // `a.function` or `x.var` is a property, not a declaration+                let after_dot = text[..start].trim_end().ends_with('.');+                if declaring && !RESERVED.contains(&word) && !after_dot {+                    if !names.iter().any(|n| n == word) && word.len() < 64 {+                        names.push(word.to_string());+                    }+                    declaring = false;+                } else {+                    declaring = !after_dot && matches!(word, "function" | "var" | "let" | "const");+                    if declaring {+                        list = (word != "function").then_some(depth);+                    }+                }+            }+            c if c.is_ascii_whitespace() || c == b'*' => i += 1,+            c => {+                declaring = false;+                match c {+                    b'(' | b'[' | b'{' => depth = depth.saturating_add(1),+                    b')' | b']' | b'}' => {+                        depth = depth.saturating_sub(1);+                        if list.is_some_and(|d| depth < d) {+                            list = None;+                        }+                    }+                    b',' if list == Some(depth) => declaring = true,+                    b';' if list.is_some_and(|d| depth <= d) => list = None,+                    _ => {}+                }+                i += 1;+            }+        }+    }+    names+}++/// The subforms down to the one named `name` (unnamed subforms and areas between are walked through, the unnamed subforms+/// kept in the path since their script objects reach what is below them); `false` leaves `path` as it was.+fn subform_path<'a>(nodes: &'a [Node], name: &str, path: &mut Vec<&'a Subform>) -> bool {+    for n in nodes {+        match n {+            Node::Subform(sf) if sf.common.name.as_deref() == Some(name) => {+                path.push(sf);+                return true;+            }+            Node::Subform(sf) if sf.common.name.is_none() => {+                path.push(sf);+                if subform_path(&sf.children, name, path) {+                    return true;+                }+                path.pop();+            }+            Node::Area(a) if subform_path(&a.children, name, path) => return true,+            _ => {}+        }+    }+    false+}++#[cfg(test)]+mod script_object_tests {+    use super::{declared_names, script_objects_for};++    #[test]+    fn a_script_gets_the_objects_it_uses_from_its_subforms() {+        let (tpl, _) = crate::parse(&crate::fixtures::script_objects_template()).expect("parses");+        let prelude = script_objects_for(&tpl, "form[0].page[0].total[0]", "this.rawValue = Names.greet('x');").unwrap();+        // Names is used directly, Calc by Names; Unused and the FormCalc object are not defined+        assert!(prelude.contains("var Calc =") && prelude.contains("var Names ="), "{prelude}");+        assert!(prelude.find("var Calc =") < prelude.find("var Names ="), "declared outer first");+        assert!(!prelude.contains("var Unused") && !prelude.contains("Formcalc"), "{prelude}");+        // from the root subform the inner object is out of reach, and a script that names none gets nothing+        assert!(!script_objects_for(&tpl, "form[0]", "Names.greet()").unwrap().contains("var Names"));+        assert_eq!(script_objects_for(&tpl, "form[0].page[0].total[0]", "this.rawValue = 1;").unwrap(), "");+        // the objects of an unnamed subform between named ones reach the scripts below it+        let (mut tpl, _) = crate::parse(&crate::fixtures::script_objects_template()).expect("parses");+        let page = tpl+            .root+            .children+            .iter_mut()+            .find_map(|n| if let crate::model::Node::Subform(sf) = n { Some(sf) } else { None })+            .expect("the page subform");+        let inner = std::mem::take(page);+        page.children.push(crate::model::Node::Subform(Box::new(crate::model::Subform {+            script_objects: vec![crate::model::ScriptObject { name: "Mid".into(), text: "function m() { return 1; }".into() }],+            children: vec![crate::model::Node::Subform(inner)],+            ..Default::default()+        })));+        let prelude = script_objects_for(&tpl, "form[0].page[0].total[0]", "Mid.m()").unwrap();+        assert!(prelude.contains("var Mid ="), "{prelude}");+        // objects that add up to more than the bound are refused, not built+        let (mut tpl, _) = crate::parse(&crate::fixtures::script_objects_template()).expect("parses");+        tpl.root.script_objects.push(crate::model::ScriptObject { name: "Huge".into(), text: " ".repeat(super::MAX_SCRIPT_OBJECT_BYTES + 1) });+        let why = script_objects_for(&tpl, "form[0]", "Huge.x").unwrap_err();+        assert!(why.contains("too large"), "{why}");+    }++    #[test]+    fn declared_names_are_read_without_comments_strings_or_properties() {+        let text = "//==== function\n/* var hidden */ var counter = 0; function check(x) { return \"function fake\" + x.function; }\nfunction* gen() {}\nlet last;";+        assert_eq!(declared_names(text), ["counter", "check", "gen", "last"]);+        // every declarator of a list, but not the commas inside its calls, arrays and objects+        assert_eq!(+            declared_names("var a = 1, b = f(2, c), d = [3, 4], e = { x: 1, y: 2 }, g; let h = 5; function i() { var j = 1, k; }"),+            ["a", "b", "d", "e", "g", "h", "i", "j", "k"]+        );+        // words that are only keywords in some places are names+        assert_eq!(declared_names("var get = 1; function set() {} var of = 2, async = 3, static = 4;"), ["get", "set", "of", "async", "static"]);+    }+}