From 17ae6ec9cd147c4ef681ff7ab1f4c02bab54ea49 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 11 Nov 2026 11:22:32 +0100
Subject: [PATCH] feat(engine): page actions and timers run in the document
realm and change only the view (#169)
show_page runs a page's close/open actions, run_timers fires due timers (at most 16 a call),
next_timer and script_clock drive the viewer. Fields they show, hide or give another icon
are drawn so on screen only (screen_config); values and resets are undoable edits. A timer's
alerts and requests go to the console. getOCGs layer changes turn layers as the panel does.
Co-authored-by: Peter Yatsyshin
---
crates/engine/src/document.rs | 2 +
crates/engine/src/js.rs | 16 +-
crates/engine/src/layers.rs | 4 +-
crates/engine/src/lib.rs | 2 +
crates/engine/src/live_scripts.rs | 376 ++++++++++++++++++++++++
crates/engine/src/live_scripts_tests.rs | 221 ++++++++++++++
crates/engine/src/open.rs | 1 +
7 files changed, 615 insertions(+), 7 deletions(-)
create mode 100644 crates/engine/src/live_scripts.rs
create mode 100644 crates/engine/src/live_scripts_tests.rs
diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs
index 602410922f..c98893ab9c 100644
--- a/crates/engine/src/document.rs
+++ b/crates/engine/src/document.rs
@@ -55,6 +55,8 @@ pub struct Document {
pub(crate) js_runtime: js::Runtime,
pub(crate) js_zoom: f64,
pub(crate) js_page: usize,
+ /// How its page actions and timers show it while it is read (see [`Session::show_page`](crate::Session::show_page)).
+ pub(crate) live: crate::live_scripts::LiveView,
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
pub xfa: Option,
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index f7bc3cbd5b..fa63da67be 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -154,7 +154,7 @@ impl JsOutput {
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
/// except a request to print, show a side panel, open Save As, or open or save an attached
/// file, which is noted on the console and dropped.
- fn absorb(&mut self, o: &Outcome) {
+ pub(crate) fn absorb(&mut self, o: &Outcome) {
self.alert(o.alerts.iter().cloned());
self.say(o.console.iter().cloned());
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
@@ -560,6 +560,7 @@ impl JsRunner {
runtime_highlight: highlight,
icon_epoch: 0,
icon_versions: Default::default(),
+ layers: Vec::new(),
};
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
// Keep them in the file and in Document JavaScripts, but do not initialize an
@@ -589,7 +590,7 @@ impl JsRunner {
}
/// Index calculation sources by PDF object identity, retaining their matching snapshot positions.
-fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> {
+pub(crate) fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> {
#[cfg(test)]
SNAPSHOT_WORK.with(|c| {
let (g, p) = c.get();
@@ -1014,8 +1015,9 @@ impl Session {
let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight);
runner.doc.zoom = doc.js_zoom;
runner.doc.page = doc.js_page;
+ runner.doc.layers = crate::live_scripts::js_layers(doc);
let fields = pdfcraft_forms::fields(&cos);
- let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::>());
+ let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::>());
let event = match target {
Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") },
None => pdfcraft_js::Event::doc(document_event.map_or("Console", DocumentScriptEvent::name)),
@@ -1043,7 +1045,7 @@ impl Session {
}
/// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits.
- fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result {
+ pub(crate) fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result {
if o.execution_refused {
o.changed.clear();
o.requests.clear();
@@ -1106,12 +1108,15 @@ impl Session {
}
}
let changes = changes(&baseline, &o.changed, &o.formatted);
+ let written: Vec = changes.iter().filter(|c| c.display.is_some() || c.icon.is_some()).map(|c| c.name.clone()).collect();
if !changes.is_empty() {
edits.push(Edit::ApplyScriptChanges { changes });
}
if !edits.is_empty() {
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
}
+ // The timers the script left, the layers it turned, and how fields show (#169).
+ self.after_script(id, &o, &written);
Ok(o)
}
}
@@ -1485,9 +1490,10 @@ impl Session {
let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight);
runner.doc.zoom = doc.js_zoom;
runner.doc.page = doc.js_page;
+ runner.doc.layers = crate::live_scripts::js_layers(doc);
let fields = pdfcraft_forms::fields(cos);
validate_script_target(target, target_id, &fields)?;
- let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>());
+ let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::>());
let job = pdfcraft_js::session::Job {
script: script.into(),
event: target.map_or_else(
diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs
index b2fab68acb..4443a52b3c 100644
--- a/crates/engine/src/layers.rs
+++ b/crates/engine/src/layers.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
use pdfcraft_render::{Layer, LayerOp, RenderPool};
-use crate::{Document, display_config, render_threads};
+use crate::{Document, render_threads};
/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and
/// most category names and groups it reads in all of them together (entries can share one
@@ -132,7 +132,7 @@ pub(crate) fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers);
- doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), crate::live_scripts::screen_config(doc));
}
/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 161b2c1d34..e737798d97 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -36,6 +36,7 @@ pub mod find_replace;
mod history;
pub mod js;
mod layers;
+mod live_scripts;
pub mod links;
pub mod model3d;
mod object_clip;
@@ -75,6 +76,7 @@ pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
pub use pdfcraft_edit::strip_subset_tag;
+pub use live_scripts::{MAX_TIMER_RUNS, ScriptRun};
pub use rich_copy::RichCopy;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
diff --git a/crates/engine/src/live_scripts.rs b/crates/engine/src/live_scripts.rs
new file mode 100644
index 0000000000..bbf89853ff
--- /dev/null
+++ b/crates/engine/src/live_scripts.rs
@@ -0,0 +1,376 @@
+//! A document's page actions and timers while it is read (#169): page open/close actions run
+//! as the reader moves between pages, `app.setInterval` / `app.setTimeOut` timers fire on the
+//! viewer's clock, and what they show or hide (an animation's frames) changes only the view.
+//! Everything runs in the document's script realm (#5699), shared with its buttons.
+
+use std::collections::BTreeMap;
+
+use pdfcraft_js::{Outcome, Request};
+
+use crate::js::{JsOutput, Runtime, calculation_scripts, field_state};
+use crate::{DocId, Document, EditError, Session};
+
+/// Most timers one call to [`Session::run_timers`] runs: a document asking for more at once
+/// falls behind instead of holding up the viewer.
+pub const MAX_TIMER_RUNS: usize = 16;
+
+/// A timer of a document's scripts, on the caller's clock.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Due {
+ id: u32,
+ period_ms: u32,
+ repeat: bool,
+ /// When it falls due, in milliseconds.
+ at: f64,
+}
+
+/// How a document's page actions and timers show it while it is read. Nothing here is part of
+/// the document: saving, printing and exporting never see it.
+#[derive(Default)]
+pub(crate) struct LiveView {
+ /// The page whose open actions ran last.
+ page: Option,
+ timers: Vec,
+ /// The realm the timers were set in: a fresh realm (an edit's reset, Revert, a full
+ /// save) starts without them.
+ timers_of: Option>>,
+ /// `display` page actions and timers gave fields, by name, over the document's own flags.
+ shown: BTreeMap,
+ /// Icons page actions and timers gave push buttons (`buttonSetIcon`), by name: the icon's
+ /// form object (`None`: no icon), over the button's own.
+ icons: BTreeMap>,
+ /// The caller's clock when it last looked, in milliseconds: a timer a button or the
+ /// console sets counts from here.
+ clock: f64,
+}
+
+impl LiveView {
+ /// The timers, unless the realm that set them was replaced since.
+ fn timers(&mut self, runtime: &Runtime) -> &mut Vec {
+ if self.timers_of.as_ref().and_then(std::sync::Weak::upgrade).is_none_or(|r| !std::sync::Arc::ptr_eq(&r, runtime)) {
+ self.timers.clear();
+ self.timers_of = None;
+ }
+ &mut self.timers
+ }
+
+ /// Keep the timers realm `runtime` reported after a run: new ones count from the clock,
+ /// cleared ones go.
+ fn schedule(&mut self, runtime: &Runtime, reported: &[pdfcraft_js::Timer]) {
+ let clock = self.clock;
+ let timers = self.timers(runtime);
+ timers.retain(|d| reported.iter().any(|t| t.id == d.id));
+ for t in reported {
+ if !timers.iter().any(|d| d.id == t.id) {
+ timers.push(Due { id: t.id, period_ms: t.period_ms, repeat: t.repeat, at: clock + f64::from(t.period_ms) });
+ }
+ }
+ self.timers_of = (!self.timers.is_empty()).then(|| std::sync::Arc::downgrade(runtime));
+ }
+}
+
+/// What running a document's page actions or timers did, for the viewer.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ScriptRun {
+ /// Alerts, console output, requests and errors, as from any script.
+ pub output: JsOutput,
+ /// Pages showing a field the scripts showed, hid or gave another icon: draw them again.
+ pub pages: Vec,
+ /// The scripts changed the document (a field's value, or a reset): read it again.
+ pub edited: bool,
+ /// How many timers ran.
+ pub ran: usize,
+ /// Layers were turned on or off (`ocg.state`): draw every page again.
+ pub layers: bool,
+}
+
+impl ScriptRun {
+ pub fn is_empty(&self) -> bool {
+ self.output.is_empty() && self.pages.is_empty() && !self.edited && !self.layers
+ }
+}
+
+/// The document's layers as scripts see them (`getOCGs`), in the viewer's order.
+pub(crate) fn js_layers(doc: &Document) -> Vec {
+ doc.info.layers.iter().map(|l| pdfcraft_js::Layer { name: l.name.clone(), on: l.visible, initial: l.initial }).collect()
+}
+
+/// A field as scripts see it: as the document has it, but shown as page actions and timers
+/// left it (its `display` and push button icon), so a button stepping an animation steps from
+/// the frame on screen.
+pub(crate) fn live_state(doc: &Document, cos: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, info: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState {
+ let mut s = field_state(cos, f, info);
+ if let Some(d) = doc.live.shown.get(&f.name) {
+ s.display = *d;
+ }
+ if let Some(icon) = doc.live.icons.get(&f.name) {
+ s.icon = *icon;
+ }
+ s
+}
+
+/// Whether a field with this `display` shows on screen.
+fn on_screen(display: i32) -> bool {
+ display == pdfcraft_js::DISPLAY_VISIBLE || display == pdfcraft_js::DISPLAY_NO_PRINT
+}
+
+/// A widget's object as the renderer names it, when its number fits.
+fn object(w: &pdfcraft_forms::Widget) -> Option<(i32, i32)> {
+ Some((i32::try_from(w.obj.num).ok()?, i32::from(w.obj.generation)))
+}
+
+/// The viewer's render configuration: the document's, with installed fonts, and the fields as
+/// page actions and timers show them. Exports and printing use `doc.config`, without them.
+pub(crate) fn screen_config(doc: &Document) -> pdfcraft_render::RenderConfig {
+ let live = &doc.live;
+ let annotations: Vec<(i32, i32, bool)> = doc
+ .form
+ .iter()
+ .filter_map(|f| live.shown.get(&f.name).map(|d| (f, on_screen(*d))))
+ .flat_map(|(f, on)| f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, on))))
+ .collect();
+ let appearances: Vec<(i32, i32, i32, i32)> = doc
+ .form
+ .iter()
+ .filter_map(|f| {
+ let id = live.icons.get(&f.name)?;
+ // No icon: drawn with an object that isn't a form, so nothing (an empty button).
+ let (num, generation) = id.map_or((0, 0), |id| ((id >> 16) as i32, (id & 0xffff) as i32));
+ Some(f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, num, generation))))
+ })
+ .flatten()
+ .collect();
+ pdfcraft_render::RenderConfig {
+ annotations: std::sync::Arc::new(annotations),
+ appearances: std::sync::Arc::new(appearances),
+ ..crate::display_config(&doc.config)
+ }
+}
+
+/// Draw the viewer with how fields show now.
+fn use_live_view(doc: &mut Document) {
+ doc.renderer = pdfcraft_render::RenderPool::new(doc.display.clone(), crate::render_threads(), screen_config(doc));
+}
+
+/// Turn layers on or off as a script set them (`ocg.state`), as the Layers panel does: how the
+/// document is shown, not the document. Returns whether any changed.
+pub(crate) fn set_layers(doc: &mut Document, layers: &[(usize, bool)]) -> bool {
+ let mut changed = false;
+ for &(i, on) in layers {
+ if let Some(l) = doc.info.layers.get_mut(i)
+ && l.visible != on
+ {
+ l.visible = on;
+ changed = true;
+ }
+ }
+ if changed {
+ crate::layers::use_layer_choices(doc);
+ }
+ changed
+}
+
+/// The `display` and icon changes in `o`, against the fields as they were (`states`), taken out
+/// of it: shown in the view instead of written into the document.
+fn take_view_changes(states: &[pdfcraft_js::FieldState], o: &mut Outcome) -> (Vec<(String, i32)>, Vec<(String, Option)>) {
+ let mut shown = Vec::new();
+ let mut icons = Vec::new();
+ for a in &mut o.changed {
+ let b = match a.object_id {
+ Some(id) => states.iter().find(|s| s.object_id == Some(id)),
+ None => states.iter().find(|s| s.name == a.name),
+ };
+ let Some(b) = b else { continue };
+ if a.display != b.display {
+ shown.push((a.name.clone(), a.display));
+ a.display = b.display;
+ }
+ if a.icon_written && a.icon != b.icon {
+ icons.push((a.name.clone(), a.icon));
+ }
+ a.icon = b.icon;
+ a.icon_written = false;
+ }
+ (shown, icons)
+}
+
+impl Session {
+ /// After any script of document `id` ran (a button, the console, a page action, a timer):
+ /// keep the timers its realm has, turn layers as it asked, and let a `display` written
+ /// into the document win over how page actions and timers showed that field.
+ pub(crate) fn after_script(&mut self, id: DocId, o: &Outcome, written: &[String]) {
+ let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return };
+ let runtime = doc.js_runtime.clone();
+ doc.live.schedule(&runtime, &o.timers);
+ let before = doc.live.shown.len();
+ let icons = doc.live.icons.len();
+ doc.live.shown.retain(|name, _| !written.contains(name));
+ doc.live.icons.retain(|name, _| !written.contains(name));
+ if doc.live.shown.len() != before || doc.live.icons.len() != icons {
+ use_live_view(doc);
+ }
+ set_layers(doc, &o.layers);
+ }
+
+ /// Run `script` (or timer `timer`) of document `id` in its realm, as a page action or timer
+ /// runs: with the fields as the view shows them. Returns the fields as the script saw them.
+ fn run_live(
+ &mut self,
+ id: DocId,
+ script: &str,
+ event: pdfcraft_js::Event,
+ timer: Option,
+ ) -> Result<(std::sync::Arc>, Outcome), EditError> {
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
+ let mut runner = crate::js::JsRunner::new(&cos, &doc.name, self.field_highlight);
+ runner.doc.zoom = doc.js_zoom;
+ runner.doc.page = doc.live.page.unwrap_or(doc.js_page);
+ runner.doc.layers = js_layers(doc);
+ let fields = pdfcraft_forms::fields(&cos);
+ let states: Vec<_> = fields.iter().map(|f| live_state(doc, &cos, f, &runner.doc)).collect();
+ let states = std::sync::Arc::new(states);
+ let job = pdfcraft_js::session::Job {
+ script: script.into(),
+ event,
+ doc: runner.doc,
+ fields: states.clone(),
+ doc_scripts: runner.doc_scripts,
+ calculations: calculation_scripts(&cos, &fields),
+ timer,
+ };
+ let o = doc.js_runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, &[]);
+ Ok((states, o))
+ }
+
+ /// Run one page action or timer of document `id` and apply what it did. Fields it showed,
+ /// hid or gave another icon change only how the document is shown; a value it changed, or
+ /// a reset, changes the document (one undoable step). A timer runs unasked and again and
+ /// again, so what it asks of the viewer (alerts, printing, navigation, links) goes to the
+ /// console instead.
+ fn live_step(&mut self, id: DocId, script: &str, event: pdfcraft_js::Event, timer: Option, run: &mut ScriptRun) {
+ let (states, mut o) = match self.run_live(id, script, event, timer) {
+ Ok(r) => r,
+ Err(e) => return run.output.errors.push(e.to_string()),
+ };
+ let discard = o.execution_refused && !o.realm_untouched;
+ let (shown, icons) = take_view_changes(&states, &mut o);
+ if timer.is_some() {
+ for a in std::mem::take(&mut o.alerts) {
+ o.console.push(format!("A timer's alert: {}", a.text));
+ }
+ for r in std::mem::take(&mut o.requests) {
+ if !matches!(r, Request::Reset(_)) {
+ o.console.push(format!("A timer asked for {r:?}; timers may only change fields"));
+ } else {
+ o.requests.push(r);
+ }
+ }
+ }
+ if !o.execution_refused
+ && let Some(doc) = self.docs.iter_mut().find(|d| d.id == id)
+ && !(shown.is_empty() && icons.is_empty())
+ {
+ for name in shown.iter().map(|(n, _)| n).chain(icons.iter().map(|(n, _)| n)) {
+ for p in doc.form.iter().filter(|f| f.name == *name).flat_map(|f| f.widgets.iter().filter_map(|w| w.page)) {
+ if !run.pages.contains(&p) {
+ run.pages.push(p);
+ }
+ }
+ }
+ doc.live.shown.extend(shown);
+ doc.live.icons.extend(icons);
+ use_live_view(doc);
+ }
+ let generation = self.get(id).map(Document::edit_generation);
+ let layers = |s: &Session| s.get(id).map(|d| d.info.layers.iter().map(|l| l.visible).collect::>());
+ let layers_before = layers(self);
+ match self.apply_script_outcome(id, &states, o, false) {
+ Ok(o) => run.output.absorb(&o),
+ Err(e) => run.output.errors.push(e.to_string()),
+ }
+ run.edited |= self.get(id).map(Document::edit_generation) != generation;
+ run.layers |= layers(self) != layers_before;
+ if discard {
+ self.invalidate_javascript(id);
+ }
+ }
+
+ /// Document `id` is read at page `page` (0-based), at `now_ms` on the caller's clock (on
+ /// which its timers fall due). When the page changed, the close actions of the page it was
+ /// at run, then `page`'s open actions (the page's own `/AA /O`, then each annotation's
+ /// page-open and page-visible actions, such as an animation's widget). Cheap when nothing
+ /// changed; call it as often as the page can change. With JavaScript off nothing runs.
+ pub fn show_page(&mut self, id: DocId, page: usize, now_ms: f64) -> ScriptRun {
+ let mut run = ScriptRun::default();
+ let js_off = self.js_off;
+ let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return run };
+ doc.live.clock = now_ms;
+ if js_off || doc.live.page == Some(page) || doc.xfa_template.is_some() {
+ return run;
+ }
+ let before = doc.live.page.replace(page);
+ let Some(cos) = doc.editor.as_ref().map(|e| e.cos.clone()) else { return run };
+ use pdfcraft_forms::PageEvent;
+ let mut scripts: Vec<(&str, pdfcraft_forms::PageScript)> = Vec::new();
+ if let Some(p) = before {
+ scripts.extend(pdfcraft_forms::page_scripts(&cos, p, PageEvent::Close).into_iter().map(|s| ("Close", s)));
+ }
+ scripts.extend(pdfcraft_forms::page_scripts(&cos, page, PageEvent::Open).into_iter().map(|s| ("Open", s)));
+ for (name, s) in scripts {
+ let event = pdfcraft_js::Event { kind: "Page".into(), target: s.field, ..pdfcraft_js::Event::doc(name) };
+ self.live_step(id, &s.script, event, None, &mut run);
+ }
+ run
+ }
+
+ /// When document `id`'s next timer falls due, on the clock [`Session::show_page`] and
+ /// [`Session::run_timers`] are given; `None` without timers (or with JavaScript off).
+ pub fn next_timer(&mut self, id: DocId) -> Option {
+ if self.js_off {
+ return None;
+ }
+ let doc = self.docs.iter_mut().find(|d| d.id == id)?;
+ let runtime = doc.js_runtime.clone();
+ doc.live.timers(&runtime).iter().map(|d| d.at).min_by(f64::total_cmp)
+ }
+
+ /// Run document `id`'s timers that are due at `now_ms`, earliest first, at most
+ /// [`MAX_TIMER_RUNS`] of them. An interval that fell behind runs once and then counts its
+ /// period from now, rather than catching up run by run.
+ pub fn run_timers(&mut self, id: DocId, now_ms: f64) -> ScriptRun {
+ let mut run = ScriptRun::default();
+ for _ in 0..MAX_TIMER_RUNS {
+ if self.js_off {
+ break;
+ }
+ let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { break };
+ doc.live.clock = now_ms;
+ let runtime = doc.js_runtime.clone();
+ let timers = doc.live.timers(&runtime);
+ let Some(i) = timers.iter().enumerate().filter(|(_, d)| d.at <= now_ms).min_by(|a, b| a.1.at.total_cmp(&b.1.at)).map(|(i, _)| i) else {
+ break;
+ };
+ let due = timers[i];
+ if due.repeat {
+ let next = due.at + f64::from(due.period_ms);
+ timers[i].at = if next <= now_ms { now_ms + f64::from(due.period_ms) } else { next };
+ } else {
+ timers.remove(i);
+ }
+ run.ran += 1;
+ let event = pdfcraft_js::Event { kind: "App".into(), ..pdfcraft_js::Event::doc("Timer") };
+ self.live_step(id, "", event, Some(due.id), &mut run);
+ }
+ run
+ }
+
+ /// The clock of document `id`'s live scripts: the time it was last given (milliseconds).
+ pub fn script_clock(&self, id: DocId) -> Option {
+ Some(self.get(id)?.live.clock)
+ }
+}
+
+#[cfg(test)]
+#[path = "live_scripts_tests.rs"]
+mod tests;
diff --git a/crates/engine/src/live_scripts_tests.rs b/crates/engine/src/live_scripts_tests.rs
new file mode 100644
index 0000000000..c902b22562
--- /dev/null
+++ b/crates/engine/src/live_scripts_tests.rs
@@ -0,0 +1,221 @@
+use std::sync::Arc;
+
+use super::screen_config;
+use crate::{DocId, Session};
+
+/// A PDF of `objs` (object 1 the catalog), with its cross-reference table.
+fn pdf(objs: &[String]) -> Vec {
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// A form XObject filling 100 × 100 with colour `rgb`.
+fn fill(rgb: &str) -> String {
+ let body = format!("{rgb} rg 0 0 100 100 re f");
+ format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{body}\nendstream", body.len())
+}
+
+/// A push button widget on page 3 0 R at [50 100 150 200].
+fn widget(name: &str, flags: u32, extra: &str) -> String {
+ format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T ({name}) /F {flags} /Rect [50 100 150 200] /P 3 0 R {extra} >>")
+}
+
+/// Two pages; on the first, an animation as the LaTeX `animate` package builds one: three
+/// frames as push buttons over one another (red showing, green and blue hidden), and a widget
+/// whose page-open action defines the player and starts an interval and whose page-close
+/// action stops it.
+fn animation_pdf() -> Vec {
+ let open = "if (typeof on == 'undefined') { var on = 0; var t = null; } \
+ function show(i) { getField('f' + on).display = display.hidden; on = i; getField('f' + on).display = display.visible; } \
+ function step() { show((on + 1) % 3); } t = app.setInterval('step()', 100);";
+ pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] >> >>".into(), // 1
+ "<< /Type /Pages /Kids [3 0 R 12 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), // 2
+ "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".into(), // 3
+ widget("f0", 4, "/AP << /N 8 0 R >>"), // 4
+ widget("f1", 6, "/AP << /N 9 0 R >>"), // 5
+ widget("f2", 6, "/AP << /N 10 0 R >>"), // 6
+ widget("anim", 4, "/AA << /PO << /S /JavaScript /JS 11 0 R >> /PC << /S /JavaScript /JS (app.clearInterval\\(t\\);) >> >>"), // 7
+ fill("1 0 0"), // 8
+ fill("0 1 0"), // 9
+ fill("0 0 1"), // 10
+ format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), // 11
+ "<< /Type /Page /Parent 2 0 R >>".into(), // 12
+ ])
+}
+
+const RED: [u8; 4] = [255, 0, 0, 255];
+const GREEN: [u8; 4] = [0, 255, 0, 255];
+const BLUE: [u8; 4] = [0, 0, 255, 255];
+
+/// RGBA at PDF point (x, y) on `page` as the viewer draws it (1 px/pt, page height 300).
+fn shown(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] {
+ let doc = s.get(id).unwrap();
+ let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), screen_config(doc));
+ let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
+ assert!(out.error.is_none(), "{:?}", out.error);
+ let i = (((300 - y) * out.width + x) * 4) as usize;
+ out.rgba[i..i + 4].try_into().unwrap()
+}
+
+/// RGBA at (x, y) on `page` as it prints or exports: the document itself.
+fn printed(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] {
+ let doc = s.get(id).unwrap();
+ let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
+ let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
+ let i = (((300 - y) * out.width + x) * 4) as usize;
+ out.rgba[i..i + 4].try_into().unwrap()
+}
+
+#[test]
+fn page_actions_start_timers_that_turn_an_animations_frames_without_changing_the_document() {
+ let mut s = Session::new();
+ let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
+ let bytes = s.get(id).unwrap().bytes.clone();
+ assert_eq!(shown(&s, id, 0, 100, 150), RED);
+ assert_eq!(s.next_timer(id), None);
+ let run = s.show_page(id, 0, 1000.0);
+ assert!(run.output.errors.is_empty(), "{run:?}");
+ assert_eq!(s.next_timer(id), Some(1100.0));
+ assert!(s.show_page(id, 0, 1010.0).is_empty(), "the same page again runs nothing");
+ assert!(s.run_timers(id, 1050.0).is_empty(), "not due yet");
+ let run = s.run_timers(id, 1100.0);
+ assert_eq!((run.pages.as_slice(), run.edited, run.ran), ([0].as_slice(), false, 1), "{run:?}");
+ assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
+ assert_eq!(printed(&s, id, 0, 100, 150), RED, "printing and export draw the document");
+ assert_eq!(s.next_timer(id), Some(1200.0));
+ let doc = s.get(id).unwrap();
+ assert!(Arc::ptr_eq(&doc.bytes, &bytes) && !doc.dirty && doc.undo_depth() == 0, "the frames turn in the view, not in the document");
+ // An interval that fell behind runs once and counts on from now.
+ assert_eq!(s.run_timers(id, 1450.0).ran, 1);
+ assert_eq!(shown(&s, id, 0, 100, 150), BLUE);
+ assert_eq!(s.next_timer(id), Some(1550.0));
+ // A button runs in the same realm: the page action's functions and state are there.
+ let o = s.run_javascript(id, "step();", Some("f0")).unwrap();
+ assert_eq!(o.error, None);
+ let o = s.run_javascript(id, "on", None).unwrap();
+ assert_eq!(o.result.as_deref(), Some("0"));
+ // Leaving the page runs its close action, which clears the interval.
+ s.show_page(id, 1, 1500.0);
+ assert_eq!(s.next_timer(id), None);
+ // With JavaScript off, page actions don't run.
+ s.set_javascript(false);
+ assert!(s.show_page(id, 0, 1600.0).is_empty());
+ assert_eq!(s.next_timer(id), None);
+}
+
+#[test]
+fn a_button_that_shows_a_frame_changes_the_document_and_the_views_frame_gives_way() {
+ let mut s = Session::new();
+ let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap();
+ s.show_page(id, 0, 0.0);
+ s.run_timers(id, 100.0);
+ assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
+ s.run_javascript(id, "step();", Some("f0")).unwrap();
+ assert_eq!(shown(&s, id, 0, 100, 150), BLUE);
+ assert!(s.get(id).unwrap().dirty, "a button's change is the document's");
+ assert_eq!(printed(&s, id, 0, 100, 150), BLUE);
+ s.run_timers(id, 200.0);
+ assert_eq!(shown(&s, id, 0, 100, 150), RED);
+}
+
+#[test]
+fn a_timer_runs_unasked_so_its_alerts_and_requests_go_to_the_console() {
+ let open = "app.setTimeOut('app.alert(\"hi\"); this.print(); app.launchURL(\"https://example.com\");', 50);";
+ let mut s = Session::new();
+ let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap();
+ assert!(s.show_page(id, 0, 0.0).output.errors.is_empty());
+ let run = s.run_timers(id, 50.0);
+ assert_eq!(run.ran, 1);
+ assert!(run.output.alerts.is_empty() && run.output.requests.is_empty(), "{run:?}");
+ assert!(run.output.console.iter().any(|l| l.contains("hi")), "{run:?}");
+ assert_eq!(s.next_timer(id), None, "a time-out runs once");
+}
+
+#[test]
+fn a_fresh_realm_has_no_timers_and_a_timer_writing_a_value_is_one_undoable_edit() {
+ let open = "app.setInterval('getField(\"f0\").value = \"x\";', 10);";
+ let mut s = Session::new();
+ let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap();
+ s.show_page(id, 0, 0.0);
+ let run = s.run_timers(id, 10.0);
+ assert!(run.edited && run.output.errors.is_empty(), "{run:?}");
+ assert_eq!(s.get(id).unwrap().undo_depth(), 1);
+ assert!(s.next_timer(id).is_some());
+ s.invalidate_javascript(id);
+ assert_eq!(s.next_timer(id), None, "the timers went with the realm");
+}
+
+/// One page with a text field f0 whose page-open action is `open`.
+fn timer_pdf(open: &str) -> Vec {
+ pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /AA << /O 5 0 R >> >>".into(),
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f0) /Rect [50 100 150 200] /P 3 0 R >>".into(),
+ "<< /S /JavaScript /JS 6 0 R >>".into(),
+ format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
+ ])
+}
+
+/// An animation as `animate` builds one by default (method=icon): hidden frames are buttons
+/// holding the icons, and the player gives the one showing button a frame's icon.
+#[test]
+fn a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place() {
+ let open = "if (typeof on == 'undefined') { var on = 0; var fr = [getField('f0').buttonGetIcon(), getField('f1').buttonGetIcon()]; } \
+ app.setInterval('on = 1 - on; getField(\"anim\").buttonSetIcon(fr[on]);', 100);";
+ let out = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(),
+ widget("anim", 4, "/AP << /N 7 0 R >> /MK << /I 7 0 R >> /AA << /PO << /S /JavaScript /JS 9 0 R >> >>"),
+ widget("f0", 6, "/AP << /N 7 0 R >> /MK << /I 7 0 R >>"),
+ widget("f1", 6, "/AP << /N 8 0 R >> /MK << /I 8 0 R >>"),
+ fill("1 0 0"),
+ fill("0 1 0"),
+ format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
+ ]);
+ let mut s = Session::new();
+ let id = s.open("icons.pdf", None, Arc::new(out), None).unwrap();
+ assert_eq!(shown(&s, id, 0, 100, 150), RED);
+ assert!(s.show_page(id, 0, 0.0).output.errors.is_empty());
+ let run = s.run_timers(id, 100.0);
+ assert_eq!((run.pages.as_slice(), run.output.errors.len()), ([0].as_slice(), 0), "{run:?}");
+ assert_eq!(shown(&s, id, 0, 100, 150), GREEN);
+ assert_eq!(printed(&s, id, 0, 100, 150), RED);
+ s.run_timers(id, 200.0);
+ assert_eq!(shown(&s, id, 0, 100, 150), RED, "its own icon again");
+ assert!(!s.get(id).unwrap().dirty);
+}
+
+#[test]
+fn get_ocgs_turns_a_layer_in_the_view() {
+ let open = "this.getOCGs()[0].state = false;";
+ let out = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> /AA << /O << /S /JavaScript /JS 6 0 R >> >> >>".into(),
+ "<< /Length 41 >>\nstream\n/OC /L1 BDC 1 0 0 rg 0 0 200 300 re f EMC\nendstream".into(),
+ "<< /Type /OCG /Name (Red) >>".into(),
+ format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()),
+ ]);
+ let mut s = Session::new();
+ let id = s.open("layers.pdf", None, Arc::new(out), None).unwrap();
+ assert_eq!(shown(&s, id, 0, 100, 150), RED);
+ let run = s.show_page(id, 0, 0.0);
+ assert!(run.layers && run.output.errors.is_empty(), "{run:?}");
+ assert!(!s.get(id).unwrap().info.layers[0].visible);
+ assert_eq!(shown(&s, id, 0, 100, 150), [255, 255, 255, 255]);
+ assert!(!s.get(id).unwrap().dirty);
+}
diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs
index 8fd5cd992e..8897beca49 100644
--- a/crates/engine/src/open.rs
+++ b/crates/engine/src/open.rs
@@ -413,6 +413,7 @@ impl Session {
js_runtime: Default::default(),
js_zoom: 100.0,
js_page: 0,
+ live: Default::default(),
xfa,
xfa_template: None,
xfa_scripts_off: false,