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 /// Whether a script called `Doc.calculateNow()`, drained.
665 ///
666 /// **Nothing reads this yet.** The sweep it asks for is
667 /// [`Cascade::calculate`], which today runs only inside a commit. Running
668 /// it on request needs a host that reads the flag back after the script
669 /// returns and sweeps then; no caller does yet.
670 #[expect(
671 dead_code,
672 reason = "missed wire: calculateNow is not yet driven from the host"
673 )]
674 pub(crate) fn take_calculate_request(&mut self) -> bool {
675 std::mem::take(&mut self.host.borrow_mut().calculate_requested)
676 }
677
678 /// The value the object model currently holds for a field.
679 ///
680 /// What a script last set through `Field.value`, or what the caller
681 /// installed. The caller reads this when applying a write it drained, so
682 /// the model and the session agree.
683 #[must_use]
684 pub fn field_value(&self, index: u32) -> Option<String> {
685 let host = self.host.borrow();
686 host.document
687 .field_at(usize::try_from(index).ok()?)
688 .map(|field| field.value.clone())
689 }
690
691 /// Tells the object model a field's value changed outside a script.
692 ///
693 /// A user typing into a field must be visible to the next script that
694 /// reads `getField(name).value`, and this crate holds no document to
695 /// re-read — so the caller says so, exactly as it says what the value was
696 /// at install time.
697 pub fn set_field_value(&mut self, index: u32, value: impl Into<String>) {
698 let value = value.into();
699 let mut host = self.host.borrow_mut();
700 if let Ok(index) = usize::try_from(index)
701 && let Some(field) = host.document.fields.get_mut(index)
702 {
703 field.value.clone_from(&value);
704 }
705 drop(host);
706 self.values.insert(index, value);
707 }
708
709 /// Installs the `/CO` calculation order — the field indices a calculation
710 /// sweep visits, in the order it visits them.
711 ///
712 /// **An empty order means no calculation runs**, which is the answer for a
713 /// document with no `/CO` array and is not a fallback to "every field";
714 /// see `pdfrum_doc`'s `Form::calculation_order`.
715 pub fn set_calculation_order(&mut self, order: Vec<u32>) {
716 self.order = order;
717 }
718
719 /// How deep a calculation may nest, for a caller building a
720 /// [`FieldWrites`].
721 #[must_use]
722 pub fn max_calculate_depth(&self) -> u32 {
723 self.max_calculate_depth
724 }
725
726 /// Records this session's stops as diagnostics on the caller's sink, and
727 /// hands back what each of them said.
728 ///
729 /// Kept separate from [`ScriptCascade::run`] so a caller decides when
730 /// diagnostics are drained, and so the cascade methods — whose signatures
731 /// take no `Diagnostics` — can still be honest about what happened.
732 ///
733 /// # Why it returns the failures rather than only recording them
734 ///
735 /// [`Diagnostic`](pdfrum_common::Diagnostic) is a *kind*, a severity and a
736 /// byte offset — a bounded sink a hostile file must not be able to grow —
737 /// so it can say **that** a script threw but not *which* one or *what it
738 /// said*. The kind goes on the sink and the detail comes back here.
739 ///
740 /// The session is drained: a second call answers nothing.
741 pub fn drain_diagnostics(&mut self, diags: &mut Diagnostics) -> Vec<ScriptFailure> {
742 let failures: Vec<ScriptFailure> = std::mem::take(&mut self.stops);
743 for failure in &failures {
744 diags.record(
745 pdfrum_common::Severity::Suspicious,
746 match failure.stop {
747 ScriptStop::LimitReached => pdfrum_common::DiagKind::ScriptLimitReached,
748 ScriptStop::Threw(_) => pdfrum_common::DiagKind::ScriptFailed,
749 },
750 None,
751 );
752 }
753 failures
754 }
755
756 /// Reads `event.rc` back.
757 ///
758 /// The slot is a `bool` and the setter coerced on the way in, so
759 /// `event.rc = 'boo'` reads back `true` — the oracle's behaviour,
760 /// reproduced rather than tightened.
761 fn event_rc(&self) -> bool {
762 self.host.borrow().event.rc
763 }
764
765 /// Reads `event.value` back.
766 fn event_value(&self) -> String {
767 self.host.borrow().event.value.clone()
768 }
769
770 /// Reads `event.change` back.
771 fn event_change(&self) -> String {
772 self.host.borrow().event.change.clone()
773 }
774
775 /// Reads the two selection indices back.
776 fn event_selection(&self) -> (i32, i32) {
777 let host = self.host.borrow();
778 (host.event.sel_start, host.event.sel_end)
779 }
780
781 /// Runs one field's `/AA` script for a trigger that carries no value and
782 /// reads nothing back — the six mouse and focus entries.
783 ///
784 /// **The event is a full one**, `targetName` and the modifier flags and
785 /// all: a script on `/AA /D` reads `event.name == "Mouse Down"` and
786 /// `event.target.value`, and `Doc.submitForm` is permitted from it
787 /// because a mouse-down *is* a user gesture. What such a trigger cannot
788 /// do is change the value: `event.value` throws
789 /// `Object no longer exists.`, which is `has_value` being false.
790 fn run_pointer_trigger(
791 &mut self,
792 field: &FieldRef,
793 kind: event::EventKind,
794 pointer: PointerModifiers,
795 ) -> bool {
796 let Some(source) = self.script_for(field, Trigger::Pointer(kind)) else {
797 return true;
798 };
799 let mut live = EventState::initialize(kind);
800 live.target_name.clone_from(&field.name);
801 live.target_index = field.index;
802 live.modifier = pointer.modifier;
803 live.shift = pointer.shift;
804 // Focus and Blur carry the field's value; the four mouse kinds do
805 // not (`cjs_event_context.cpp:146-205` — only the two focus
806 // functions take a `WideString*`).
807 if matches!(kind, event::EventKind::Focus | event::EventKind::Blur) {
808 live.has_value = true;
809 live.value = self
810 .values
811 .get(&field.index.unwrap_or(u32::MAX))
812 .cloned()
813 .unwrap_or_default();
814 }
815 self.run_event(&source, live, &field.name)
816 }
817}
818
819/// Which `/AA` entry a hook runs.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821enum Trigger {
822 /// `/AA /K`, with or without a commit imminent — one key, two methods.
823 Keystroke,
824 /// `/AA /V`.
825 Validate,
826 /// `/AA /F`.
827 Format,
828 /// One of the six that fire on a pointer or the keyboard focus:
829 /// `/AA /E`, `/X`, `/D`, `/U`, `/Fo`, `/Bl`.
830 Pointer(event::EventKind),
831}
832
833/// Whether a modifier key and Shift were held when a pointer event arrived.
834///
835/// Two `bool`s rather than a bitmask because that is all the `event` object
836/// exposes: `event.modifier` and `event.shift`, each read-only. Private
837/// because [`Cascade::pointer`] takes the session's own [`crate::Modifiers`]
838/// and this is what it narrows to.
839#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
840struct PointerModifiers {
841 /// `event.modifier` — Ctrl on Windows, Command elsewhere.
842 pub modifier: bool,
843 /// `event.shift`.
844 pub shift: bool,
845}
846
847impl Cascade for ScriptCascade {
848 /// The keystroke hook: `/AA /K` with `willCommit` false.
849 ///
850 /// The script may **rewrite `event.change` and move the selection**, and
851 /// what it left is what gets applied — only the change and the two
852 /// selection indices are read back. A write to `event.value` on this path
853 /// compiles, does not throw, and is **discarded**.
854 fn keystroke(&mut self, field: &FieldRef, change: Keystroke) -> KeystrokeOutcome {
855 let Some(source) = self.script_for(field, Trigger::Keystroke) else {
856 return KeystrokeOutcome::Accept(change);
857 };
858 let mut live = EventState::initialize(event::EventKind::Keystroke);
859 live.target_name.clone_from(&field.name);
860 live.target_index = field.index;
861 live.has_value = true;
862 live.value.clone_from(&change.value);
863 live.change.clone_from(&change.change);
864 live.sel_start = change.selection_start;
865 live.sel_end = change.selection_end;
866 // Keystroke and Format are the only kinds that set it to 0.
867 live.commit_key = 0;
868 if !self.run_event(&source, live, &field.name) {
869 // A script that threw or ran out of budget did not say "accept".
870 return KeystrokeOutcome::Reject;
871 }
872 if !self.event_rc() {
873 return KeystrokeOutcome::Reject;
874 }
875 let (selection_start, selection_end) = self.event_selection();
876 KeystrokeOutcome::Accept(Keystroke {
877 change: self.event_change(),
878 value: change.value,
879 selection_start,
880 selection_end,
881 })
882 }
883
884 /// The same `/AA /K` with a commit imminent, which is the only thing that
885 /// differs: `willCommit` is `true` and `event.value` is the whole value
886 /// rather than the text before an insertion.
887 fn keystroke_commit(&mut self, field: &FieldRef, value: &str) -> bool {
888 let Some(source) = self.script_for(field, Trigger::Keystroke) else {
889 return true;
890 };
891 let mut live = EventState::initialize(event::EventKind::Keystroke);
892 live.target_name.clone_from(&field.name);
893 live.target_index = field.index;
894 live.has_value = true;
895 live.value = value.to_string();
896 live.will_commit = true;
897 live.commit_key = 0;
898 self.run_event(&source, live, &field.name) && self.event_rc()
899 }
900
901 /// `/AA /V`. `event.rc` is the whole answer; a write to `event.value` is
902 /// discarded exactly as on the keystroke path.
903 fn validate(&mut self, field: &FieldRef, value: &str) -> bool {
904 let Some(source) = self.script_for(field, Trigger::Validate) else {
905 return true;
906 };
907 let mut live = EventState::initialize(event::EventKind::Validate);
908 live.target_name.clone_from(&field.name);
909 live.target_index = field.index;
910 live.has_value = true;
911 live.value = value.to_string();
912 self.run_event(&source, live, &field.name) && self.event_rc()
913 }
914
915 /// `/AA /C`, over the whole calculation order.
916 ///
917 /// **One call runs the entire sweep**: `/CO` is walked and each field
918 /// written in turn. The three-way gate is normative — a calculated value
919 /// is written **only if** the script did not throw, **and** `event.rc` is
920 /// still truthy, **and** the string actually changed.
921 ///
922 /// The `busy_` guard is [`FieldWrites`]'s depth budget, whose default is
923 /// 1 because upstream permits no nesting at all.
924 fn calculate(&mut self, writes: &mut FieldWrites, trigger: &FieldRef) {
925 if !writes.enter() {
926 // The budget is spent: this is a nested sweep, and upstream's
927 // `busy_` makes every one of those a no-op.
928 return;
929 }
930 let order: Vec<u32> = self.order.clone();
931 for index in order {
932 let Some(source) = self
933 .actions
934 .get(&index)
935 .and_then(|actions| actions.calculate.clone())
936 else {
937 continue;
938 };
939 let before = self.values.get(&index).cloned().unwrap_or_default();
940 let mut live = EventState::initialize(event::EventKind::Calculate);
941 live.target_name = self.names.get(&index).cloned().unwrap_or_default();
942 live.target_index = Some(index);
943 live.has_value = true;
944 live.value.clone_from(&before);
945 // `event.source` is meaningful only here, and it names the field
946 // whose change provoked the sweep.
947 live.source_name.clone_from(&trigger.name);
948 live.source_index = trigger.index;
949
950 let whence = live.target_name.clone();
951 if !self.run_event(&source, live, &whence) {
952 continue;
953 }
954 if !self.event_rc() {
955 continue;
956 }
957 let after = self.event_value();
958 if after == before {
959 continue;
960 }
961 self.values.insert(index, after.clone());
962 writes.set(index, after);
963 }
964 writes.leave();
965 }
966
967 /// One of the six pointer and focus `/AA` entries.
968 ///
969 /// The trigger decides which script and which `event.name`; the two
970 /// modifier flags are all the `event` object exposes of what was held.
971 fn pointer(
972 &mut self,
973 field: &FieldRef,
974 trigger: crate::cascade::PointerTrigger,
975 held: crate::Modifiers,
976 ) {
977 use crate::cascade::PointerTrigger;
978 let kind = match trigger {
979 PointerTrigger::Enter => event::EventKind::MouseEnter,
980 PointerTrigger::Exit => event::EventKind::MouseExit,
981 PointerTrigger::Down => event::EventKind::MouseDown,
982 PointerTrigger::Up => event::EventKind::MouseUp,
983 PointerTrigger::Focus => event::EventKind::Focus,
984 PointerTrigger::Blur => event::EventKind::Blur,
985 };
986 // `event.modifier` is the **control** key, not "any modifier":
987 // `CPWL_Wnd::IsCTRLpressed` is what every `On*` call passes
988 // (`fpdfsdk/formfiller/cffl_interactiveformfiller.cpp`), and
989 // `event.shift` is `IsSHIFTpressed`. Alt reaches neither.
990 let pointer = PointerModifiers {
991 modifier: held.contains(crate::Modifiers::CONTROL),
992 shift: held.contains(crate::Modifiers::SHIFT),
993 };
994 self.run_pointer_trigger(field, kind, pointer);
995 }
996
997 /// The field the last `Field.setFocus()` named, drained.
998 ///
999 /// `CJS_Field::setFocus` reaches `CPDFSDK_FormFillEnvironment::SetFocusAnnot`,
1000 /// which kills the outgoing widget's focus — firing its `/AA /Bl` — and
1001 /// then gives the keyboard to the incoming one, firing its `/AA /Fo`.
1002 /// Neither half can run from inside the native function without
1003 /// re-entering the routing the script is already inside, so the request
1004 /// is recorded here and the caller spends it.
1005 ///
1006 /// **The last call wins.** The slot holds one field, so a script calling
1007 /// `setFocus` twice moves the keyboard once, to the second field — which
1008 /// is upstream's shape, where each call overwrites `focus_annot_` and only
1009 /// the final one survives the script.
1010 fn take_focus_request(&mut self) -> Option<u32> {
1011 self.host.borrow_mut().focus_requested.take()
1012 }
1013
1014 /// `/AA /F`.
1015 ///
1016 /// **The write reaches the appearance only, never `/V`.** `event.value`
1017 /// is bound to a *local* string; what the script leaves there is drawn,
1018 /// and re-running with no formatter reverts the appearance to the raw
1019 /// value.
1020 ///
1021 /// Format also **hard-codes `willCommit = true`** and leaves `rc` unbound,
1022 /// so a Format script's `event.rc` writes reach nothing.
1023 fn format(&mut self, field: &FieldRef, value: &str) -> Option<String> {
1024 let source = self.script_for(field, Trigger::Format)?;
1025 let mut live = EventState::initialize(event::EventKind::Format);
1026 live.target_name.clone_from(&field.name);
1027 live.target_index = field.index;
1028 live.has_value = true;
1029 live.value = value.to_string();
1030 // Hard-coded, not inherited.
1031 live.will_commit = true;
1032 live.commit_key = 0;
1033 if !self.run_event(&source, live, &field.name) {
1034 return None;
1035 }
1036 let formatted = self.event_value();
1037 (formatted != value).then_some(formatted)
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests;