mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 00:18:34 +00:00
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 <p.yatsyshin@googlemail.com>
This commit is contained in:
co-authored by
Peter Yatsyshin
parent
85020ab05f
commit
17ae6ec9cd
@@ -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<XfaLayout>,
|
||||
|
||||
+11
-5
@@ -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::<Vec<_>>());
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
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<Outcome, EditError> {
|
||||
pub(crate) fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result<Outcome, EditError> {
|
||||
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<String> = 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::<Vec<_>>());
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let job = pdfcraft_js::session::Job {
|
||||
script: script.into(),
|
||||
event: target.map_or_else(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<usize>,
|
||||
timers: Vec<Due>,
|
||||
/// The realm the timers were set in: a fresh realm (an edit's reset, Revert, a full
|
||||
/// save) starts without them.
|
||||
timers_of: Option<std::sync::Weak<std::sync::Mutex<pdfcraft_js::session::ScriptSession>>>,
|
||||
/// `display` page actions and timers gave fields, by name, over the document's own flags.
|
||||
shown: BTreeMap<String, i32>,
|
||||
/// 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<String, Option<u64>>,
|
||||
/// 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<Due> {
|
||||
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<usize>,
|
||||
/// 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<pdfcraft_js::Layer> {
|
||||
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<u64>)>) {
|
||||
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<u32>,
|
||||
) -> Result<(std::sync::Arc<Vec<pdfcraft_js::FieldState>>, 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<u32>, 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::<Vec<_>>());
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
Some(self.get(id)?.live.clock)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "live_scripts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user