Skip to main content

pdfluent_forms/
actions.rs

1//! Field validation, calculation, and format script hooks (B.7).
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use crate::tree::*;
10
11/// Action trigger types from the `/AA` (Additional Actions) dictionary on a
12/// form field or page.
13///
14/// Each variant maps to a PDF additional-action key per ISO 32000-2 §12.6.3.
15/// Most are field-level; `PageOpen` and `PageClose` are page-level.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ActionTrigger {
18    /// `/K` — fires on every keystroke while a text field is being edited.
19    /// Used for input filtering (e.g. allow only digits).
20    Keystroke,
21    /// `/V` — fires when the field's value is committed (focus loss or
22    /// explicit submit). Used for validation; the script may reject the
23    /// value.
24    Validate,
25    /// `/F` — fires before the field's value is displayed. Used to format
26    /// the visual representation (e.g. number formatting, dates).
27    Format,
28    /// `/C` — fires when any field referenced in this field's calculation
29    /// order changes. Used to derive a value from other fields.
30    Calculate,
31    /// `/E` — fires when the cursor enters the field's annotation area.
32    CursorEnter,
33    /// `/X` — fires when the cursor exits the field's annotation area.
34    CursorExit,
35    /// `/Fo` — fires when the field gains keyboard focus.
36    Focus,
37    /// `/Bl` — fires when the field loses keyboard focus.
38    Blur,
39    /// `/O` — page-level: fires when the page is opened in a viewer.
40    PageOpen,
41    /// `/C` (page-level): fires when the page is closed in a viewer.
42    PageClose,
43}
44
45/// A field action extracted from the /AA dictionary.
46#[derive(Debug, Clone)]
47pub struct FieldAction {
48    /// Which trigger fires this action.
49    pub trigger: ActionTrigger,
50    /// JavaScript source code, if it's a JavaScript action.
51    pub javascript: Option<String>,
52}
53
54/// Callback interface for an external JavaScript engine.
55pub trait JsActionHandler {
56    /// Called on keystroke events (/K). Returns `true` if accepted.
57    fn on_keystroke(
58        &mut self,
59        tree: &mut FieldTree,
60        field_id: FieldId,
61        change: &str,
62        js: &str,
63    ) -> bool;
64    /// Called on validate events (/V). Returns `true` if valid.
65    fn on_validate(&mut self, tree: &mut FieldTree, field_id: FieldId, js: &str) -> bool;
66    /// Called on format events (/F). Returns formatted display string.
67    fn on_format(&mut self, tree: &FieldTree, field_id: FieldId, js: &str) -> Option<String>;
68    /// Called on calculate events (/C). Returns calculated value.
69    fn on_calculate(&mut self, tree: &mut FieldTree, field_id: FieldId, js: &str)
70        -> Option<String>;
71}
72
73/// Run the calculation scripts (`/AA /C`) of all fields in the calculation
74/// order (`/CO`), storing each result in the field's value.
75///
76/// The handler does the script work; see
77/// [`crate::calculate::SandboxedJsHandler`] for the sandboxed one. A field
78/// without a calculate script is skipped, and so is one whose handler returns
79/// `None` (script error, or the script rejected the value).
80pub fn run_calculations(tree: &mut FieldTree, handler: &mut dyn JsActionHandler) {
81    let order: Vec<FieldId> = tree.calculation_order.clone();
82    for field_id in order {
83        let Some(js) = tree.calculate_scripts.get(&field_id).cloned() else {
84            continue;
85        };
86        if let Some(value) = handler.on_calculate(tree, field_id, &js) {
87            tree.get_mut(field_id).value = Some(FieldValue::Text(value));
88        }
89    }
90}
91
92/// Extract action triggers present on a field.
93pub fn field_action_triggers(tree: &FieldTree, id: FieldId) -> Vec<ActionTrigger> {
94    if !tree.get(id).has_actions {
95        return vec![];
96    }
97    vec![
98        ActionTrigger::Keystroke,
99        ActionTrigger::Validate,
100        ActionTrigger::Format,
101        ActionTrigger::Calculate,
102        ActionTrigger::CursorEnter,
103        ActionTrigger::CursorExit,
104        ActionTrigger::Focus,
105        ActionTrigger::Blur,
106    ]
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::flags::FieldFlags;
113    fn make_field(has_actions: bool) -> (FieldTree, FieldId) {
114        let mut tree = FieldTree::new();
115        let id = tree.alloc(FieldNode {
116            partial_name: "f".into(),
117            alternate_name: None,
118            mapping_name: None,
119            field_type: Some(FieldType::Text),
120            flags: FieldFlags::empty(),
121            value: None,
122            default_value: None,
123            default_appearance: None,
124            quadding: None,
125            max_len: None,
126            options: vec![],
127            top_index: None,
128            rect: None,
129            appearance_state: None,
130            on_state: None,
131            page_index: None,
132            parent: None,
133            children: vec![],
134            object_id: None,
135            has_actions,
136            mk: None,
137            border_style: None,
138        });
139        (tree, id)
140    }
141    #[test]
142    fn triggers_empty() {
143        let (tree, id) = make_field(false);
144        assert!(field_action_triggers(&tree, id).is_empty());
145    }
146    #[test]
147    fn triggers_present() {
148        let (tree, id) = make_field(true);
149        assert!(!field_action_triggers(&tree, id).is_empty());
150    }
151}