Skip to main content

pdfrum_form/
commit.rs

1//! The commit cascade: turning an edited field back into a stored value.
2//!
3//! Six steps in a fixed order, and the order is normative:
4//!
5//! ```text
6//! is_changed → keystroke_commit → validate → save → calculate → format
7//! ```
8//!
9//! Without scripts every hook takes its permissive answer, so a commit
10//! reduces to "the value changed, store it" — but the shape is the full one
11//! rather than a reduced one, which is what lets a scripting implementation
12//! drop in without a redesign.
13//!
14//! # A rejected commit keeps focus
15//!
16//! ISO 32000-1 §12.7.5.3 gives the Validate event the job of rejecting the
17//! *value*, not of ending the interaction: **a refused commit reverts the edit
18//! and keeps the field**, so the user can fix what the script objected to.
19//!
20//! [`CommitOutcome`] says three things separately, because "the commit
21//! finished", "the value was stored" and "focus should move on" are three
22//! different questions: `committed`, `reverted` and
23//! [`CommitOutcome::keeps_focus`]. Without scripts the rejection branch is
24//! unreachable — nothing can refuse.
25
26use crate::cascade::{Cascade, FieldRef, FieldWrites};
27
28/// What a commit did.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CommitOutcome {
31    /// Whether the cascade ran to the end. **True even when a hook refused**,
32    /// which is what makes focus proceed either way.
33    pub committed: bool,
34    /// Whether a hook refused and the field was put back to its stored value.
35    pub reverted: bool,
36    /// The value now stored, when one was stored.
37    pub stored: Option<String>,
38    /// A display string a formatting hook produced, which does not change the
39    /// stored value.
40    ///
41    /// Meaningful only when [`committed`](CommitOutcome::committed) and not
42    /// [`reverted`](CommitOutcome::reverted). A commit that ran no hooks
43    /// because the value had not moved says nothing about what the field
44    /// shows, and a reader must not take its `None` for "show the raw value":
45    /// the formatting hook runs on a *change*. [`CommitOutcome::formats`] is
46    /// that question asked directly.
47    pub display: Option<String>,
48    /// Values a calculation asked to be written to other fields.
49    pub writes: Vec<(u32, String)>,
50}
51
52impl CommitOutcome {
53    /// The answer for a field whose value had not changed: nothing ran.
54    #[must_use]
55    pub fn unchanged() -> CommitOutcome {
56        CommitOutcome {
57            committed: true,
58            reverted: false,
59            stored: None,
60            display: None,
61            writes: Vec::new(),
62        }
63    }
64
65    /// Whether the field that just committed should **keep** the keyboard.
66    ///
67    /// True exactly when a hook refused; see the module documentation.
68    #[must_use]
69    pub fn keeps_focus(&self) -> bool {
70        self.reverted
71    }
72
73    /// Whether this outcome **decides** what the field displays.
74    ///
75    /// The distinction [`display`](CommitOutcome::display) alone cannot make:
76    /// a `None` from a commit that ran means "no formatter, draw the raw
77    /// value" and must erase any earlier answer, while a `None` from a commit
78    /// that never ran — because the value had not moved, or because a gate
79    /// refused — means nothing at all and must leave the field showing what
80    /// it was showing. Only the first regenerates the appearance.
81    #[must_use]
82    pub fn formats(&self) -> bool {
83        self.committed && !self.reverted && self.stored.is_some()
84    }
85}
86
87/// Runs the cascade for one field.
88///
89/// `stored` is what the document currently holds and `edited` is what the
90/// user has; when they agree nothing runs at all, which is the first gate and
91/// the reason an unchanged field costs nothing to blur through.
92pub fn run(
93    field: &FieldRef,
94    stored: &str,
95    edited: &str,
96    cascade: &mut dyn Cascade,
97    max_calculate_depth: u32,
98) -> CommitOutcome {
99    if stored == edited {
100        return CommitOutcome::unchanged();
101    }
102
103    // A refusal at either gate reverts the field and reports the commit as
104    // finished — but, unlike the oracle, it does not let focus move on.
105    //
106    // [oracle-bug] `CFFL_FormField::CommitData` returns `true` from both
107    // refusal paths (`fpdfsdk/formfiller/cffl_formfield.cpp:525-531`), and
108    // that return value is `KillFocusForAnnot`'s only guard (`:306`), so a
109    // rejected value loses the caret exactly as an accepted one does.
110    // ISO 32000-1 §12.7.5.3 makes Validate reject the *value*; pdf.js keeps
111    // the field with the comment to prove it — `focus: true, // Stay in the
112    // field.`, `src/scripting_api/event.js:277`. `keeps_focus` is that fix.
113    if !cascade.keystroke_commit(field, edited) || !cascade.validate(field, edited) {
114        return CommitOutcome {
115            committed: true,
116            reverted: true,
117            stored: None,
118            display: None,
119            writes: Vec::new(),
120        };
121    }
122
123    let stored_value = edited.to_string();
124
125    let mut writes = FieldWrites::with_max_depth(max_calculate_depth);
126    cascade.calculate(&mut writes, field);
127    let writes: Vec<(u32, String)> = writes.writes().map(|(f, v)| (f, v.to_string())).collect();
128
129    // Format runs last and changes only what is shown, never what is stored.
130    let display = cascade.format(field, &stored_value);
131
132    CommitOutcome {
133        committed: true,
134        reverted: false,
135        stored: Some(stored_value),
136        display,
137        writes,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::cascade::{Keystroke, KeystrokeOutcome, NoScripts};
145
146    fn field() -> FieldRef {
147        FieldRef {
148            name: "Text Box".to_string(),
149            index: Some(0),
150        }
151    }
152
153    /// Nothing runs when the value has not moved.
154    #[test]
155    fn an_unchanged_field_runs_no_hook() {
156        struct Counting {
157            calls: u32,
158        }
159        impl Cascade for Counting {
160            fn validate(&mut self, _f: &FieldRef, _v: &str) -> bool {
161                self.calls += 1;
162                true
163            }
164        }
165
166        let mut cascade = Counting { calls: 0 };
167        let outcome = run(&field(), "same", "same", &mut cascade, 8);
168
169        assert_eq!(cascade.calls, 0);
170        assert!(outcome.committed);
171        assert!(!outcome.reverted);
172        assert_eq!(outcome.stored, None);
173    }
174
175    /// Without scripts the value is simply stored.
176    #[test]
177    fn a_changed_field_stores_its_new_value() {
178        let outcome = run(&field(), "old", "new", &mut NoScripts, 8);
179        assert!(outcome.committed);
180        assert!(!outcome.reverted);
181        assert_eq!(outcome.stored.as_deref(), Some("new"));
182        assert_eq!(outcome.display, None, "no formatting without scripts");
183        assert!(outcome.writes.is_empty(), "no calculation without scripts");
184    }
185
186    /// A refusal reports the commit as finished — and **keeps the field**,
187    /// which is where we diverge from the oracle on purpose; see the
188    /// `[oracle-bug]` note on `run`.
189    #[test]
190    fn a_refused_commit_reports_success_and_keeps_the_field() {
191        struct Refusing;
192        impl Cascade for Refusing {
193            fn validate(&mut self, _f: &FieldRef, _v: &str) -> bool {
194                false
195            }
196        }
197
198        let outcome = run(&field(), "old", "new", &mut Refusing, 8);
199        assert!(
200            outcome.committed,
201            "a refusal must not report the commit as failed"
202        );
203        assert!(outcome.reverted, "…but it must say the value was put back");
204        assert_eq!(outcome.stored, None, "and nothing was stored");
205        assert!(
206            outcome.keeps_focus(),
207            "[oracle-bug] the user must be able to correct what was rejected"
208        );
209    }
210
211    /// Either gate refusing has the same shape, focus included.
212    #[test]
213    fn the_keystroke_gate_refuses_the_same_way() {
214        struct Refusing;
215        impl Cascade for Refusing {
216            fn keystroke_commit(&mut self, _f: &FieldRef, _v: &str) -> bool {
217                false
218            }
219        }
220
221        let outcome = run(&field(), "old", "new", &mut Refusing, 8);
222        assert!(outcome.committed);
223        assert!(outcome.reverted);
224        assert_eq!(outcome.stored, None);
225        assert!(outcome.keeps_focus());
226    }
227
228    /// An accepted commit lets focus go, which is the ordinary path and the
229    /// one that must not change.
230    #[test]
231    fn an_accepted_commit_lets_focus_move_on() {
232        assert!(!run(&field(), "old", "new", &mut NoScripts, 8).keeps_focus());
233        assert!(!CommitOutcome::unchanged().keeps_focus());
234    }
235
236    /// The order is normative, so it is asserted rather than assumed.
237    #[test]
238    fn the_hooks_run_in_the_specified_order() {
239        #[derive(Default)]
240        struct Recording {
241            seen: Vec<&'static str>,
242        }
243        impl Cascade for Recording {
244            fn keystroke(&mut self, _f: &FieldRef, c: Keystroke) -> KeystrokeOutcome {
245                self.seen.push("keystroke");
246                KeystrokeOutcome::Accept(c)
247            }
248            fn keystroke_commit(&mut self, _f: &FieldRef, _v: &str) -> bool {
249                self.seen.push("keystroke_commit");
250                true
251            }
252            fn validate(&mut self, _f: &FieldRef, _v: &str) -> bool {
253                self.seen.push("validate");
254                true
255            }
256            fn calculate(&mut self, _w: &mut FieldWrites, _t: &FieldRef) {
257                self.seen.push("calculate");
258            }
259            fn format(&mut self, _f: &FieldRef, _v: &str) -> Option<String> {
260                self.seen.push("format");
261                None
262            }
263        }
264
265        let mut cascade = Recording::default();
266        run(&field(), "old", "new", &mut cascade, 8);
267
268        assert_eq!(
269            cascade.seen,
270            vec!["keystroke_commit", "validate", "calculate", "format"]
271        );
272    }
273
274    /// A refusal stops the cascade there: nothing after the refusing gate
275    /// runs.
276    #[test]
277    fn a_refusal_stops_the_cascade() {
278        #[derive(Default)]
279        struct Recording {
280            seen: Vec<&'static str>,
281        }
282        impl Cascade for Recording {
283            fn validate(&mut self, _f: &FieldRef, _v: &str) -> bool {
284                self.seen.push("validate");
285                false
286            }
287            fn calculate(&mut self, _w: &mut FieldWrites, _t: &FieldRef) {
288                self.seen.push("calculate");
289            }
290            fn format(&mut self, _f: &FieldRef, _v: &str) -> Option<String> {
291                self.seen.push("format");
292                None
293            }
294        }
295
296        let mut cascade = Recording::default();
297        run(&field(), "old", "new", &mut cascade, 8);
298        assert_eq!(cascade.seen, vec!["validate"]);
299    }
300
301    /// Formatting changes what is shown and not what is stored — the whole
302    /// point of a separate display string.
303    #[test]
304    fn a_display_string_does_not_change_the_stored_value() {
305        struct Formatting;
306        impl Cascade for Formatting {
307            fn format(&mut self, _f: &FieldRef, value: &str) -> Option<String> {
308                Some(format!("${value}.00"))
309            }
310        }
311
312        let outcome = run(&field(), "0", "1234", &mut Formatting, 8);
313        assert_eq!(outcome.stored.as_deref(), Some("1234"));
314        assert_eq!(outcome.display.as_deref(), Some("$1234.00"));
315    }
316
317    #[test]
318    fn a_calculation_reaches_the_outcome() {
319        struct Calculating;
320        impl Cascade for Calculating {
321            fn calculate(&mut self, writes: &mut FieldWrites, _t: &FieldRef) {
322                writes.set(7, "total");
323            }
324        }
325
326        let outcome = run(&field(), "old", "new", &mut Calculating, 8);
327        assert_eq!(outcome.writes, vec![(7, "total".to_string())]);
328    }
329
330    /// The cascade takes its hooks behind a reference, which is the one place
331    /// in the crate that needs the seam to be a trait at all.
332    #[test]
333    fn the_cascade_is_taken_by_reference() {
334        let mut owned = NoScripts;
335        let cascade: &mut dyn Cascade = &mut owned;
336        let outcome = run(&field(), "a", "b", cascade, 8);
337        assert_eq!(outcome.stored.as_deref(), Some("b"));
338    }
339}