mod af;
mod bind;
mod color;
mod consts;
mod doc;
pub(crate) mod event;
mod field;
mod global;
mod host;
pub mod model;
mod submit;
mod timer;
pub mod transcript;
pub mod zone;
use std::rc::Rc;
use boa_engine::context::Context;
use pdfrum_common::{Deadline, Diagnostics, Limits};
use crate::cascade::{Cascade, FieldRef, FieldWrites, Keystroke, KeystrokeOutcome};
use event::EventState;
pub use model::{AnnotModel, DocumentModel, FieldModel, FieldModelFlags, FieldModelKind};
pub use transcript::TranscriptLine;
pub const GOLDEN_CLOCK_SECS: u64 = 1_399_672_130;
pub const GOLDEN_TIMEZONE: zone::Zone = zone::Zone::LOS_ANGELES;
pub const GOLDEN_PRINTD_OFFSET_SECS: i32 = -8 * 3600;
pub const GOLDEN_FILE_PATH: &str = "myfile.pdf";
#[derive(Debug, Clone, Default)]
pub struct ScriptConfig {
pub limits: Limits,
pub clock_ms: Option<i64>,
pub timezone: zone::Zone,
pub printd_offset_secs: i32,
}
impl ScriptConfig {
#[must_use]
pub fn frozen_at(seconds: u64) -> ScriptConfig {
let millis = i64::try_from(seconds)
.unwrap_or(i64::MAX)
.saturating_mul(1000);
ScriptConfig {
limits: Limits::default(),
clock_ms: Some(millis),
timezone: GOLDEN_TIMEZONE,
printd_offset_secs: GOLDEN_PRINTD_OFFSET_SECS,
}
}
#[must_use]
pub fn wall_clock() -> ScriptConfig {
ScriptConfig {
clock_ms: None,
..ScriptConfig::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptStop {
LimitReached,
Threw(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptFailure {
pub whence: String,
pub stop: ScriptStop,
}
impl ScriptFailure {
#[must_use]
pub fn line(&self) -> String {
let whence = if self.whence.is_empty() {
"/OpenAction"
} else {
&self.whence
};
match &self.stop {
ScriptStop::LimitReached => {
format!("script {whence}: stopped by a sandbox limit")
}
ScriptStop::Threw(message) => {
let first = message.lines().next().unwrap_or("").trim_end();
format!("script {whence}: {first}")
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FieldActions {
pub keystroke: Option<String>,
pub validate: Option<String>,
pub calculate: Option<String>,
pub format: Option<String>,
pub mouse_enter: Option<String>,
pub mouse_exit: Option<String>,
pub mouse_down: Option<String>,
pub mouse_up: Option<String>,
pub focus: Option<String>,
pub blur: Option<String>,
}
pub struct ScriptCascade {
context: Context,
host: host::Host,
actions: std::collections::BTreeMap<u32, FieldActions>,
names: std::collections::BTreeMap<u32, String>,
values: std::collections::BTreeMap<u32, String>,
order: Vec<u32>,
stops: Vec<ScriptFailure>,
max_calculate_depth: u32,
busy: bool,
deadline: Option<Deadline>,
}
impl std::fmt::Debug for ScriptCascade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScriptCascade")
.field("transcript", &self.transcript().len())
.field("stops", &self.stops)
.field("busy", &self.busy)
.finish_non_exhaustive()
}
}
#[derive(Debug, thiserror::Error)]
#[error("the script engine could not build a realm: {message}")]
pub struct BuildError {
message: String,
}
impl ScriptCascade {
pub fn new(config: &ScriptConfig) -> Result<ScriptCascade, BuildError> {
let mut builder = Context::builder();
if let Some(millis) = config.clock_ms {
let millis = u64::try_from(millis).unwrap_or(0);
builder = builder
.host_hooks(Rc::new(host::ConfiguredZone {
zone: config.timezone,
}))
.clock(Rc::new(boa_engine::context::time::FixedClock::from_millis(
millis,
)));
}
let mut context = builder.build().map_err(|error| BuildError {
message: error.to_string(),
})?;
let mut runtime_limits = context.runtime_limits();
runtime_limits.set_loop_iteration_limit(config.limits.max_script_loop_iterations);
runtime_limits.set_recursion_limit(config.limits.max_script_recursion);
runtime_limits.set_stack_size_limit(config.limits.max_script_stack);
context.set_runtime_limits(runtime_limits);
context.insert_data(bind::PrintdOffset(config.printd_offset_secs));
let host = bind::new_host();
bind::install(&mut context, Rc::clone(&host)).map_err(|error| BuildError {
message: error.to_string(),
})?;
Ok(ScriptCascade {
context,
host,
actions: std::collections::BTreeMap::new(),
names: std::collections::BTreeMap::new(),
values: std::collections::BTreeMap::new(),
order: Vec::new(),
stops: Vec::new(),
max_calculate_depth: config.limits.max_calculate_depth,
busy: false,
deadline: config.limits.deadline.clone(),
})
}
#[must_use]
pub fn transcript(&self) -> Vec<TranscriptLine> {
self.host.borrow().transcript.clone()
}
#[must_use]
pub fn timers(&self) -> Vec<(String, i32)> {
self.host.borrow().timers.listed()
}
pub fn advance_time(&mut self, elapsed: std::time::Duration) -> usize {
let millis = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let due = self.host.borrow_mut().timers.advance(millis);
let mut ran = 0;
for (id, script) in due {
self.host.borrow_mut().timers.begin(id);
self.host.borrow_mut().event = EventState::initialize(event::EventKind::Unknown);
self.run(&script, "app.setTimeOut");
self.host.borrow_mut().timers.end(id);
ran += 1;
}
ran
}
pub fn fail_next_timer(&mut self) {
self.host.borrow_mut().timers.fail_next();
}
pub fn record_named_action(&mut self, name: impl Into<String>) {
self.host
.borrow_mut()
.transcript
.push(TranscriptLine::NamedAction(name.into()));
}
#[must_use]
pub fn transcript_text(&self) -> String {
transcript::render(&self.transcript())
}
#[must_use]
pub fn stops(&self) -> &[ScriptFailure] {
&self.stops
}
pub fn run(&mut self, source: &str, whence: &str) -> bool {
if self.busy {
self.stops.push(ScriptFailure {
whence: whence.to_string(),
stop: ScriptStop::Threw("System is busy.".to_string()),
});
return false;
}
if self.deadline.as_ref().is_some_and(Deadline::passed) {
self.stops.push(ScriptFailure {
whence: whence.to_string(),
stop: ScriptStop::LimitReached,
});
return false;
}
self.busy = true;
let result = self
.context
.eval(boa_engine::Source::from_bytes(source.as_bytes()));
self.busy = false;
match result {
Ok(_) => true,
Err(error) => {
let message = error.to_string();
let stop = if message.contains("RuntimeLimit") {
ScriptStop::LimitReached
} else {
ScriptStop::Threw(message)
};
self.stops.push(ScriptFailure {
whence: whence.to_string(),
stop,
});
false
}
}
}
#[must_use]
pub fn last_stop_was_a_limit(&self) -> bool {
matches!(
self.stops.last().map(|failure| &failure.stop),
Some(ScriptStop::LimitReached)
)
}
fn run_event(&mut self, source: &str, live: EventState, whence: &str) -> bool {
self.host.borrow_mut().event = live;
self.run(source, whence)
}
fn script_for(&self, field: &FieldRef, trigger: Trigger) -> Option<String> {
let actions = self.actions.get(&field.index?)?;
match trigger {
Trigger::Keystroke => actions.keystroke.clone(),
Trigger::Validate => actions.validate.clone(),
Trigger::Format => actions.format.clone(),
Trigger::Pointer(kind) => match kind {
event::EventKind::MouseEnter => actions.mouse_enter.clone(),
event::EventKind::MouseExit => actions.mouse_exit.clone(),
event::EventKind::MouseDown => actions.mouse_down.clone(),
event::EventKind::MouseUp => actions.mouse_up.clone(),
event::EventKind::Focus => actions.focus.clone(),
event::EventKind::Blur => actions.blur.clone(),
_ => None,
},
}
}
pub fn format_on_load(&mut self, field: &FieldRef) -> bool {
let Some(source) = self.script_for(field, Trigger::Format) else {
return true;
};
let mut live = EventState::initialize(event::EventKind::Format);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.has_value = true;
live.value = self
.values
.get(&field.index.unwrap_or(u32::MAX))
.cloned()
.unwrap_or_default();
live.will_commit = true;
live.commit_key = 0;
self.run_event(&source, live, &field.name)
}
pub fn set_field(
&mut self,
index: u32,
name: impl Into<String>,
value: impl Into<String>,
actions: FieldActions,
) {
self.names.insert(index, name.into());
self.values.insert(index, value.into());
self.actions.insert(index, actions);
}
pub fn set_document(&mut self, document: model::DocumentModel) {
self.host.borrow_mut().document = document;
}
pub fn drain_field_writes(&mut self) -> Vec<(u32, String)> {
std::mem::take(&mut self.host.borrow_mut().field_writes)
}
#[expect(
dead_code,
reason = "missed wire: calculateNow is not yet driven from the host"
)]
pub(crate) fn take_calculate_request(&mut self) -> bool {
std::mem::take(&mut self.host.borrow_mut().calculate_requested)
}
#[must_use]
pub fn field_value(&self, index: u32) -> Option<String> {
let host = self.host.borrow();
host.document
.field_at(usize::try_from(index).ok()?)
.map(|field| field.value.clone())
}
pub fn set_field_value(&mut self, index: u32, value: impl Into<String>) {
let value = value.into();
let mut host = self.host.borrow_mut();
if let Ok(index) = usize::try_from(index)
&& let Some(field) = host.document.fields.get_mut(index)
{
field.value.clone_from(&value);
}
drop(host);
self.values.insert(index, value);
}
pub fn set_calculation_order(&mut self, order: Vec<u32>) {
self.order = order;
}
#[must_use]
pub fn max_calculate_depth(&self) -> u32 {
self.max_calculate_depth
}
pub fn drain_diagnostics(&mut self, diags: &mut Diagnostics) -> Vec<ScriptFailure> {
let failures: Vec<ScriptFailure> = std::mem::take(&mut self.stops);
for failure in &failures {
diags.record(
pdfrum_common::Severity::Suspicious,
match failure.stop {
ScriptStop::LimitReached => pdfrum_common::DiagKind::ScriptLimitReached,
ScriptStop::Threw(_) => pdfrum_common::DiagKind::ScriptFailed,
},
None,
);
}
failures
}
fn event_rc(&self) -> bool {
self.host.borrow().event.rc
}
fn event_value(&self) -> String {
self.host.borrow().event.value.clone()
}
fn event_change(&self) -> String {
self.host.borrow().event.change.clone()
}
fn event_selection(&self) -> (i32, i32) {
let host = self.host.borrow();
(host.event.sel_start, host.event.sel_end)
}
fn run_pointer_trigger(
&mut self,
field: &FieldRef,
kind: event::EventKind,
pointer: PointerModifiers,
) -> bool {
let Some(source) = self.script_for(field, Trigger::Pointer(kind)) else {
return true;
};
let mut live = EventState::initialize(kind);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.modifier = pointer.modifier;
live.shift = pointer.shift;
if matches!(kind, event::EventKind::Focus | event::EventKind::Blur) {
live.has_value = true;
live.value = self
.values
.get(&field.index.unwrap_or(u32::MAX))
.cloned()
.unwrap_or_default();
}
self.run_event(&source, live, &field.name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trigger {
Keystroke,
Validate,
Format,
Pointer(event::EventKind),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct PointerModifiers {
pub modifier: bool,
pub shift: bool,
}
impl Cascade for ScriptCascade {
fn keystroke(&mut self, field: &FieldRef, change: Keystroke) -> KeystrokeOutcome {
let Some(source) = self.script_for(field, Trigger::Keystroke) else {
return KeystrokeOutcome::Accept(change);
};
let mut live = EventState::initialize(event::EventKind::Keystroke);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.has_value = true;
live.value.clone_from(&change.value);
live.change.clone_from(&change.change);
live.sel_start = change.selection_start;
live.sel_end = change.selection_end;
live.commit_key = 0;
if !self.run_event(&source, live, &field.name) {
return KeystrokeOutcome::Reject;
}
if !self.event_rc() {
return KeystrokeOutcome::Reject;
}
let (selection_start, selection_end) = self.event_selection();
KeystrokeOutcome::Accept(Keystroke {
change: self.event_change(),
value: change.value,
selection_start,
selection_end,
})
}
fn keystroke_commit(&mut self, field: &FieldRef, value: &str) -> bool {
let Some(source) = self.script_for(field, Trigger::Keystroke) else {
return true;
};
let mut live = EventState::initialize(event::EventKind::Keystroke);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.has_value = true;
live.value = value.to_string();
live.will_commit = true;
live.commit_key = 0;
self.run_event(&source, live, &field.name) && self.event_rc()
}
fn validate(&mut self, field: &FieldRef, value: &str) -> bool {
let Some(source) = self.script_for(field, Trigger::Validate) else {
return true;
};
let mut live = EventState::initialize(event::EventKind::Validate);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.has_value = true;
live.value = value.to_string();
self.run_event(&source, live, &field.name) && self.event_rc()
}
fn calculate(&mut self, writes: &mut FieldWrites, trigger: &FieldRef) {
if !writes.enter() {
return;
}
let order: Vec<u32> = self.order.clone();
for index in order {
let Some(source) = self
.actions
.get(&index)
.and_then(|actions| actions.calculate.clone())
else {
continue;
};
let before = self.values.get(&index).cloned().unwrap_or_default();
let mut live = EventState::initialize(event::EventKind::Calculate);
live.target_name = self.names.get(&index).cloned().unwrap_or_default();
live.target_index = Some(index);
live.has_value = true;
live.value.clone_from(&before);
live.source_name.clone_from(&trigger.name);
live.source_index = trigger.index;
let whence = live.target_name.clone();
if !self.run_event(&source, live, &whence) {
continue;
}
if !self.event_rc() {
continue;
}
let after = self.event_value();
if after == before {
continue;
}
self.values.insert(index, after.clone());
writes.set(index, after);
}
writes.leave();
}
fn pointer(
&mut self,
field: &FieldRef,
trigger: crate::cascade::PointerTrigger,
held: crate::Modifiers,
) {
use crate::cascade::PointerTrigger;
let kind = match trigger {
PointerTrigger::Enter => event::EventKind::MouseEnter,
PointerTrigger::Exit => event::EventKind::MouseExit,
PointerTrigger::Down => event::EventKind::MouseDown,
PointerTrigger::Up => event::EventKind::MouseUp,
PointerTrigger::Focus => event::EventKind::Focus,
PointerTrigger::Blur => event::EventKind::Blur,
};
let pointer = PointerModifiers {
modifier: held.contains(crate::Modifiers::CONTROL),
shift: held.contains(crate::Modifiers::SHIFT),
};
self.run_pointer_trigger(field, kind, pointer);
}
fn take_focus_request(&mut self) -> Option<u32> {
self.host.borrow_mut().focus_requested.take()
}
fn format(&mut self, field: &FieldRef, value: &str) -> Option<String> {
let source = self.script_for(field, Trigger::Format)?;
let mut live = EventState::initialize(event::EventKind::Format);
live.target_name.clone_from(&field.name);
live.target_index = field.index;
live.has_value = true;
live.value = value.to_string();
live.will_commit = true;
live.commit_key = 0;
if !self.run_event(&source, live, &field.name) {
return None;
}
let formatted = self.event_value();
(formatted != value).then_some(formatted)
}
}
#[cfg(test)]
mod tests;