Skip to main content

pdfrum_form/script/
mod.rs

1//! A `boa`-backed [`Cascade`](crate::Cascade): a document's own scripts, run.
2//!
3//! Behind the default-off `javascript` feature; with it off, `boa_engine` is not
4//! in the dependency tree at all.
5//!
6//! **A script reaches no I/O.** `Doc.submitForm`, `Doc.print`, `app.launchURL`
7//! and the rest are transcript lines a host reads back through
8//! [`ScriptCascade::transcript`], so a URL or a form's bytes come back rather
9//! than going out; nothing opens a socket, a file or a process.
10//!
11//! **A script that exhausts a sandbox limit refuses rather than accepting.**
12//! [`Limits`] bounds loops, recursion and stack depth; not heap or regex
13//! backtracking.
14//!
15//! [`Limits`]: pdfrum_common::Limits
16
17mod af;
18mod bind;
19mod color;
20mod consts;
21mod doc;
22pub(crate) mod event;
23mod field;
24mod global;
25mod host;
26pub mod model;
27mod submit;
28mod timer;
29pub mod transcript;
30pub mod zone;
31
32use std::rc::Rc;
33
34use boa_engine::context::Context;
35use pdfrum_common::{Deadline, Diagnostics, Limits};
36
37use crate::cascade::{Cascade, FieldRef, FieldWrites, Keystroke, KeystrokeOutcome};
38use event::EventState;
39
40pub use model::{AnnotModel, DocumentModel, FieldModel, FieldModelFlags, FieldModelKind};
41pub use transcript::TranscriptLine;
42
43/// The instant the **goldens were recorded at**, in seconds since the epoch:
44/// what a conformance run must freeze its clock to.
45///
46/// Not a default and not something this crate applies on its own — a *constant
47/// a golden run passes in*, through [`ScriptConfig::frozen_at`]. The frozen
48/// clock leaks into the expected bytes (`public_methods_expected.txt` pins
49/// `AFParseDateEx(1, 2) = 1399672130000`), so a conformance run must set it
50/// and an ordinary embedder must not.
51///
52/// Exported for tests and for a harness that wants to name the seed; the
53/// conformance tool reads its own `--time=` rather than reaching for this.
54pub const GOLDEN_CLOCK_SECS: u64 = 1_399_672_130;
55
56/// The timezone the **engine's `Date`** saw when the goldens were recorded:
57/// `TZ=America/Los_Angeles`, as V8 resolves it — which is `GMT-0700` for the
58/// fixtures' July dates and `GMT-0800` for their December ones.
59///
60/// **A rule, not a number**, and that is the whole point: V8 is the one part
61/// of the oracle's date plumbing `pdfium_test` does *not* hook, so it reads
62/// the real zone database per instant. A flat `GMT-0700` here is an hour
63/// wrong on every winter date, and one of those winter dates
64/// (`new Date(2525, 11, 31)`) is midnight, so the hour prints as a day —
65/// `12/30/2525` for an expected `12/31/2525`. See [`zone`].
66///
67/// This is `Date`'s zone only. `util.printd` uses a *different* offset — see
68/// [`GOLDEN_PRINTD_OFFSET_SECS`], and read that doc before assuming the two
69/// should agree.
70pub const GOLDEN_TIMEZONE: zone::Zone = zone::Zone::LOS_ANGELES;
71
72/// The offset **`util.printd` applies** when the goldens were recorded, which
73/// is not the one `Date` uses: a flat `GMT-0800`, with no daylight saving,
74/// whatever the date.
75///
76/// # Why the two differ, which is not a bug in either
77///
78/// The golden harness replaces `localtime` with `gmtime`
79/// (`testing/pdfium_test/pdfium_test.cc:2134`), so `GetDaylightSavingTA`'s
80/// `tm_isdst` is always zero (`fxjs/fx_date_helpers.cpp:54-67`) while
81/// `GetLocalTZA`'s standard-offset term still reads the zone's own — PST, −8
82/// hours. The engine's `Date` never goes through those hooks and keeps the
83/// real rule, [`GOLDEN_TIMEZONE`]. The two are one hour apart all summer and
84/// **agree all winter**, by construction — and getting the winter half wrong
85/// is exactly the regression [`zone`] documents.
86pub const GOLDEN_PRINTD_OFFSET_SECS: i32 = -8 * 3600;
87
88/// The file path **the goldens were recorded with**, which is the test
89/// harness's own and not any real file's.
90///
91/// It leaks into the expected bytes twice, as `this.URL` and as `this.path`
92/// — the second with a leading separator.
93///
94/// A constant a golden run passes in, exactly as [`GOLDEN_CLOCK_SECS`] is —
95/// an embedder passes the path it actually opened.
96pub const GOLDEN_FILE_PATH: &str = "myfile.pdf";
97
98/// What a scripting session is allowed to do, and what it sees.
99#[derive(Debug, Clone, Default)]
100pub struct ScriptConfig {
101    /// The bounds. Exhausting one is a diagnostic and a refusal.
102    pub limits: Limits,
103    /// The clock scripts see, in milliseconds since the epoch.
104    ///
105    /// `None` reads the **host clock**, which is the ordinary case and is the
106    /// oracle's too. `Some` freezes it, which is what a golden run needs — see
107    /// [`ScriptConfig::frozen_at`].
108    pub clock_ms: Option<i64>,
109    /// The local timezone scripts see through `Date`.
110    ///
111    /// Configuration rather than something read from the machine, because it
112    /// is in the expected bytes: every `util.printd` golden line is shifted to
113    /// PDFium's Los Angeles zone, and a golden run in another zone must
114    /// still produce them.
115    ///
116    /// A [`zone::Zone`] rather than an offset because the answer depends on
117    /// the instant — [`Default`] gives UTC, which has no daylight term.
118    pub timezone: zone::Zone,
119    /// The offset `util.printd` applies before reading a date's components —
120    /// `FX_LocalTime`'s, which is **not** the one `Date` uses. See
121    /// [`GOLDEN_PRINTD_OFFSET_SECS`] for why they differ.
122    pub printd_offset_secs: i32,
123}
124
125impl ScriptConfig {
126    /// The configuration for a run whose clock is frozen at `seconds` since
127    /// the epoch.
128    ///
129    /// **The seed is the caller's**: a conformance run passes
130    /// [`GOLDEN_CLOCK_SECS`], an embedder its own instant.
131    ///
132    /// The zone and printd's offset come with it rather than being separately
133    /// configurable, because the clock and the zone are frozen together —
134    /// freezing the instant without freezing the zone reproduces neither.
135    ///
136    /// A `seconds` past `i64` milliseconds saturates rather than wrapping.
137    #[must_use]
138    pub fn frozen_at(seconds: u64) -> ScriptConfig {
139        let millis = i64::try_from(seconds)
140            .unwrap_or(i64::MAX)
141            .saturating_mul(1000);
142        ScriptConfig {
143            limits: Limits::default(),
144            clock_ms: Some(millis),
145            timezone: GOLDEN_TIMEZONE,
146            printd_offset_secs: GOLDEN_PRINTD_OFFSET_SECS,
147        }
148    }
149
150    /// The configuration for a run with **no `--time=`**: the real wall
151    /// clock, and the host's own zone offsets left at whatever
152    /// [`Default`] gives them.
153    ///
154    /// This is the ordinary embedder's configuration and the one a tool
155    /// invoked without the flag builds.
156    #[must_use]
157    pub fn wall_clock() -> ScriptConfig {
158        ScriptConfig {
159            clock_ms: None,
160            ..ScriptConfig::default()
161        }
162    }
163}
164
165/// Why a script did not finish.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ScriptStop {
168    /// It exhausted one of the [`Limits`] — a loop, recursion or stack bound.
169    /// The refusing answer follows.
170    LimitReached,
171    /// It threw, or would not parse. The message is the engine's.
172    Threw(String),
173}
174
175/// One script that stopped, named and explained.
176///
177/// **An uncaught exception is never swallowed.** The error is reported on the
178/// diagnostic channel and the **next script still runs**; the transcript is
179/// unaffected, because a throwing script prints nothing to it. `[oracle-bug]`:
180/// the oracle drops the error entirely, so a fixture that crashes on its first
181/// line reads as an empty transcript and scores as a pass.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct ScriptFailure {
184    /// Which script — a field's fully-qualified name, a `/Names /JavaScript`
185    /// key, or the empty string for `/OpenAction`.
186    pub whence: String,
187    /// Why it stopped.
188    pub stop: ScriptStop,
189}
190
191impl ScriptFailure {
192    /// The failure as **one** diagnostic line: where, and what the engine
193    /// said.
194    ///
195    /// The position rides **in** the message — a throw reads
196    /// `TypeError: not a callable function (unknown at :1:25)` — so there is
197    /// no separate line/column pair.
198    ///
199    /// Only the **first** line of it:
200    ///
201    /// `boa` appends a stack — `\n    at <main> (…)` — after the message.
202    /// A diagnostic is a line, and a caller writing one per failure must not
203    /// have a multi-line one break its format. [`ScriptStop::Threw`] keeps the
204    /// whole string for a caller that wants it.
205    #[must_use]
206    pub fn line(&self) -> String {
207        let whence = if self.whence.is_empty() {
208            "/OpenAction"
209        } else {
210            &self.whence
211        };
212        match &self.stop {
213            ScriptStop::LimitReached => {
214                format!("script {whence}: stopped by a sandbox limit")
215            }
216            ScriptStop::Threw(message) => {
217                let first = message.lines().next().unwrap_or("").trim_end();
218                format!("script {whence}: {first}")
219            }
220        }
221    }
222}
223
224/// The ten `/AA` entries a field can carry, as their JavaScript source.
225///
226/// `None` is the ordinary case and is not an absence to be filled in later:
227/// nearly every field in nearly every document has no script at all, and a
228/// hook with no script takes the permissive answer — which is `NoScripts`'s
229/// answer, and is correct rather than a fallback.
230///
231/// # Four value hooks and six event ones
232///
233/// The first four intervene in a *value*: they can rewrite what is typed,
234/// refuse a commit, compute another field or produce a display string. The
235/// six below them intervene in nothing — a pointer or focus script can talk
236/// to the host and read the form, and `event.value` throws for it — which is
237/// why they are fired rather than consulted.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct FieldActions {
240    /// `/AA /K` — the keystroke hook, run per character and again on commit.
241    pub keystroke: Option<String>,
242    /// `/AA /V` — the validation hook.
243    pub validate: Option<String>,
244    /// `/AA /C` — the calculation hook.
245    pub calculate: Option<String>,
246    /// `/AA /F` — the format hook.
247    pub format: Option<String>,
248    /// `/AA /E` — the pointer entered the widget.
249    pub mouse_enter: Option<String>,
250    /// `/AA /X` — the pointer left it.
251    pub mouse_exit: Option<String>,
252    /// `/AA /D` — a button went down over it.
253    pub mouse_down: Option<String>,
254    /// `/AA /U` — a button came up over it.
255    pub mouse_up: Option<String>,
256    /// `/AA /Fo` — the widget took the keyboard.
257    pub focus: Option<String>,
258    /// `/AA /Bl` — it lost the keyboard.
259    pub blur: Option<String>,
260}
261
262/// A `boa`-backed [`Cascade`].
263///
264/// Built over one realm, which the whole session shares — because a document's
265/// scripts share a `global` and expect to, and because building a realm per
266/// event would lose it.
267pub struct ScriptCascade {
268    context: Context,
269    host: host::Host,
270    /// Each field's `/AA` scripts, by the index [`FieldRef`] carries.
271    ///
272    /// **Installed by the caller rather than read here**, because reading
273    /// `/AA` needs a document and this type deliberately holds none — which
274    /// is what lets the whole engine be tested against a script string and no
275    /// PDF. `pdfrum-doc`'s `nav::additional_action` is what produces them.
276    actions: std::collections::BTreeMap<u32, FieldActions>,
277    /// Each field's fully-qualified name, for `event.targetName`.
278    names: std::collections::BTreeMap<u32, String>,
279    /// Each field's current value, as the calculation sweep sees it.
280    ///
281    /// A snapshot the sweep updates as its own writes land, so a later field
282    /// in the `/CO` order reads what an earlier one computed — which is what
283    /// `NotificationOption::kNotify` achieves upstream by re-entering the
284    /// notifier chain.
285    values: std::collections::BTreeMap<u32, String>,
286    /// The `/CO` order, which is the whole of what a calculation sweep
287    /// visits. **Empty means no calculation runs at all**, however many
288    /// fields carry `/AA /C` — see `Form::calculation_order`.
289    order: Vec<u32>,
290    /// What a script last did wrong, for the caller's diagnostics.
291    stops: Vec<ScriptFailure>,
292    /// How deep a calculation may nest. One, upstream.
293    max_calculate_depth: u32,
294    /// Whether a script is running: one running inside another is refused
295    /// rather than re-entered.
296    busy: bool,
297    /// `Limits::deadline`, read before each run: a script that starts past
298    /// it is refused as one that exhausted its loop budget. `boa` has no
299    /// wall-clock hook, so the budget is the bound *inside* a run and the
300    /// deadline the bound *between* runs.
301    deadline: Option<Deadline>,
302}
303
304impl std::fmt::Debug for ScriptCascade {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        // `boa_engine::Context` is not `Debug`, and printing a realm would be
307        // useless anyway. What a reader wants is what the session has done.
308        f.debug_struct("ScriptCascade")
309            .field("transcript", &self.transcript().len())
310            .field("stops", &self.stops)
311            .field("busy", &self.busy)
312            .finish_non_exhaustive()
313    }
314}
315
316/// Building a realm failed, which only a broken engine build can cause.
317#[derive(Debug, thiserror::Error)]
318#[error("the script engine could not build a realm: {message}")]
319pub struct BuildError {
320    message: String,
321}
322
323impl ScriptCascade {
324    /// Builds a session with its own realm.
325    ///
326    /// # Errors
327    ///
328    /// Only if `boa` cannot build a context at all, which no input can cause.
329    pub fn new(config: &ScriptConfig) -> Result<ScriptCascade, BuildError> {
330        let mut builder = Context::builder();
331        // The instant and the zone are frozen **together or not at all**,
332        // which is upstream's single guard: `FSDK_SetTimeFunction` and
333        // `FSDK_SetLocaltimeFunction` are installed inside one
334        // `if (options.time > -1)` (`pdfium_test.cc:2129-2135`). Absent
335        // `--time=`, `Date` reads the machine's real clock through boa's
336        // default hooks and its real zone through boa's default
337        // `local_timezone_offset_seconds` — the answer an embedder wants,
338        // and the one a golden run must never see.
339        if let Some(millis) = config.clock_ms {
340            let millis = u64::try_from(millis).unwrap_or(0);
341            builder = builder
342                .host_hooks(Rc::new(host::ConfiguredZone {
343                    zone: config.timezone,
344                }))
345                .clock(Rc::new(boa_engine::context::time::FixedClock::from_millis(
346                    millis,
347                )));
348        }
349        let mut context = builder.build().map_err(|error| BuildError {
350            message: error.to_string(),
351        })?;
352
353        let mut runtime_limits = context.runtime_limits();
354        runtime_limits.set_loop_iteration_limit(config.limits.max_script_loop_iterations);
355        runtime_limits.set_recursion_limit(config.limits.max_script_recursion);
356        runtime_limits.set_stack_size_limit(config.limits.max_script_stack);
357        context.set_runtime_limits(runtime_limits);
358
359        context.insert_data(bind::PrintdOffset(config.printd_offset_secs));
360        let host = bind::new_host();
361        bind::install(&mut context, Rc::clone(&host)).map_err(|error| BuildError {
362            message: error.to_string(),
363        })?;
364
365        Ok(ScriptCascade {
366            context,
367            host,
368            actions: std::collections::BTreeMap::new(),
369            names: std::collections::BTreeMap::new(),
370            values: std::collections::BTreeMap::new(),
371            order: Vec::new(),
372            stops: Vec::new(),
373            max_calculate_depth: config.limits.max_calculate_depth,
374            busy: false,
375            deadline: config.limits.deadline.clone(),
376        })
377    }
378
379    /// Everything a script asked the host to do, in order.
380    ///
381    /// The value the goldens are scored against. See
382    /// [`transcript::render`] for the bytes.
383    #[must_use]
384    pub fn transcript(&self) -> Vec<TranscriptLine> {
385        self.host.borrow().transcript.clone()
386    }
387
388    /// The timers this session currently has armed, as
389    /// `(script, interval_ms)`.
390    ///
391    /// A *value the host reads* rather than a process-wide registry — and a
392    /// **live** one: a timer a script has cancelled, and a one-shot that has
393    /// already fired, are gone from it.
394    #[must_use]
395    pub fn timers(&self) -> Vec<(String, i32)> {
396        self.host.borrow().timers.listed()
397    }
398
399    /// Tells the session that `elapsed` passed, and runs whatever came due.
400    ///
401    /// **This library never reads a clock.** A host with an event loop calls
402    /// this from its own timer; a test calls it with a number. Nothing here
403    /// starts a thread, and a session nobody advances fires nothing however
404    /// long it lives — which is what makes a document's `app.setInterval`
405    /// inert in a renderer that only draws pages.
406    ///
407    /// Answers how many timer scripts ran. Two rules a caller is likely to be
408    /// surprised by: **a timer fires at most once per call**, however large
409    /// the increment — so advancing five seconds in one step fires a
410    /// one-second interval once, not five times — and a timer is **re-armed
411    /// before** its script runs, so a script cancelling its own timer cancels
412    /// the next firing rather than this one.
413    ///
414    /// A script a timer runs is an ordinary script: it may throw, and its
415    /// failure is recorded on [`stops`](Self::stops) like any other.
416    pub fn advance_time(&mut self, elapsed: std::time::Duration) -> usize {
417        let millis = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
418        let due = self.host.borrow_mut().timers.advance(millis);
419        let mut ran = 0;
420        for (id, script) in due {
421            self.host.borrow_mut().timers.begin(id);
422            // `CJS_App::RunJsScript` runs the source under a fresh
423            // `OnExternal_Exec` event — kind `Exec`, whose `type` is
424            // `External` and whose `value` is not live, so a timer script
425            // reading `event.value` gets the same refusal a mouse script
426            // does.
427            self.host.borrow_mut().event = EventState::initialize(event::EventKind::Unknown);
428            self.run(&script, "app.setTimeOut");
429            self.host.borrow_mut().timers.end(id);
430            ran += 1;
431        }
432        ran
433    }
434
435    /// Makes the **next** timer a script arms fail to arm.
436    ///
437    /// A host that cannot give out another timer, which upstream models with
438    /// `SetFailNextTimer` — the script still gets a timer object back, its id
439    /// is the invalid `0`, and nothing ever fires. Exported because it is the
440    /// only way to reach the branch a crash regression pins.
441    pub fn fail_next_timer(&mut self) {
442        self.host.borrow_mut().timers.fail_next();
443    }
444
445    /// Records a **named viewer action** — `/S /Named` — on the transcript.
446    ///
447    /// An action the *document* asks for that runs no JavaScript at all:
448    /// `Print`, `NextPage`, `SaveAs` and the rest. It reaches the host
449    /// through `CPDFSDK_FormFillEnvironment::ExecuteNamedAction`, exactly as
450    /// an alert does — nothing is performed here either, and a host reads the
451    /// request back off [`transcript`](Self::transcript) and decides.
452    ///
453    /// Recorded by the caller rather than found here, for the same reason
454    /// every `/AA` script is: reading an action dictionary needs a document,
455    /// and this type holds none.
456    pub fn record_named_action(&mut self, name: impl Into<String>) {
457        self.host
458            .borrow_mut()
459            .transcript
460            .push(TranscriptLine::NamedAction(name.into()));
461    }
462
463    /// The transcript, rendered the way the oracle writes it to stdout.
464    #[must_use]
465    pub fn transcript_text(&self) -> String {
466        transcript::render(&self.transcript())
467    }
468
469    /// What went wrong, and in which script — the diagnostics a caller reads
470    /// after a run.
471    #[must_use]
472    pub fn stops(&self) -> &[ScriptFailure] {
473        &self.stops
474    }
475
476    /// Runs one script, recording anything it asked for and anything that
477    /// stopped it.
478    ///
479    /// The `bool` is the answer, not a failed mutation: `true` means the
480    /// script completed, `false` means a reason was recorded on
481    /// [`stops`](Self::stops). **Never panics and never propagates an engine
482    /// error to the caller.** A script is untrusted input: a parse failure, a
483    /// thrown exception and an exhausted limit are the three ordinary
484    /// outcomes, and all three answer `false` here with a recorded reason.
485    ///
486    /// `whence` names the script for the diagnostic — a field name, or
487    /// `"/OpenAction"`.
488    pub fn run(&mut self, source: &str, whence: &str) -> bool {
489        if self.busy {
490            // `CJS_EventContext::busy_` (`fxjs/cjs_event_context.cpp:32-38`):
491            // a script provoked by a script is refused, not re-entered.
492            self.stops.push(ScriptFailure {
493                whence: whence.to_string(),
494                stop: ScriptStop::Threw("System is busy.".to_string()),
495            });
496            return false;
497        }
498        if self.deadline.as_ref().is_some_and(Deadline::passed) {
499            self.stops.push(ScriptFailure {
500                whence: whence.to_string(),
501                stop: ScriptStop::LimitReached,
502            });
503            return false;
504        }
505        self.busy = true;
506        let result = self
507            .context
508            .eval(boa_engine::Source::from_bytes(source.as_bytes()));
509        self.busy = false;
510
511        match result {
512            Ok(_) => true,
513            Err(error) => {
514                let message = error.to_string();
515                // boa reports an exhausted `RuntimeLimits` as a
516                // `RuntimeLimitError`, which is the one failure that is about
517                // the *sandbox* rather than about the script's own logic.
518                let stop = if message.contains("RuntimeLimit") {
519                    ScriptStop::LimitReached
520                } else {
521                    ScriptStop::Threw(message)
522                };
523                self.stops.push(ScriptFailure {
524                    whence: whence.to_string(),
525                    stop,
526                });
527                false
528            }
529        }
530    }
531
532    /// Whether the last thing that stopped a script was a limit rather than
533    /// the script's own logic.
534    ///
535    /// The distinction a caller cares about: a script that *threw* said
536    /// something about the document, and one that ran out of budget said
537    /// nothing at all.
538    #[must_use]
539    pub fn last_stop_was_a_limit(&self) -> bool {
540        matches!(
541            self.stops.last().map(|failure| &failure.stop),
542            Some(ScriptStop::LimitReached)
543        )
544    }
545
546    /// Installs one trigger's `event` state and runs the script.
547    ///
548    /// # Every trigger resets every field first
549    ///
550    /// [`EventState::initialize`] is `CJS_EventContext::Initialize`: the
551    /// whole record goes back to its reset values before the trigger's own
552    /// fields are written, so a Validate script never sees the selection a
553    /// preceding Keystroke left.
554    ///
555    /// **Which fields are read back afterwards is where the kinds differ**,
556    /// and that lives in the [`Cascade`] methods rather than here: a write to
557    /// a field that is dead for the kind lands in the record and is dropped.
558    fn run_event(&mut self, source: &str, live: EventState, whence: &str) -> bool {
559        self.host.borrow_mut().event = live;
560        self.run(source, whence)
561    }
562
563    /// The script one trigger runs for one field, if it has one.
564    fn script_for(&self, field: &FieldRef, trigger: Trigger) -> Option<String> {
565        // A field the form's own list does not reach has no installed scripts
566        // to find, which is `NoScripts`'s answer and the oracle's.
567        let actions = self.actions.get(&field.index?)?;
568        match trigger {
569            Trigger::Keystroke => actions.keystroke.clone(),
570            Trigger::Validate => actions.validate.clone(),
571            Trigger::Format => actions.format.clone(),
572            Trigger::Pointer(kind) => match kind {
573                event::EventKind::MouseEnter => actions.mouse_enter.clone(),
574                event::EventKind::MouseExit => actions.mouse_exit.clone(),
575                event::EventKind::MouseDown => actions.mouse_down.clone(),
576                event::EventKind::MouseUp => actions.mouse_up.clone(),
577                event::EventKind::Focus => actions.focus.clone(),
578                event::EventKind::Blur => actions.blur.clone(),
579                // The four value kinds never reach here: `Trigger::Pointer`
580                // is only built from the six above.
581                _ => None,
582            },
583        }
584    }
585
586    /// Runs a field's `/AA /F` the way loading its page does, discarding the
587    /// display string.
588    ///
589    /// # Why a page load runs a formatter at all
590    ///
591    /// Reading a page builds every widget on it, and building a text field or
592    /// a combo box runs its format script so the *stored* value can be drawn
593    /// as a formatted one. The script's answer reaches the appearance and
594    /// never `/V`, and for a text field upstream then drops it — only a combo
595    /// box regenerates from it. What is **not** dropped is everything the
596    /// script asked the host to do on its way there, which is why a document
597    /// whose only script is a formatter still prints alerts on open.
598    ///
599    /// The `bool` is whether the script completed.
600    pub fn format_on_load(&mut self, field: &FieldRef) -> bool {
601        let Some(source) = self.script_for(field, Trigger::Format) else {
602            return true;
603        };
604        let mut live = EventState::initialize(event::EventKind::Format);
605        live.target_name.clone_from(&field.name);
606        live.target_index = field.index;
607        live.has_value = true;
608        live.value = self
609            .values
610            .get(&field.index.unwrap_or(u32::MAX))
611            .cloned()
612            .unwrap_or_default();
613        live.will_commit = true;
614        live.commit_key = 0;
615        self.run_event(&source, live, &field.name)
616    }
617
618    /// Installs one field's scripts, its name and its current value.
619    ///
620    /// The caller reads `/AA` and hands it over, because reading it needs a
621    /// document and this type deliberately holds none.
622    pub fn set_field(
623        &mut self,
624        index: u32,
625        name: impl Into<String>,
626        value: impl Into<String>,
627        actions: FieldActions,
628    ) {
629        self.names.insert(index, name.into());
630        self.values.insert(index, value.into());
631        self.actions.insert(index, actions);
632    }
633
634    /// Installs what the `Doc` object answers from.
635    ///
636    /// The document half of the same bargain [`set_field`](Self::set_field)
637    /// struck: this type holds no PDF, so the caller reads one and hands over
638    /// a value. [`model::read`] is that reader for a `pdfrum` catalog, and a
639    /// host with its own document type writes its own.
640    ///
641    /// Without this the object model is still **bound** — `getField` exists
642    /// and is callable — and answers as an empty document would: no pages, no
643    /// fields, and `undefined` from `getField`. That is the honest answer for
644    /// a realm nobody told about a document, and it is why an unbound name is
645    /// never what a script meets.
646    pub fn set_document(&mut self, document: model::DocumentModel) {
647        self.host.borrow_mut().document = document;
648    }
649
650    /// What a script wrote through `Field.value` or `Doc.resetForm`, drained.
651    ///
652    /// A **value the caller reads back**, not a write this type performed:
653    /// applying it from inside a native function would re-enter the cascade
654    /// the script is already inside. The caller spends these through the
655    /// ordinary commit path, so the appearance regenerates the way any other
656    /// value change does.
657    ///
658    /// Each entry is a `/Fields` position and the value the script set — the
659    /// same shape [`FieldWrites`] carries, and the same index space.
660    pub fn drain_field_writes(&mut self) -> Vec<(u32, String)> {
661        std::mem::take(&mut self.host.borrow_mut().field_writes)
662    }
663
664    /// What a script wrote through `Field.borderStyle`, drained.
665    pub fn drain_border_style_writes(&mut self) -> Vec<(u32, pdfrum_doc::ap::BorderStyle)> {
666        std::mem::take(&mut self.host.borrow_mut().border_style_writes)
667    }
668
669    /// Whether a script called `Doc.calculateNow()`, drained.
670    ///
671    /// **Nothing reads this yet.** The sweep it asks for is
672    /// [`Cascade::calculate`], which today runs only inside a commit. Running
673    /// it on request needs a host that reads the flag back after the script
674    /// returns and sweeps then; no caller does yet.
675    #[expect(
676        dead_code,
677        reason = "missed wire: calculateNow is not yet driven from the host"
678    )]
679    pub(crate) fn take_calculate_request(&mut self) -> bool {
680        std::mem::take(&mut self.host.borrow_mut().calculate_requested)
681    }
682
683    /// The value the object model currently holds for a field.
684    ///
685    /// What a script last set through `Field.value`, or what the caller
686    /// installed. The caller reads this when applying a write it drained, so
687    /// the model and the session agree.
688    #[must_use]
689    pub fn field_value(&self, index: u32) -> Option<String> {
690        let host = self.host.borrow();
691        host.document
692            .field_at(usize::try_from(index).ok()?)
693            .map(|field| field.value.clone())
694    }
695
696    /// Tells the object model a field's value changed outside a script.
697    ///
698    /// A user typing into a field must be visible to the next script that
699    /// reads `getField(name).value`, and this crate holds no document to
700    /// re-read — so the caller says so, exactly as it says what the value was
701    /// at install time.
702    pub fn set_field_value(&mut self, index: u32, value: impl Into<String>) {
703        let value = value.into();
704        let mut host = self.host.borrow_mut();
705        if let Ok(index) = usize::try_from(index)
706            && let Some(field) = host.document.fields.get_mut(index)
707        {
708            field.value.clone_from(&value);
709        }
710        drop(host);
711        self.values.insert(index, value);
712    }
713
714    /// Installs the `/CO` calculation order — the field indices a calculation
715    /// sweep visits, in the order it visits them.
716    ///
717    /// **An empty order means no calculation runs**, which is the answer for a
718    /// document with no `/CO` array and is not a fallback to "every field";
719    /// see `pdfrum_doc`'s `Form::calculation_order`.
720    pub fn set_calculation_order(&mut self, order: Vec<u32>) {
721        self.order = order;
722    }
723
724    /// How deep a calculation may nest, for a caller building a
725    /// [`FieldWrites`].
726    #[must_use]
727    pub fn max_calculate_depth(&self) -> u32 {
728        self.max_calculate_depth
729    }
730
731    /// Records this session's stops as diagnostics on the caller's sink, and
732    /// hands back what each of them said.
733    ///
734    /// Kept separate from [`ScriptCascade::run`] so a caller decides when
735    /// diagnostics are drained, and so the cascade methods — whose signatures
736    /// take no `Diagnostics` — can still be honest about what happened.
737    ///
738    /// # Why it returns the failures rather than only recording them
739    ///
740    /// [`Diagnostic`](pdfrum_common::Diagnostic) is a *kind*, a severity and a
741    /// byte offset — a bounded sink a hostile file must not be able to grow —
742    /// so it can say **that** a script threw but not *which* one or *what it
743    /// said*. The kind goes on the sink and the detail comes back here.
744    ///
745    /// The session is drained: a second call answers nothing.
746    pub fn drain_diagnostics(&mut self, diags: &mut Diagnostics) -> Vec<ScriptFailure> {
747        let failures: Vec<ScriptFailure> = std::mem::take(&mut self.stops);
748        for failure in &failures {
749            diags.record(
750                pdfrum_common::Severity::Suspicious,
751                match failure.stop {
752                    ScriptStop::LimitReached => pdfrum_common::DiagKind::ScriptLimitReached,
753                    ScriptStop::Threw(_) => pdfrum_common::DiagKind::ScriptFailed,
754                },
755                None,
756            );
757        }
758        failures
759    }
760
761    /// Reads `event.rc` back.
762    ///
763    /// The slot is a `bool` and the setter coerced on the way in, so
764    /// `event.rc = 'boo'` reads back `true` — the oracle's behaviour,
765    /// reproduced rather than tightened.
766    fn event_rc(&self) -> bool {
767        self.host.borrow().event.rc
768    }
769
770    /// Reads `event.value` back.
771    fn event_value(&self) -> String {
772        self.host.borrow().event.value.clone()
773    }
774
775    /// Reads `event.change` back.
776    fn event_change(&self) -> String {
777        self.host.borrow().event.change.clone()
778    }
779
780    /// Reads the two selection indices back.
781    fn event_selection(&self) -> (i32, i32) {
782        let host = self.host.borrow();
783        (host.event.sel_start, host.event.sel_end)
784    }
785
786    /// Runs one field's `/AA` script for a trigger that carries no value and
787    /// reads nothing back — the six mouse and focus entries.
788    ///
789    /// **The event is a full one**, `targetName` and the modifier flags and
790    /// all: a script on `/AA /D` reads `event.name == "Mouse Down"` and
791    /// `event.target.value`, and `Doc.submitForm` is permitted from it
792    /// because a mouse-down *is* a user gesture. What such a trigger cannot
793    /// do is change the value: `event.value` throws
794    /// `Object no longer exists.`, which is `has_value` being false.
795    fn run_pointer_trigger(
796        &mut self,
797        field: &FieldRef,
798        kind: event::EventKind,
799        pointer: PointerModifiers,
800    ) -> bool {
801        let Some(source) = self.script_for(field, Trigger::Pointer(kind)) else {
802            return true;
803        };
804        let mut live = EventState::initialize(kind);
805        live.target_name.clone_from(&field.name);
806        live.target_index = field.index;
807        live.modifier = pointer.modifier;
808        live.shift = pointer.shift;
809        // Focus and Blur carry the field's value; the four mouse kinds do
810        // not (`cjs_event_context.cpp:146-205` — only the two focus
811        // functions take a `WideString*`).
812        if matches!(kind, event::EventKind::Focus | event::EventKind::Blur) {
813            live.has_value = true;
814            live.value = self
815                .values
816                .get(&field.index.unwrap_or(u32::MAX))
817                .cloned()
818                .unwrap_or_default();
819        }
820        self.run_event(&source, live, &field.name)
821    }
822}
823
824/// Which `/AA` entry a hook runs.
825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826enum Trigger {
827    /// `/AA /K`, with or without a commit imminent — one key, two methods.
828    Keystroke,
829    /// `/AA /V`.
830    Validate,
831    /// `/AA /F`.
832    Format,
833    /// One of the six that fire on a pointer or the keyboard focus:
834    /// `/AA /E`, `/X`, `/D`, `/U`, `/Fo`, `/Bl`.
835    Pointer(event::EventKind),
836}
837
838/// Whether a modifier key and Shift were held when a pointer event arrived.
839///
840/// Two `bool`s rather than a bitmask because that is all the `event` object
841/// exposes: `event.modifier` and `event.shift`, each read-only. Private
842/// because [`Cascade::pointer`] takes the session's own [`crate::Modifiers`]
843/// and this is what it narrows to.
844#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
845struct PointerModifiers {
846    /// `event.modifier` — Ctrl on Windows, Command elsewhere.
847    pub modifier: bool,
848    /// `event.shift`.
849    pub shift: bool,
850}
851
852impl Cascade for ScriptCascade {
853    /// The keystroke hook: `/AA /K` with `willCommit` false.
854    ///
855    /// The script may **rewrite `event.change` and move the selection**, and
856    /// what it left is what gets applied — only the change and the two
857    /// selection indices are read back. A write to `event.value` on this path
858    /// compiles, does not throw, and is **discarded**.
859    fn keystroke(&mut self, field: &FieldRef, change: Keystroke) -> KeystrokeOutcome {
860        let Some(source) = self.script_for(field, Trigger::Keystroke) else {
861            return KeystrokeOutcome::Accept(change);
862        };
863        let mut live = EventState::initialize(event::EventKind::Keystroke);
864        live.target_name.clone_from(&field.name);
865        live.target_index = field.index;
866        live.has_value = true;
867        live.value.clone_from(&change.value);
868        live.change.clone_from(&change.change);
869        live.sel_start = change.selection_start;
870        live.sel_end = change.selection_end;
871        // Keystroke and Format are the only kinds that set it to 0.
872        live.commit_key = 0;
873        if !self.run_event(&source, live, &field.name) {
874            // A script that threw or ran out of budget did not say "accept".
875            return KeystrokeOutcome::Reject;
876        }
877        if !self.event_rc() {
878            return KeystrokeOutcome::Reject;
879        }
880        let (selection_start, selection_end) = self.event_selection();
881        KeystrokeOutcome::Accept(Keystroke {
882            change: self.event_change(),
883            value: change.value,
884            selection_start,
885            selection_end,
886        })
887    }
888
889    /// The same `/AA /K` with a commit imminent, which is the only thing that
890    /// differs: `willCommit` is `true` and `event.value` is the whole value
891    /// rather than the text before an insertion.
892    fn keystroke_commit(&mut self, field: &FieldRef, value: &str) -> bool {
893        let Some(source) = self.script_for(field, Trigger::Keystroke) else {
894            return true;
895        };
896        let mut live = EventState::initialize(event::EventKind::Keystroke);
897        live.target_name.clone_from(&field.name);
898        live.target_index = field.index;
899        live.has_value = true;
900        live.value = value.to_string();
901        live.will_commit = true;
902        live.commit_key = 0;
903        self.run_event(&source, live, &field.name) && self.event_rc()
904    }
905
906    /// `/AA /V`. `event.rc` is the whole answer; a write to `event.value` is
907    /// discarded exactly as on the keystroke path.
908    fn validate(&mut self, field: &FieldRef, value: &str) -> bool {
909        let Some(source) = self.script_for(field, Trigger::Validate) else {
910            return true;
911        };
912        let mut live = EventState::initialize(event::EventKind::Validate);
913        live.target_name.clone_from(&field.name);
914        live.target_index = field.index;
915        live.has_value = true;
916        live.value = value.to_string();
917        self.run_event(&source, live, &field.name) && self.event_rc()
918    }
919
920    /// `/AA /C`, over the whole calculation order.
921    ///
922    /// **One call runs the entire sweep**: `/CO` is walked and each field
923    /// written in turn. The three-way gate is normative — a calculated value
924    /// is written **only if** the script did not throw, **and** `event.rc` is
925    /// still truthy, **and** the string actually changed.
926    ///
927    /// The `busy_` guard is [`FieldWrites`]'s depth budget, whose default is
928    /// 1 because upstream permits no nesting at all.
929    fn calculate(&mut self, writes: &mut FieldWrites, trigger: &FieldRef) {
930        if !writes.enter() {
931            // The budget is spent: this is a nested sweep, and upstream's
932            // `busy_` makes every one of those a no-op.
933            return;
934        }
935        let order: Vec<u32> = self.order.clone();
936        for index in order {
937            let Some(source) = self
938                .actions
939                .get(&index)
940                .and_then(|actions| actions.calculate.clone())
941            else {
942                continue;
943            };
944            let before = self.values.get(&index).cloned().unwrap_or_default();
945            let mut live = EventState::initialize(event::EventKind::Calculate);
946            live.target_name = self.names.get(&index).cloned().unwrap_or_default();
947            live.target_index = Some(index);
948            live.has_value = true;
949            live.value.clone_from(&before);
950            // `event.source` is meaningful only here, and it names the field
951            // whose change provoked the sweep.
952            live.source_name.clone_from(&trigger.name);
953            live.source_index = trigger.index;
954
955            let whence = live.target_name.clone();
956            if !self.run_event(&source, live, &whence) {
957                continue;
958            }
959            if !self.event_rc() {
960                continue;
961            }
962            let after = self.event_value();
963            if after == before {
964                continue;
965            }
966            self.values.insert(index, after.clone());
967            writes.set(index, after);
968        }
969        writes.leave();
970    }
971
972    /// One of the six pointer and focus `/AA` entries.
973    ///
974    /// The trigger decides which script and which `event.name`; the two
975    /// modifier flags are all the `event` object exposes of what was held.
976    fn pointer(
977        &mut self,
978        field: &FieldRef,
979        trigger: crate::cascade::PointerTrigger,
980        held: crate::Modifiers,
981    ) {
982        use crate::cascade::PointerTrigger;
983        let kind = match trigger {
984            PointerTrigger::Enter => event::EventKind::MouseEnter,
985            PointerTrigger::Exit => event::EventKind::MouseExit,
986            PointerTrigger::Down => event::EventKind::MouseDown,
987            PointerTrigger::Up => event::EventKind::MouseUp,
988            PointerTrigger::Focus => event::EventKind::Focus,
989            PointerTrigger::Blur => event::EventKind::Blur,
990        };
991        // `event.modifier` is the **control** key, not "any modifier":
992        // `CPWL_Wnd::IsCTRLpressed` is what every `On*` call passes
993        // (`fpdfsdk/formfiller/cffl_interactiveformfiller.cpp`), and
994        // `event.shift` is `IsSHIFTpressed`. Alt reaches neither.
995        let pointer = PointerModifiers {
996            modifier: held.contains(crate::Modifiers::CONTROL),
997            shift: held.contains(crate::Modifiers::SHIFT),
998        };
999        self.run_pointer_trigger(field, kind, pointer);
1000    }
1001
1002    /// The field the last `Field.setFocus()` named, drained.
1003    ///
1004    /// `CJS_Field::setFocus` reaches `CPDFSDK_FormFillEnvironment::SetFocusAnnot`,
1005    /// which kills the outgoing widget's focus — firing its `/AA /Bl` — and
1006    /// then gives the keyboard to the incoming one, firing its `/AA /Fo`.
1007    /// Neither half can run from inside the native function without
1008    /// re-entering the routing the script is already inside, so the request
1009    /// is recorded here and the caller spends it.
1010    ///
1011    /// **The last call wins.** The slot holds one field, so a script calling
1012    /// `setFocus` twice moves the keyboard once, to the second field — which
1013    /// is upstream's shape, where each call overwrites `focus_annot_` and only
1014    /// the final one survives the script.
1015    fn take_focus_request(&mut self) -> Option<u32> {
1016        self.host.borrow_mut().focus_requested.take()
1017    }
1018
1019    fn drain_border_style_writes(&mut self) -> Vec<(u32, pdfrum_doc::ap::BorderStyle)> {
1020        std::mem::take(&mut self.host.borrow_mut().border_style_writes)
1021    }
1022
1023    /// `/AA /F`.
1024    ///
1025    /// **The write reaches the appearance only, never `/V`.** `event.value`
1026    /// is bound to a *local* string; what the script leaves there is drawn,
1027    /// and re-running with no formatter reverts the appearance to the raw
1028    /// value.
1029    ///
1030    /// Format also **hard-codes `willCommit = true`** and leaves `rc` unbound,
1031    /// so a Format script's `event.rc` writes reach nothing.
1032    fn format(&mut self, field: &FieldRef, value: &str) -> Option<String> {
1033        let source = self.script_for(field, Trigger::Format)?;
1034        let mut live = EventState::initialize(event::EventKind::Format);
1035        live.target_name.clone_from(&field.name);
1036        live.target_index = field.index;
1037        live.has_value = true;
1038        live.value = value.to_string();
1039        // Hard-coded, not inherited.
1040        live.will_commit = true;
1041        live.commit_key = 0;
1042        if !self.run_event(&source, live, &field.name) {
1043            return None;
1044        }
1045        let formatted = self.event_value();
1046        (formatted != value).then_some(formatted)
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests;