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;