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,