Skip to main content

pdfrum_form/
cascade.rs

1//! The four points where a field's `/AA` scripts can intervene.
2//!
3//! # The defaults are not stubs
4//!
5//! This is worth stating plainly, because the natural reading of a trait
6//! whose every method has a default is "the real implementation is missing".
7//! It is not. A PDF viewer built without a JavaScript engine runs the
8//! *identical* commit path — same functions, same order, the same action
9//! record built and populated, the action tree still walked — with exactly
10//! three value-mutation points inert: the accept flag never goes false, the
11//! change string is never rewritten, and calculate and format return at their
12//! first line.
13//!
14//! So [`NoScripts`] is not "the real thing minus scripts". It is the real
15//! thing with the identity cascade, and the method defaults below *are* that
16//! behaviour, bit for bit. A scripting implementation substitutes a different
17//! value for one parameter and changes no call site.
18//!
19//! # Why four value gates, and a fifth hook that gates nothing
20//!
21//! There are precisely four gates: a keystroke may be rewritten or rejected
22//! as it is typed; a commit may be rejected; other fields may be recalculated
23//! from the committed one; and a display string may be produced that does not
24//! change the stored value. The first two are the same action dictionary
25//! distinguished only by whether a commit is imminent, which is why they are
26//! two methods over one key.
27
28use crate::event::Modifiers;
29
30/// A field, named the way a script would name it.
31///
32/// Carries the identity a script needs to talk about the field, not a
33/// reference to it: nothing here can be used to reach back into the session.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FieldRef {
36    /// The field's fully qualified name.
37    pub name: String,
38    /// Its position in the document's terminal-field list — the flat
39    /// `/AcroForm /Fields` walk.
40    ///
41    /// # Document-wide, and `Option` because not every field is in that list
42    ///
43    /// This is the space `/AcroForm /CO` indexes, the space `Doc.numFields`
44    /// counts and the space `Doc.getNthFieldName(n)` reads — every way a
45    /// script has of naming a field by number. It is **not** the page-local
46    /// `FieldId` a session stores interaction state under; those coincide
47    /// only for a single-page form whose widgets appear in `/Fields` order,
48    /// and a calculation that confused them would write the wrong field on
49    /// any other file.
50    ///
51    /// `None` for a widget the form's field list does not reach — an unnamed
52    /// one, most often. Such a field is real for interaction and invisible to
53    /// a script, which is the oracle's answer too: `GetFieldByDict` returns
54    /// null for it and `CountFields` never counted it.
55    pub index: Option<u32>,
56}
57
58/// A keystroke offered to the keystroke hook.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Keystroke {
61    /// The text being inserted — one character for a typed key, a whole run
62    /// for a paste, empty for a deletion.
63    pub change: String,
64    /// The field's text before the change.
65    pub value: String,
66    /// Where the replaced range starts, as a character index.
67    ///
68    /// Signed, and negative is a real value a script can write: `event.selStart`
69    /// is an `int32`, read back without a clamp. See [`Keystroke::applied`]
70    /// for what an out-of-range index does.
71    pub selection_start: i32,
72    /// Where it ends, as a character index. Signed for the same reason.
73    pub selection_end: i32,
74}
75
76/// What the keystroke hook decided.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum KeystrokeOutcome {
79    /// Take the keystroke, possibly with the change text rewritten.
80    Accept(Keystroke),
81    /// Drop the keystroke. The field is left as it was.
82    Reject,
83}
84
85/// Values a calculation wants written to other fields.
86///
87/// A recursion budget rides along, because a calculation that triggers
88/// another calculation is the shape a script uses to build an infinite loop.
89/// The budget belongs to the implementation rather than to the seam: the
90/// script-free cascade never spends any of it.
91#[derive(Debug, Clone, Default)]
92pub struct FieldWrites {
93    writes: Vec<(u32, String)>,
94    depth: u32,
95    max_depth: u32,
96}
97
98impl FieldWrites {
99    /// A write sink that permits `max_depth` nested calculations.
100    #[must_use]
101    pub fn with_max_depth(max_depth: u32) -> FieldWrites {
102        FieldWrites {
103            writes: Vec::new(),
104            depth: 0,
105            max_depth,
106        }
107    }
108
109    /// Records a new value for a field. Ignored past the recursion budget.
110    pub fn set(&mut self, field: u32, value: impl Into<String>) {
111        if self.depth <= self.max_depth {
112            self.writes.push((field, value.into()));
113        }
114    }
115
116    /// The recorded writes, in the order they were made.
117    pub fn writes(&self) -> impl Iterator<Item = (u32, &str)> {
118        self.writes.iter().map(|(f, v)| (*f, v.as_str()))
119    }
120
121    /// Whether nothing was written.
122    #[must_use]
123    pub fn is_empty(&self) -> bool {
124        self.writes.is_empty()
125    }
126
127    /// How deep the current calculation is nested.
128    #[must_use]
129    pub fn depth(&self) -> u32 {
130        self.depth
131    }
132
133    /// Whether one more level of nesting is permitted.
134    #[must_use]
135    pub fn can_recurse(&self) -> bool {
136        self.depth < self.max_depth
137    }
138
139    /// Enters one level of nesting.
140    ///
141    /// The `bool` is the answer, not a failed mutation: whether the recursion
142    /// budget allowed another level. `false` means the cap is already reached.
143    pub fn enter(&mut self) -> bool {
144        if !self.can_recurse() {
145            return false;
146        }
147        self.depth += 1;
148        true
149    }
150
151    /// Leaves one level of nesting.
152    pub fn leave(&mut self) {
153        self.depth = self.depth.saturating_sub(1);
154    }
155}
156
157/// The script hooks a commit passes through.
158///
159/// One implementation ships here — [`NoScripts`] — and its behaviour is the
160/// method defaults below. See the module documentation for why those are the
161/// specification rather than a placeholder.
162pub trait Cascade {
163    /// The keystroke hook, with no commit imminent: may rewrite or reject
164    /// what is being typed.
165    fn keystroke(&mut self, _field: &FieldRef, change: Keystroke) -> KeystrokeOutcome {
166        KeystrokeOutcome::Accept(change)
167    }
168
169    /// The keystroke hook with a commit imminent: may reject the commit.
170    fn keystroke_commit(&mut self, _field: &FieldRef, _value: &str) -> bool {
171        true
172    }
173
174    /// The validation hook: may reject the commit.
175    fn validate(&mut self, _field: &FieldRef, _value: &str) -> bool {
176        true
177    }
178
179    /// The calculation hook: may rewrite other fields' values.
180    fn calculate(&mut self, _writes: &mut FieldWrites, _trigger: &FieldRef) {}
181
182    /// The format hook: may return a display string that does not change the
183    /// stored value.
184    ///
185    /// `None` means "display the raw value", which is exactly what a build
186    /// without scripts does — and exactly why such a build shows `1234` where
187    /// a formatting script would show a currency amount.
188    fn format(&mut self, _field: &FieldRef, _value: &str) -> Option<String> {
189        None
190    }
191
192    /// A pointer or focus event reached a field.
193    ///
194    /// **This hook changes nothing and cannot refuse anything**, which is why
195    /// it returns `()` where the four above return an answer: the six `/AA`
196    /// entries it covers can talk to the host and read the form, and
197    /// `event.value` is not even live for them. A viewer runs them for their
198    /// side effects and carries on regardless.
199    ///
200    /// It is one method over six triggers rather than six methods because
201    /// nothing downstream branches on which one fired — the branch is inside
202    /// the implementation, on the `event` object it populates.
203    fn pointer(&mut self, _field: &FieldRef, _trigger: PointerTrigger, _held: Modifiers) {}
204
205    /// The field a script asked the keyboard for, drained.
206    ///
207    /// A position in the document-wide field list — the space
208    /// [`FieldRef::index`] counts in — or `None` when nothing asked.
209    ///
210    /// **Recorded while a script runs and spent afterwards.** Moving the
211    /// keyboard from inside a running script would re-enter the routing the
212    /// script is already inside, so the request is written down here and the
213    /// caller routes it through the same path a click takes: the outgoing
214    /// field's `/AA /Bl`, then the incoming field's `/AA /Fo`.
215    ///
216    /// The default is `None` — [`NoScripts`]'s answer and every
217    /// non-scripting cascade's, because with no engine nothing can ask.
218    fn take_focus_request(&mut self) -> Option<u32> {
219        None
220    }
221
222    /// `Field.borderStyle` writes a script made, drained.
223    ///
224    /// Each entry is a `/Fields` position and the style the setter accepted.
225    /// Spent after the script returns so the appearance regenerates through
226    /// the ordinary path rather than from inside a native function.
227    fn drain_border_style_writes(&mut self) -> Vec<(u32, pdfrum_doc::ap::BorderStyle)> {
228        Vec::new()
229    }
230}
231
232/// Which of the six pointer and focus `/AA` entries a [`Cascade::pointer`]
233/// call is for.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum PointerTrigger {
236    /// `/AA /E` — the pointer entered the widget's area.
237    Enter,
238    /// `/AA /X` — it left.
239    Exit,
240    /// `/AA /D` — a button went down over it.
241    Down,
242    /// `/AA /U` — a button came up over it.
243    Up,
244    /// `/AA /Fo` — the widget took the keyboard.
245    Focus,
246    /// `/AA /Bl` — it lost the keyboard.
247    Blur,
248}
249
250impl Keystroke {
251    /// The payload a field offers its keystroke hook, read off a live edit.
252    ///
253    /// The four fields: the text being inserted, the field's value *before*
254    /// the change, and the selection the change replaces — which is a caret's
255    /// position twice over when nothing is selected.
256    #[must_use]
257    pub fn of(edit: &crate::edit::TextEdit, change: impl Into<String>) -> Keystroke {
258        let (start, end) = edit.selection_indices();
259        Keystroke {
260            change: change.into(),
261            value: edit.text.clone(),
262            selection_start: i32::try_from(start).unwrap_or(i32::MAX),
263            selection_end: i32::try_from(end).unwrap_or(i32::MAX),
264        }
265    }
266
267    /// The value this keystroke produces: its selection replaced by its
268    /// change.
269    ///
270    /// A hook that rewrote `change` — or moved the selection — is answered by
271    /// applying what it returned rather than what was offered, which is the
272    /// whole point of handing the payload back.
273    ///
274    /// # An out-of-range index yields nothing, not a clamp
275    ///
276    /// The two halves do not agree with each other, and both are reproduced:
277    ///
278    /// - an out-of-range **`selection_start`** — negative, or past the end —
279    ///   yields an **empty prefix**, so the field's leading text vanishes;
280    /// - an out-of-range **`selection_end`** yields an empty suffix, which is
281    ///   the same answer a clamp to the end would give.
282    ///
283    /// Clamping the prefix instead would keep text the oracle drops, on an
284    /// input any `/AA /K` script can produce in one assignment.
285    #[must_use]
286    pub fn applied(&self) -> String {
287        let chars: Vec<char> = self.value.chars().collect();
288        let len = chars.len();
289        // `First(count)`: in range or nothing. Note `count == len` is in
290        // range and yields the whole string.
291        let prefix: String = usize::try_from(self.selection_start)
292            .ok()
293            .filter(|start| *start <= len)
294            .and_then(|start| chars.get(..start))
295            .unwrap_or_default()
296            .iter()
297            .collect();
298        // `Substr(end)`, behind the caller's own `end >= 0 && end < length`.
299        let suffix: String = usize::try_from(self.selection_end)
300            .ok()
301            .filter(|end| *end < len)
302            .and_then(|end| chars.get(end..))
303            .unwrap_or_default()
304            .iter()
305            .collect();
306        let mut out = prefix;
307        out.push_str(&self.change);
308        out.push_str(&suffix);
309        out
310    }
311}
312
313/// The script-free cascade, and this crate's only implementation.
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
315pub struct NoScripts;
316
317impl Cascade for NoScripts {}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn field() -> FieldRef {
324        FieldRef {
325            name: "Text Box".to_string(),
326            index: Some(0),
327        }
328    }
329
330    fn keystroke(change: &str) -> Keystroke {
331        Keystroke {
332            change: change.to_string(),
333            value: String::new(),
334            selection_start: 0,
335            selection_end: 0,
336        }
337    }
338
339    /// The permissive answers are the contract, not a placeholder: a build
340    /// without scripts accepts every keystroke and every commit.
341    #[test]
342    fn the_script_free_cascade_accepts_everything_unchanged() {
343        let mut cascade = NoScripts;
344        assert_eq!(
345            cascade.keystroke(&field(), keystroke("A")),
346            KeystrokeOutcome::Accept(keystroke("A"))
347        );
348        assert!(cascade.keystroke_commit(&field(), "anything"));
349        assert!(cascade.validate(&field(), "anything"));
350    }
351
352    /// No cross-field recalculation happens without scripts.
353    #[test]
354    fn the_script_free_cascade_writes_no_other_field() {
355        let mut cascade = NoScripts;
356        let mut writes = FieldWrites::with_max_depth(4);
357        cascade.calculate(&mut writes, &field());
358        assert!(writes.is_empty());
359    }
360
361    /// A field displays its raw value: this is the visible gap a formatting
362    /// script would close.
363    #[test]
364    fn the_script_free_cascade_formats_nothing() {
365        let mut cascade = NoScripts;
366        assert_eq!(cascade.format(&field(), "1234"), None);
367    }
368
369    #[test]
370    fn writes_are_recorded_in_order() {
371        let mut writes = FieldWrites::with_max_depth(4);
372        writes.set(2, "two");
373        writes.set(1, "one");
374        let seen: Vec<_> = writes.writes().collect();
375        assert_eq!(seen, vec![(2, "two"), (1, "one")]);
376    }
377
378    /// The budget is what stops a calculation that triggers a calculation.
379    #[test]
380    fn nesting_stops_at_the_budget() {
381        let mut writes = FieldWrites::with_max_depth(2);
382        assert!(writes.enter());
383        assert!(writes.enter());
384        assert!(!writes.enter(), "budget should be exhausted");
385        assert_eq!(writes.depth(), 2);
386
387        writes.leave();
388        assert!(writes.can_recurse());
389    }
390
391    /// The trait is object safe: the commit path holds exactly one of these
392    /// behind a reference, which is the whole reason it is a trait.
393    #[test]
394    fn the_seam_is_object_safe() {
395        let mut owned = NoScripts;
396        let cascade: &mut dyn Cascade = &mut owned;
397        assert!(cascade.validate(&field(), "x"));
398    }
399}