Skip to main content

xfa_layout_engine/
scripting.rs

1//! Scripting integration โ€” run FormCalc calculate/validate scripts on form fields.
2//!
3//! Implements XFA Spec 3.3 ยง14.3.2 event model for calculate and validate events.
4//! Before layout, the engine executes calculate scripts on fields to compute
5//! derived values, then optionally runs validate scripts to check constraints.
6//!
7//! NOTE: This module handles simple calculate/validate scripts with a flat
8//! interpreter.  The more advanced dynamic scripting (initialize events,
9//! SOM-based field resolution, presence toggling) lives in
10//! `pdf-xfa/src/dynamic.rs` which uses the full FormTree SOM resolver.
11
12// Copyright (c) 2026 Innovation Trigger B.V.
13//
14// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
15// the PDFluent Commercial Licence. See the LICENSE file in this repository --
16// that file travels with the copy you received, which a URL does not.
17
18use std::sync::{atomic::AtomicBool, Arc};
19
20use crate::form::{FormNodeId, FormNodeType, FormTree};
21use xfa_js_sandboxed::{ExecCtx, FieldValues, XfaJsRuntime};
22
23use formcalc_interpreter::interpreter::Interpreter;
24use formcalc_interpreter::lexer::tokenize;
25use formcalc_interpreter::parser;
26use formcalc_interpreter::value::Value;
27
28/// Errors from script execution.
29#[derive(Debug, thiserror::Error)]
30pub enum ScriptError {
31    #[error("FormCalc error in node '{node}': {message}")]
32    /// Script execution error.
33    Execution {
34        /// Node name.
35        node: String,
36        /// Error message.
37        message: String,
38    },
39    #[error("Validation failed for node '{node}': {message}")]
40    /// Validation failed error.
41    ValidationFailed {
42        /// Node name.
43        node: String,
44        /// Error message.
45        message: String,
46    },
47}
48
49/// Result of running all scripts on a form tree.
50#[derive(Debug, Default)]
51pub struct ScriptResult {
52    /// Fields whose values were updated by calculate scripts.
53    pub updated_fields: Vec<FormNodeId>,
54    /// Validation failures (node id and error message).
55    pub validation_errors: Vec<(FormNodeId, String)>,
56}
57
58/// Execute all calculate scripts in the form tree, updating field values.
59///
60/// Walks the tree depth-first. For each Field node with a `calculate` script,
61/// evaluates the script and sets the field's value to the result.
62/// Returns a summary of which fields were updated.
63pub fn run_calculations(form: &mut FormTree) -> Result<ScriptResult, ScriptError> {
64    let mut result = ScriptResult::default();
65    let mut interpreter = Interpreter::new();
66
67    // Collect all nodes with calculate scripts first (to avoid borrow issues)
68    let calc_nodes: Vec<(FormNodeId, String, String)> = form
69        .nodes
70        .iter()
71        .enumerate()
72        .filter_map(|(i, node)| {
73            node.calculate
74                .as_ref()
75                .map(|script| (FormNodeId(i), node.name.clone(), script.clone()))
76        })
77        .collect();
78
79    for (id, _name, script) in calc_nodes {
80        // Gracefully skip scripts that fail (e.g. unrecognized JavaScript syntax,
81        // unsupported FormCalc constructs). Matches Adobe's best-effort behavior.
82        let value = match eval_script(&mut interpreter, &script) {
83            Ok(v) => v,
84            Err(_) => continue,
85        };
86
87        // Convert the FormCalc result to a string and set the field value
88        let value_str = value_to_string(&value);
89
90        let node = form.get_mut(id);
91        if let FormNodeType::Field { ref mut value } = node.node_type {
92            if *value != value_str {
93                *value = value_str;
94                result.updated_fields.push(id);
95            }
96        }
97    }
98
99    Ok(result)
100}
101
102/// Execute all validate scripts in the form tree, collecting failures.
103///
104/// For each Field node with a `validate` script, evaluates the script.
105/// A validation passes if the result is truthy (non-zero number, non-empty string).
106pub fn run_validations(form: &FormTree) -> Result<ScriptResult, ScriptError> {
107    let mut result = ScriptResult::default();
108    let mut interpreter = Interpreter::new();
109
110    for (i, node) in form.nodes.iter().enumerate() {
111        if let Some(ref script) = node.validate {
112            let val =
113                eval_script(&mut interpreter, script).map_err(|e| ScriptError::Execution {
114                    node: node.name.clone(),
115                    message: e,
116                })?;
117
118            if !is_truthy(&val) {
119                let msg = format!(
120                    "Validation script returned falsy value: {}",
121                    value_to_string(&val)
122                );
123                result.validation_errors.push((FormNodeId(i), msg));
124            }
125        }
126    }
127
128    Ok(result)
129}
130
131/// Run calculate scripts, then layout. Convenience wrapper for the common flow.
132///
133/// Returns the script result so callers can inspect which fields changed
134/// and whether validations passed.
135pub fn prepare_form(form: &mut FormTree) -> Result<ScriptResult, ScriptError> {
136    let mut calc_result = run_calculations(form)?;
137    let val_result = run_validations(form)?;
138    calc_result.validation_errors = val_result.validation_errors;
139    Ok(calc_result)
140}
141
142/// Evaluate a FormCalc script string and return the result value.
143fn eval_script(interpreter: &mut Interpreter, script: &str) -> Result<Value, String> {
144    let tokens = tokenize(script).map_err(|e| format!("Tokenize error: {e}"))?;
145    let ast = parser::parse(tokens).map_err(|e| format!("Parse error: {e}"))?;
146    interpreter
147        .exec(&ast)
148        .map_err(|e| format!("Runtime error: {e}"))
149}
150
151/// Convert a FormCalc Value to a display string.
152fn value_to_string(val: &Value) -> String {
153    match val {
154        Value::Number(n) => {
155            // Format integers without decimal point
156            if *n == n.floor() && n.is_finite() {
157                format!("{}", *n as i64)
158            } else {
159                format!("{n}")
160            }
161        }
162        Value::String(s) => s.clone(),
163        Value::Null => String::new(),
164    }
165}
166
167/// Check if a FormCalc value is truthy (for validation results).
168fn is_truthy(val: &Value) -> bool {
169    match val {
170        Value::Number(n) => *n != 0.0,
171        Value::String(s) => !s.is_empty(),
172        Value::Null => false,
173    }
174}
175
176/// Execute JavaScript calculate scripts on specific form fields.
177///
178/// This is the `application/x-javascript` dispatch path. Callers provide an
179/// explicit list of `(target_field_id, js_script_body)` pairs โ€” the language
180/// detection happens at a higher layer (the template parser / XFA merger)
181/// which knows the `contentType` of each `<script>` element.
182///
183/// For each pair the function:
184/// 1. Snapshots all field raw-values into a [`FieldValues`] store.
185/// 2. Executes the JS script body via the sandboxed [`XfaJsRuntime`].
186/// 3. Flushes any `rawValue` mutations back into the `FormTree`.
187///
188/// Errors from individual scripts are swallowed (best-effort, matching Adobe
189/// behaviour). Use the returned [`ScriptResult`] to inspect which fields were
190/// updated.
191pub fn run_js_calculations(
192    form: &mut FormTree,
193    scripts: &[(FormNodeId, &str)],
194    runtime: &mut XfaJsRuntime,
195    cancel: Arc<AtomicBool>,
196) -> Result<ScriptResult, ScriptError> {
197    let mut result = ScriptResult::default();
198
199    for (target_id, script) in scripts {
200        // Snapshot all field values.
201        let mut field_values = FieldValues::new();
202        for node in &form.nodes {
203            if let FormNodeType::Field { value } = &node.node_type {
204                if !node.name.is_empty() {
205                    field_values.set(&node.name, value.as_str());
206                }
207            }
208        }
209
210        let ctx = ExecCtx {
211            fields: &mut field_values,
212            cancel: Arc::clone(&cancel),
213            event_new_text: None,
214        };
215
216        if let Ok(js_val) = runtime.execute_calculate(script, ctx) {
217            // If the script returned a non-null/undefined value AND the
218            // target field exists, use it as the new rawValue (same
219            // semantics as FormCalc calculate).
220            let raw = js_val.to_raw_string();
221            let mut wrote_via_return = false;
222            if !raw.is_empty() {
223                let node = &mut form.nodes[target_id.0];
224                if let FormNodeType::Field { value } = &mut node.node_type {
225                    if *value != raw {
226                        *value = raw;
227                        result.updated_fields.push(*target_id);
228                        wrote_via_return = true;
229                    }
230                }
231            }
232
233            // Also apply explicit xfa.form.<field>.rawValue writes.
234            for (i, node) in form.nodes.iter_mut().enumerate() {
235                if let FormNodeType::Field { value } = &mut node.node_type {
236                    if let Some(new_val) = field_values.get(&node.name) {
237                        if new_val != value.as_str() {
238                            let id = FormNodeId(i);
239                            // Don't double-count if we already wrote this via return.
240                            if !(wrote_via_return && id == *target_id) {
241                                *value = new_val.to_string();
242                                if !result.updated_fields.contains(&id) {
243                                    result.updated_fields.push(id);
244                                }
245                            }
246                        }
247                    }
248                }
249            }
250        }
251        // Errors are swallowed; the script is skipped (best-effort).
252    }
253
254    Ok(result)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::form::{FormNode, Occur};
261    use crate::text::FontMetrics;
262    use crate::types::{BoxModel, LayoutStrategy};
263
264    fn make_field_with_calc(
265        tree: &mut FormTree,
266        name: &str,
267        initial_value: &str,
268        calculate: Option<&str>,
269    ) -> FormNodeId {
270        tree.add_node(FormNode {
271            name: name.to_string(),
272            node_type: FormNodeType::Field {
273                value: initial_value.to_string(),
274            },
275            box_model: BoxModel {
276                width: Some(100.0),
277                height: Some(20.0),
278                max_width: f64::MAX,
279                max_height: f64::MAX,
280                ..Default::default()
281            },
282            layout: LayoutStrategy::Positioned,
283            children: vec![],
284            occur: Occur::once(),
285            font: FontMetrics::default(),
286            calculate: calculate.map(|s| s.to_string()),
287            validate: None,
288            column_widths: vec![],
289            col_span: 1,
290        })
291    }
292
293    #[test]
294    fn calculate_script_updates_field_value() {
295        let mut tree = FormTree::new();
296        make_field_with_calc(&mut tree, "Total", "0", Some("10 + 20"));
297
298        let result = run_calculations(&mut tree).unwrap();
299
300        assert_eq!(result.updated_fields.len(), 1);
301        if let FormNodeType::Field { value } = &tree.get(result.updated_fields[0]).node_type {
302            assert_eq!(value, "30");
303        } else {
304            panic!("Expected Field node");
305        }
306    }
307
308    #[test]
309    fn calculate_script_string_result() {
310        let mut tree = FormTree::new();
311        make_field_with_calc(
312            &mut tree,
313            "Greeting",
314            "",
315            Some("Concat(\"Hello\", \" \", \"World\")"),
316        );
317
318        let result = run_calculations(&mut tree).unwrap();
319
320        assert_eq!(result.updated_fields.len(), 1);
321        if let FormNodeType::Field { value } = &tree.get(result.updated_fields[0]).node_type {
322            assert_eq!(value, "Hello World");
323        }
324    }
325
326    #[test]
327    fn no_update_when_value_unchanged() {
328        let mut tree = FormTree::new();
329        make_field_with_calc(&mut tree, "Same", "42", Some("42"));
330
331        let result = run_calculations(&mut tree).unwrap();
332
333        assert_eq!(result.updated_fields.len(), 0); // Value didn't change
334    }
335
336    #[test]
337    fn fields_without_scripts_are_untouched() {
338        let mut tree = FormTree::new();
339        make_field_with_calc(&mut tree, "Static", "original", None);
340
341        let result = run_calculations(&mut tree).unwrap();
342
343        assert_eq!(result.updated_fields.len(), 0);
344        if let FormNodeType::Field { value } = &tree.get(FormNodeId(0)).node_type {
345            assert_eq!(value, "original");
346        }
347    }
348
349    #[test]
350    fn validation_passes_for_truthy() {
351        let mut tree = FormTree::new();
352        let id = tree.add_node(FormNode {
353            name: "Amount".to_string(),
354            node_type: FormNodeType::Field {
355                value: "100".to_string(),
356            },
357            box_model: BoxModel {
358                width: Some(100.0),
359                height: Some(20.0),
360                max_width: f64::MAX,
361                max_height: f64::MAX,
362                ..Default::default()
363            },
364            layout: LayoutStrategy::Positioned,
365            children: vec![],
366            occur: Occur::once(),
367            font: FontMetrics::default(),
368            calculate: None,
369            validate: Some("1".to_string()), // truthy
370            column_widths: vec![],
371            col_span: 1,
372        });
373        let _ = id;
374
375        let result = run_validations(&tree).unwrap();
376        assert!(result.validation_errors.is_empty());
377    }
378
379    #[test]
380    fn validation_fails_for_falsy() {
381        let mut tree = FormTree::new();
382        tree.add_node(FormNode {
383            name: "Required".to_string(),
384            node_type: FormNodeType::Field {
385                value: "".to_string(),
386            },
387            box_model: BoxModel {
388                width: Some(100.0),
389                height: Some(20.0),
390                max_width: f64::MAX,
391                max_height: f64::MAX,
392                ..Default::default()
393            },
394            layout: LayoutStrategy::Positioned,
395            children: vec![],
396            occur: Occur::once(),
397            font: FontMetrics::default(),
398            calculate: None,
399            validate: Some("0".to_string()), // falsy
400            column_widths: vec![],
401            col_span: 1,
402        });
403
404        let result = run_validations(&tree).unwrap();
405        assert_eq!(result.validation_errors.len(), 1);
406    }
407
408    #[test]
409    fn prepare_form_runs_both() {
410        let mut tree = FormTree::new();
411        // Field with calculate script
412        make_field_with_calc(&mut tree, "Sum", "0", Some("5 * 3"));
413        // Field with validation
414        tree.add_node(FormNode {
415            name: "Check".to_string(),
416            node_type: FormNodeType::Field {
417                value: "ok".to_string(),
418            },
419            box_model: BoxModel {
420                width: Some(100.0),
421                height: Some(20.0),
422                max_width: f64::MAX,
423                max_height: f64::MAX,
424                ..Default::default()
425            },
426            layout: LayoutStrategy::Positioned,
427            children: vec![],
428            occur: Occur::once(),
429            font: FontMetrics::default(),
430            calculate: None,
431            validate: Some("0".to_string()), // will fail
432            column_widths: vec![],
433            col_span: 1,
434        });
435
436        let result = prepare_form(&mut tree).unwrap();
437
438        // Calculate ran
439        assert_eq!(result.updated_fields.len(), 1);
440        if let FormNodeType::Field { value } = &tree.get(FormNodeId(0)).node_type {
441            assert_eq!(value, "15");
442        }
443        // Validation ran
444        assert_eq!(result.validation_errors.len(), 1);
445    }
446
447    #[test]
448    fn complex_calculation() {
449        let mut tree = FormTree::new();
450        make_field_with_calc(&mut tree, "Tax", "0", Some("Round(100 * 0.21, 2)"));
451
452        let result = run_calculations(&mut tree).unwrap();
453        assert_eq!(result.updated_fields.len(), 1);
454        if let FormNodeType::Field { value } = &tree.get(result.updated_fields[0]).node_type {
455            assert_eq!(value, "21");
456        }
457    }
458}