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"]);
+    }
+}
