//! pdfcraft-js — Acrobat JavaScript for forms (L3). //! //! Field scripts (format, keystroke, validate, calculate), button actions and document-level //! scripts run in [boa](https://boajs.dev), a JavaScript engine written in Rust, with the subset //! of Acrobat's object model that forms use, as the public *JavaScript for Acrobat API Reference* //! describes it: //! //! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. //! - the document (`this`, also the global object): `getField`, `numFields`, //! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, //! `calculateNow`, `print`, `submitForm`, `mailDoc`. //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. //! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, //! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion //! are bounded. Side effects (alerts, field changes, resets, printing, navigation) are returned //! in an [`Outcome`] for the caller to apply. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldType { Text, CheckBox, RadioButton, ComboBox, ListBox, Button, Signature, } impl FieldType { pub fn name(self) -> &'static str { match self { FieldType::Text => "text", FieldType::CheckBox => "checkbox", FieldType::RadioButton => "radiobutton", FieldType::ComboBox => "combobox", FieldType::ListBox => "listbox", FieldType::Button => "button", FieldType::Signature => "signature", } } } /// `display.*`: visible, hidden, noPrint, noView. pub const DISPLAY_VISIBLE: i32 = 0; pub const DISPLAY_HIDDEN: i32 = 1; pub const DISPLAY_NO_PRINT: i32 = 2; pub const DISPLAY_NO_VIEW: i32 = 3; /// A field as scripts see it. #[derive(Clone, Debug, PartialEq)] pub struct FieldState { pub name: String, pub kind: FieldType, /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: /// the selected export values. pub value: Vec, pub default: Vec, pub readonly: bool, pub required: bool, pub display: i32, /// Choice options (export value, display text); check boxes and radios: their export /// values, one per widget. pub options: Vec<(String, String)>, pub char_limit: Option, /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. pub text_color: Option>, pub fill_color: Option>, } impl FieldState { pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { FieldState { name: name.into(), kind, value, default: Vec::new(), readonly: false, required: false, display: DISPLAY_VISIBLE, options: Vec::new(), char_limit: None, text_color: None, fill_color: None, } } fn single(&self) -> String { self.value.first().cloned().unwrap_or_default() } } /// The event a script runs for. #[derive(Clone, Debug, PartialEq)] pub struct Event { /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … pub name: String, /// `Field`, `Doc`, `Page`, … pub kind: String, /// The field the event is for. pub target: Option, pub value: String, pub change: String, pub will_commit: bool, } impl Event { /// A field event (`event.type` = "Field"). pub fn field(name: &str, target: &str, value: &str) -> Event { Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } } /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. pub fn doc(name: &str) -> Event { Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } } } /// About the document, for `this.numPages`, `this.info` and the like. #[derive(Clone, Debug, Default, PartialEq)] pub struct DocInfo { pub file_name: String, pub num_pages: usize, /// The current page, 0-based. pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, /// The document has an XFA form, so scripts see `xfa_installed`. pub xfa: bool, } /// Something a script asked the viewer to do. #[derive(Clone, Debug, PartialEq)] pub enum Request { /// `this.resetForm([names])` (empty: every field). Reset(Vec), Print, /// `this.pageNum = n` (0-based). GoToPage(usize), LaunchUrl(String), /// `this.submitForm(url)`: never sent on PdfCraft's own. Submit(String), /// `field.setFocus()`. Focus(String), Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. ShowPanel(Panel), } /// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Panel { /// The document's bookmarks (outline). Bookmarks, /// Page thumbnails. Pages, /// The files attached to the document. Attachments, /// The document's signatures and their validity. Signatures, /// The document's layers (optional content). Layers, } impl Panel { /// The panel's name in automation output, fixed whatever the variant is called. pub fn id(self) -> &'static str { match self { Panel::Bookmarks => "bookmarks", Panel::Pages => "pages", Panel::Attachments => "attachments", Panel::Signatures => "signatures", Panel::Layers => "layers", } } } /// What running a script did. #[derive(Clone, Debug, Default, PartialEq)] pub struct Outcome { /// `event.rc`: `false` rejects the keystroke or value. pub rc: bool, /// `event.value` after the script. pub value: String, pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, pub alerts: Vec, pub console: Vec, pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. pub error: Option, /// Errors in the document-level scripts that ran first. Each is a script of its own: one /// that throws stops where it failed, and the others and the script itself still run (#3940). pub doc_errors: Vec, /// The script's completion value (what the console prints), unless undefined. pub result: Option, } /// Bounds that keep a script from hanging the viewer. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Limits { pub loop_iterations: u64, pub recursion: usize, } impl Default for Limits { fn default() -> Self { Limits { loop_iterations: 1_000_000, recursion: 256 } } } struct Host { fields: Arc>, changed: BTreeSet, alerts: Vec, console: Vec, requests: Vec, doc: DocInfo, /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. kept: usize, /// Alerts, console lines and requests the limits left out. dropped: usize, } impl Host { /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. fn room(&mut self, bytes: usize) -> bool { match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { Some(k) => { self.kept = k; true } None => { self.dropped = self.dropped.saturating_add(1); false } } } fn alert(&mut self, m: String) { if self.alerts.len() >= xfa::MAX_ALERTS { self.dropped = self.dropped.saturating_add(1); } else if self.room(m.len()) { self.alerts.push(m); } } fn print(&mut self, m: String) { if self.console.len() >= xfa::MAX_CONSOLE { self.dropped = self.dropped.saturating_add(1); } else if self.room(m.len()) { self.console.push(m); } } /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] /// requests and the run's byte budget. fn request(&mut self, r: Request) { if self.requests.len() >= xfa::MAX_EFFECTS { self.dropped = self.dropped.saturating_add(1); return; } let r = match r { Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), Request::Submit(u) => Request::Submit(xfa::clip(u)), Request::Focus(n) => Request::Focus(xfa::clip(n)), other => other, }; let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), _ => 0, }; if self.room(bytes) { self.requests.push(r); } } } type Shared = RefCell; /// The host state, installed before scripts run. fn host(ctx: &Context) -> JsResult<&Shared> { ctx.get_data::().ok_or_else(|| error("the script host is not set up")) } fn s(v: &str) -> JsValue { JsValue::from(JsString::from(v)) } fn text(v: &JsValue, ctx: &mut Context) -> JsResult { if v.is_undefined() || v.is_null() { return Ok(String::new()); } Ok(v.to_string(ctx)?.to_std_string_escaped()) } /// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer /// string. fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { if v.is_undefined() || v.is_null() { return Ok(String::new()); } let js = v.to_string(ctx)?; // Two UTF-16 units per character at most: the clip still sees more than a line's worth. let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; Ok(xfa::clip(js.to_std_string_escaped())) } fn arg(args: &[JsValue], i: usize) -> JsValue { args.get(i).cloned().unwrap_or_default() } fn error(msg: impl Into) -> JsError { JsNativeError::typ().with_message(msg.into()).into() } /// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. fn as_number(t: &str) -> Option { let t = t.trim(); if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { return None; } t.parse::().ok().filter(|v| v.is_finite()) } fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { FunctionObjectBuilder::new(ctx.realm(), f).build() } fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { if let Some(o) = v.as_object() && o.is_array() { let a = JsArray::from_object(o.clone())?; let n = a.length(ctx)?; if n > MAX_ARRAY_LENGTH { return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); } let mut out = Vec::new(); for i in 0..n { let x = a.get(i, ctx)?; out.push(text(&x, ctx)?); } return Ok(out); } Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) } fn colour(c: &Option>, ctx: &mut Context) -> JsValue { match c { None => JsArray::from_iter([s("T")], ctx).into(), Some(parts) => { let vals: Vec = parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); JsArray::from_iter(vals, ctx).into() } } } // ── field objects ─────────────────────────────────────────────────────────────────────────── fn field_index(ctx: &Context, name: &JsString) -> JsResult { let n = name.to_std_string_escaped(); host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) } fn read(ctx: &Context, name: &JsString) -> JsResult { let i = field_index(ctx, name)?; Ok(host(ctx)?.borrow().fields[i].clone()) } fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { let i = field_index(ctx, name)?; let mut h = host(ctx)?.borrow_mut(); let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; change(field); h.changed.insert(i); Ok(()) } fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { match f.kind { FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), FieldType::ListBox if f.value.len() > 1 => { let vals: Vec = f.value.iter().map(|v| s(v)).collect(); JsArray::from_iter(vals, ctx).into() } _ => { let v = f.single(); as_number(&v).map_or_else(|| s(&v), JsValue::from) } } } fn set_value(f: &mut FieldState, vals: Vec) { f.value = match f.kind { FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), FieldType::ListBox => vals, _ => vals.into_iter().take(1).collect(), }; } macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { let $f = read($c, name)?; Ok($body) } function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) }}; } fn field_object(ctx: &mut Context, name: &str) -> JsObject { let n = JsString::from(name); let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); let value_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let vals = strings(&arg(args, 0), c)?; write(c, name, |f| set_value(f, vals))?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), _ => f.value.join(","), })); let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); let fname = getter!(ctx, n, |f, _c| s(&f.name)); let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); let readonly_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.readonly = on)?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); let required_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.required = on)?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); let display_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let d = arg(args, 0).to_number(c)? as i32; write(c, name, |f| f.display = d.clamp(0, 3))?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); let hidden_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); let export_values = getter!(ctx, n, |f, c| { let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); JsArray::from_iter(vals, c).into() }); let indices = getter!(ctx, n, |f, c| { let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } }); let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); let text_color_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let v = strings(&arg(args, 0), c)?; write(c, name, |f| f.text_color = Some(v))?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); let fill_color_set = { fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let v = strings(&arg(args, 0), c)?; write(c, name, |f| f.fill_color = Some(v))?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)?; // bExportValue defaults to true. let export = args.get(1).is_none_or(JsValue::to_boolean); let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; Ok(s(if export { &o.0 } else { &o.1 })) } fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)? as usize; let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); Ok(JsValue::from(on)) } fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)? as usize; let on = args.get(1).is_none_or(JsValue::to_boolean); let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; write(c, name, |f| { if on { f.value = vec![export]; } else if f.value.first() == Some(&export) { f.value.clear(); } })?; Ok(JsValue::undefined()) } fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); Ok(JsValue::undefined()) } let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); ObjectInitializer::new(ctx) .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) .accessor(js_string!("valueAsString"), Some(as_string), None, rw) .accessor(js_string!("defaultValue"), Some(default), None, rw) .accessor(js_string!("type"), Some(kind), None, rw) .accessor(js_string!("name"), Some(fname), None, rw) .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) .accessor(js_string!("numItems"), Some(num_items), None, rw) .accessor(js_string!("charLimit"), Some(char_limit), None, rw) .accessor(js_string!("exportValues"), Some(export_values), None, rw) .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) .function(get_item_at, js_string!("getItemAt"), 2) .function(is_box_checked, js_string!("isBoxChecked"), 1) .function(check_this_box, js_string!("checkThisBox"), 2) .function(set_focus, js_string!("setFocus"), 0) .build() } // ── the document (global object) ──────────────────────────────────────────────────────────── fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let name = text(&arg(args, 0), ctx)?; let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); if exists { return Ok(field_object(ctx, &name).into()); } // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. let prefix = format!("{name}."); let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) } fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let i = arg(args, 0).to_number(ctx)?; let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); Ok(name.map_or(JsValue::null(), |n| s(&n))) } fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let names = strings(&arg(args, 0), ctx)?; host(ctx)?.borrow_mut().request(Request::Reset(names)); Ok(JsValue::undefined()) } fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().request(Request::Print); Ok(JsValue::undefined()) } fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let a = arg(args, 0); let url = match a.as_object() { Some(o) if !o.is_array() => { let u = o.get(js_string!("cURL"), ctx)?; text(&u, ctx)? } _ => text(&a, ctx)?, }; host(ctx)?.borrow_mut().request(Request::Submit(url)); Ok(JsValue::undefined()) } fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::undefined()) } fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) } fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) } fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let n = arg(args, 0).to_number(ctx)?; let mut h = host(ctx)?.borrow_mut(); let last = h.doc.num_pages.saturating_sub(1); let p = (n.max(0.0) as usize).min(last); h.doc.page = p; h.request(Request::GoToPage(p)); Ok(JsValue::undefined()) } // ── app, util, console ────────────────────────────────────────────────────────────────────── /// A message argument: a string, or an object with `cMsg`, cut to a message's length. fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { match v.as_object() { Some(o) if !o.is_array() && !o.is_callable() => { let m = o.get(JsString::from(key), ctx)?; text_line(&m, ctx) } _ => text_line(v, ctx), } } /// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and /// returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; host(ctx)?.borrow_mut().alert(m); // The OK button. Ok(JsValue::from(1)) } /// `app.beep()`: queues a request to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } /// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object /// with `cURL`. fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } /// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print /// and Save As. Other items do nothing. pub(crate) fn menu_item(name: &str) -> Option { Some(match name { "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), "Print" => Request::Print, // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form // never overwrites the file unasked. "SaveAs" | "Save" => Request::SaveAs, _ => return None, }) } /// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens /// Save As too); an unknown name does nothing. fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let item = text(&arg(args, 0), ctx)?; if let Some(r) = menu_item(&item) { host(ctx)?.borrow_mut().request(r); } Ok(JsValue::undefined()) } /// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to /// type one. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) } fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = text_line(&arg(args, 0), ctx)?; host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().console.clear(); Ok(JsValue::undefined()) } fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { // Timers never fire in the sandbox; scripts get an object they can pass to clear*. Ok(ObjectInitializer::new(ctx).build().into()) } /// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups /// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` /// without grouping. fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let fmt = text(&arg(args, 0), ctx)?; let mut out = String::new(); let check_output = |len: usize| -> JsResult<()> { if len > MAX_PRINTF_OUTPUT_BYTES { return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); } Ok(()) }; let mut next = 1; let mut chars = fmt.chars().peekable(); while let Some(c) = chars.next() { if c != '%' { check_output(out.len().saturating_add(c.len_utf8()))?; out.push(c); continue; } if chars.peek() == Some(&'%') { chars.next(); check_output(out.len().saturating_add(1))?; out.push('%'); continue; } let mut sep = 1u8; if chars.peek() == Some(&',') { chars.next(); sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; } let (mut plus, mut space, mut zero) = (false, false, false); while let Some(&f) = chars.peek() { match f { '+' => plus = true, ' ' => space = true, '0' => zero = true, '#' => {} _ => break, } chars.next(); } let mut width = 0usize; while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { width = width.saturating_mul(10).saturating_add(d as usize); chars.next(); } let mut prec = None; if chars.peek() == Some(&'.') { chars.next(); let mut p = 0usize; while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { p = p.saturating_mul(10).saturating_add(d as usize); chars.next(); } prec = Some(p); } if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); } let conv = chars.next().unwrap_or('s'); let v = arg(args, next); next += 1; let body = match conv { 'd' | 'f' => { let n = v.to_number(ctx)?; let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; let n = if conv == 'd' { n.trunc() } else { n }; let mut t = group(&format!("{:.*}", p, n.abs()), sep); if n < 0.0 { t.insert(0, '-'); } else if plus { t.insert(0, '+'); } else if space { t.insert(0, ' '); } t } 'x' => format!("{:X}", v.to_number(ctx)? as i64), _ => { let t = text(&v, ctx)?; match prec { Some(p) => t.chars().take(p).collect(), None => t, } } }; let pad = width.saturating_sub(body.chars().count()); check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; if zero && matches!(conv, 'd' | 'f') { let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; out.push_str(sign); out.push_str(&"0".repeat(pad)); out.push_str(digits); } else { out.push_str(&" ".repeat(pad)); out.push_str(&body); } } Ok(s(&out)) } /// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. fn group(t: &str, sep: u8) -> String { let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); let (thousands, point) = match sep { 0 => (Some(','), '.'), 2 => (Some('.'), ','), 3 => (None, ','), _ => (None, '.'), }; let mut g = String::new(); for (i, ch) in int.chars().enumerate() { if let Some(th) = thousands && i > 0 && (int.len() - i).is_multiple_of(3) { g.push(th); } g.push(ch); } if let Some(f) = frac { g.push(point); g.push_str(f); } g } const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; /// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, /// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let f = arg(args, 0); let fmt = if f.is_number() { match f.to_number(ctx)? as i32 { 0 => "D:yyyymmddHHMMss".to_string(), 1 => "yyyy.mm.dd HH:MM:ss".to_string(), _ => "m/d/yy h:MM:ss tt".to_string(), } } else { text(&f, ctx)? }; let d = arg(args, 1); let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; let mut part = |m: &str| -> JsResult { let func = date.get(JsString::from(m), ctx)?; let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; let v = func.call(&d, &[], ctx)?.to_number(ctx)?; if v.is_nan() { return Err(error("invalid date")); } Ok(v as i64) }; let (y, mo, day, wd, h, mi, sec) = (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); let mut out = String::new(); let b = fmt.as_bytes(); let mut i = 0; while i < b.len() { let c = b[i]; // Only date letters read as runs; a literal is copied one character at a time, so // measuring its run would rescan a long literal stretch at every position. let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; #[cfg(test)] PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); let push_num = |out: &mut String, v: i64, n: usize| { if n >= 2 { out.push_str(&format!("{v:02}")); } else { out.push_str(&v.to_string()); } }; match c { b'y' => { if run >= 4 { out.push_str(&format!("{y:04}")); } else { out.push_str(&format!("{:02}", y % 100)); } } b'm' => match run { r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), n => push_num(&mut out, mo + 1, n), }, b'd' => match run { r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), 3 => out.push_str(&DAYS[wd as usize % 7][..3]), n => push_num(&mut out, day, n), }, b'H' => push_num(&mut out, h, run), b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), b'M' => push_num(&mut out, mi, run), b's' => push_num(&mut out, sec, run), b't' => { let ampm = if h < 12 { "am" } else { "pm" }; out.push_str(if run >= 2 { ampm } else { &m[..1] }); } b'\\' if i + 1 < b.len() => { // The escaped character may be multi-byte; copy it whole. let ch = fmt[i + 1..].chars().next().unwrap_or(' '); out.push(ch); i += 1 + ch.len_utf8(); continue; } _ => { // Copy the rest of a multi-byte character too. let ch = fmt[i..].chars().next().unwrap_or(' '); out.push(ch); i += ch.len_utf8(); continue; } } i += run; } Ok(s(&out)) } /// A field of a `util.scand` format. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DateUnit { /// `yyyy`: four digits. Year, /// `yy`: two digits, read as 1950–2049. ShortYear, Month, /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. MonthName, Day, /// `ddd`, `dddd`: a weekday's name, read and not checked. Weekday, Hour, /// `h`, `hh`: 0–12, with `tt` for the afternoon. Hour12, Minute, Second, /// `t`, `tt`: am or pm. AmPm, } impl DateUnit { /// A year, month or day: a format needs one of these for `util.scand` to read anything. fn is_date(self) -> bool { matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) } /// Read from a run of digits, not from letters. fn is_numeric(self) -> bool { !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) } } /// A piece of a `util.scand` format. #[derive(Debug, PartialEq, Eq)] enum ScanPart { /// A field, the digits it takes when typed together with the next one (`width`), and whether /// the next one follows it with nothing between them in the format (`joined`). Field { unit: DateUnit, width: usize, joined: bool }, /// Letters the format writes as they are, which the text must have there. Word(String), } /// The fields and literal words of a `util.scand` format, in order. A numeric field is marked /// `joined` when another numeric field follows it directly; anything but letters and digits /// outside a field is left out. fn scan_parts(fmt: &str) -> Vec { let chars: Vec = fmt.chars().collect(); let mut parts: Vec = Vec::new(); // The last part is a field written right before this point, or letters still being collected. let (mut after_field, mut in_word) = (false, false); let mut i = 0; while let Some(&c) = chars.get(i) { // Counted for field letters only, so a long run of anything else is read once. let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; let field = match (c, run) { ('y', 4..) => Some((DateUnit::Year, 4)), ('y', _) => Some((DateUnit::ShortYear, 2)), ('m', 3..) => Some((DateUnit::MonthName, 0)), ('m', n) => Some((DateUnit::Month, n)), ('d', 3..) => Some((DateUnit::Weekday, 0)), ('d', n) => Some((DateUnit::Day, n)), ('H', n) => Some((DateUnit::Hour, n.min(2))), ('h', n) => Some((DateUnit::Hour12, n.min(2))), ('M', n) => Some((DateUnit::Minute, n.min(2))), ('s', n) => Some((DateUnit::Second, n.min(2))), ('t', _) => Some((DateUnit::AmPm, 0)), _ => None, }; if let Some((unit, width)) = field { if after_field && unit.is_numeric() && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() && last.is_numeric() { *joined = true; } parts.push(ScanPart::Field { unit, width, joined: false }); (after_field, in_word) = (true, false); i += run; continue; } // `\` writes the next character as it is. let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; i += step; after_field = false; match literal { Some(l) if l.is_alphanumeric() => match parts.last_mut() { Some(ScanPart::Word(w)) if in_word => w.push(l), _ => { parts.push(ScanPart::Word(l.to_string())); in_word = true; } }, _ => in_word = false, } } parts } /// A run of digits or of letters in the text `util.scand` reads. enum ScanToken<'a> { Digits(&'a str), Letters(&'a str), } /// The text's runs of digits and of letters, in order; anything else only separates them, and /// may not begin or end the text. `None` when it does. fn scan_tokens(text: &str) -> Option>> { // Some(true) for a digit, Some(false) for a letter, None for a separator. let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); kind(text.chars().next()?)?; kind(text.chars().next_back()?)?; let mut out = Vec::new(); let mut start: Option<(usize, bool)> = None; for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { let k = kind(c); if let Some((from, digits)) = start && k != Some(digits) { let piece = text.get(from..at)?; out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); start = None; } if let Some(digits) = k && start.is_none() { start = Some((at, digits)); } } Some(out) } /// What `util.scand` reads. #[derive(Debug, PartialEq, Eq)] enum Scanned { /// The text is empty: today, at noon. Today, /// A date and time: year, month (1–12), day, hours, minutes, seconds. At(i32, u32, u32, u32, u32, u32), } /// The fields `util.scand` has read so far. #[derive(Default)] struct ScanValues { y: Option, mo: Option, d: Option, h: Option, mi: Option, sec: Option, pm: Option, /// The hour was read from `h` or `hh`. twelve: bool, } impl ScanValues { /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour /// marked as 12-hour. Month names, weekdays and am/pm are not read here. fn set(&mut self, unit: DateUnit, v: u32) { match unit { DateUnit::Year => self.y = i32::try_from(v).ok(), DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), DateUnit::Month => self.mo = Some(v), DateUnit::Day => self.d = Some(v), DateUnit::Hour => self.h = Some(v), DateUnit::Hour12 => { self.h = Some(v); self.twelve = true; } DateUnit::Minute => self.mi = Some(v), DateUnit::Second => self.sec = Some(v), DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} } } } /// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): /// /// - Fields may be separated by anything but digits and letters, whether or not the format /// separates them, but nothing may come before the first or after the last. /// - Digits typed together fill the format's fields written together with exactly as many /// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may /// have one or two, except a four-digit year. /// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. /// - Impossible dates are refused, but a month or day of 0 is read as 1. /// - A format with no year, month or day reads nothing; an empty text is today, at noon. /// /// Not observed: a year left out is `this_year`, and a month or day left out is 1. fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { let parts = scan_parts(fmt); if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { return None; } if text.is_empty() { return Some(Scanned::Today); } let tokens = scan_tokens(text)?; let mut v = ScanValues::default(); let (mut p, mut t) = (0, 0); while let Some(part) = parts.get(p) { let token = tokens.get(t)?; t += 1; match (part, token) { (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { if w.to_lowercase() != l.to_lowercase() { return None; } p += 1; } (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); p += 1; } (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { named(&DAYS, l)?; p += 1; } (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { v.pm = Some(match l.to_lowercase().as_str() { "am" | "a" => false, "pm" | "p" => true, _ => return None, }); p += 1; } (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { // The fields written together from here, no more than the run's digits can fill, // and the fewest of them the run fills. let mut group = vec![(*unit, *width)]; let (mut more, mut widths) = (*joined, *width); while more && widths < run.len() && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) { group.push((*unit, *width)); widths = widths.saturating_add(*width); more = *joined; } let mut sum = 0; let fills = group.iter().position(|(_, w)| { sum += w; sum == run.len() }); if let Some(last) = fills { let mut at = 0; for (unit, w) in group.iter().take(last + 1) { v.set(*unit, run.get(at..at + w)?.parse().ok()?); at += w; } p += last + 1; } else if *unit != DateUnit::Year && run.len() <= 2 { v.set(*unit, run.parse().ok()?); p += 1; } else { return None; } } _ => return None, } } if t != tokens.len() { return None; } let y = v.y.unwrap_or(this_year); let mo = v.mo.unwrap_or(1).max(1); let d = v.d.unwrap_or(1).max(1); if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { return None; } let mut h = v.h.unwrap_or(0); if v.twelve && h > 12 { return None; } match v.pm { Some(true) if h < 12 => h += 12, Some(false) if h == 12 => h = 0, _ => {} } let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) } /// The index of the name `word` is, in full or as its first three letters, in any case. fn named(names: &[&str], word: &str) -> Option { let word = word.to_lowercase(); let i = names.iter().position(|n| { let n = n.to_lowercase(); word == n || n.get(..3) == Some(word.as_str()) })?; u32::try_from(i).ok() } /// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local /// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { return Ok(JsValue::null()); }; let fmt = text(&arg(args, 0), ctx)?; let date = JsDate::new(ctx); let this_year = date.get_full_year(ctx)?.to_number(ctx)?; let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; match scan_date(&fmt, &typed, this_year) { None => return Ok(JsValue::null()), Some(Scanned::Today) => { date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; } Some(Scanned::At(y, mo, d, h, mi, sec)) => { // setFullYear, not the constructor, so years 0–99 stay as they are. date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; } } Ok(date.into()) } /// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` /// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); let (mut out, mut j, mut case) = (String::new(), 0, 0); let mut i = 0; let push = |out: &mut String, c: char, case: i32| match case { 1 => out.extend(c.to_uppercase()), -1 => out.extend(c.to_lowercase()), _ => out.push(c), }; while i < fmt.len() { let f = fmt[i]; i += 1; let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { while *j < src.len() { let c = src[*j]; *j += 1; if ok(c) { return Some(c); } } None }; match f { '?' => { if let Some(c) = take(&mut j, &|_| true) { push(&mut out, c, case) } } 'X' => { if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { push(&mut out, c, case) } } 'A' => { if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { push(&mut out, c, case) } } '9' => { if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { push(&mut out, c, case) } } '*' => { for c in src[j.min(src.len())..].iter().copied() { push(&mut out, c, case); } j = src.len(); } '\\' if i < fmt.len() => { out.push(fmt[i]); i += 1; } '>' => case = 1, '<' => case = -1, '=' => case = 0, c => out.push(c), } } Ok(s(&out)) } /// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as /// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. fn color_object(ctx: &mut Context) -> JsObject { let mut entries: Vec<(&str, Vec)> = vec![ ("transparent", vec![s("T")]), ("black", vec![s("G"), 0.into()]), ("white", vec![s("G"), 1.into()]), ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), ("dkGray", vec![s("G"), JsValue::from(0.25)]), ("gray", vec![s("G"), JsValue::from(0.5)]), ("ltGray", vec![s("G"), JsValue::from(0.75)]), ]; let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); let mut o = ObjectInitializer::new(ctx); for (k, v) in arrays { o.property(JsString::from(k), v, Attribute::all()); } o.build() } /// The XFA version scripts see as `xfa_version`. const XFA_VERSION: f64 = 3.5; /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, /// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, /// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and /// `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), ("print", 1, NativeFunction::from_fn_ptr(print)), ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; } let global = ctx.global_object(); let rw = Attribute::CONFIGURABLE; let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); let (file, pages, info, xfa) = { let h = host(ctx)?.borrow(); (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) }; let mut io = ObjectInitializer::new(ctx); for (k, v) in &info { io.property(JsString::from(k.as_str()), s(v), Attribute::all()); } let info = io.build(); use boa_engine::property::PropertyDescriptor; global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; ctx.register_global_property(js_string!("info"), info, rw)?; // XFA forms check both before they run: every document sees the XFA version, and only an // XFA form sees `xfa_installed`. ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; if xfa { ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; } let app = ObjectInitializer::new(ctx) .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) .property(js_string!("platform"), s(platform()), Attribute::READONLY) .property(js_string!("language"), s("ENU"), Attribute::READONLY) .build(); ctx.register_global_property(js_string!("app"), app, rw)?; let util = ObjectInitializer::new(ctx) .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; let console = ObjectInitializer::new(ctx) .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) .build(); ctx.register_global_property(js_string!("console"), console, rw)?; let display = ObjectInitializer::new(ctx) .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) .build(); ctx.register_global_property(js_string!("display"), display, rw)?; let color = color_object(ctx); ctx.register_global_property(js_string!("color"), color, rw)?; Ok(()) } fn platform() -> &'static str { if cfg!(target_os = "macos") { "MAC" } else if cfg!(target_os = "windows") { "WIN" } else { "UNIX" } } fn event_object(ctx: &mut Context, e: &Event) -> JsObject { let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) .property(js_string!("value"), s(&e.value), Attribute::all()) .property(js_string!("change"), s(&e.change), Attribute::all()) .property(js_string!("changeEx"), s(&e.change), Attribute::all()) .property(js_string!("willCommit"), e.will_commit, Attribute::all()) .property(js_string!("rc"), true, Attribute::all()) .property(js_string!("target"), target, Attribute::all()) .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) .property(js_string!("selStart"), 0, Attribute::all()) .property(js_string!("selEnd"), 0, Attribute::all()) .property(js_string!("commitKey"), 0, Attribute::all()) .property(js_string!("modifier"), false, Attribute::all()) .property(js_string!("shift"), false, Attribute::all()) .build() } // ── stack safety ──────────────────────────────────────────────────────────────────────────── // // boa's parser and compiler recurse once per nesting level and have no limit of their own: about // a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come // from documents, so `run` refuses nesting no real form needs and runs the engine on a thread // with a large stack (address space, committed only as it is used). Together these bound the // stack a script can use: brackets and prefix operators by the limits below, and every other // construct (a few KiB per level, several bytes of source each) by the length limit. /// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. #[cfg(test)] pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Largest width or precision accepted by `util.printf`. const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; /// Longest `util.printf` output, in bytes. const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; /// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's /// length costs the script nothing but the host one string per item; real lists are tiny. const MAX_ARRAY_LENGTH: u64 = 1 << 20; /// Deepest nesting of `(`, `[` and `{`. const MAX_BRACKET_DEPTH: usize = 64; /// Longest run of prefix operators (`!`, `~`, `+`, `-`). const MAX_PREFIX_RUN: usize = 64; /// Most `?` (conditional expressions nest without brackets). const MAX_CONDITIONALS: usize = 4096; /// The script thread's stack. #[cfg(not(target_arch = "wasm32"))] const SCRIPT_STACK: usize = 1 << 30; /// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings /// and comments are skipped; brackets in regular expressions and templates count, which only /// errs on the safe side. fn refuse(source: &str) -> Option { if source.len() > MAX_SCRIPT_BYTES { return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); } let too_deep = || Some("the script is nested too deeply to run safely".to_string()); let b = source.as_bytes(); let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); let mut i = 0; while let Some(&c) = b.get(i) { i += 1; match c { b'\'' | b'"' => { // To the closing quote (or the end of the line, where an unclosed string ends). while let Some(&d) = b.get(i) { i += 1; if d == b'\\' { i += 1; } else if d == c || d == b'\n' { break; } } prefix = 0; } b'/' if b.get(i) == Some(&b'/') => { while b.get(i).is_some_and(|d| *d != b'\n') { i += 1; } } b'/' if b.get(i) == Some(&b'*') => { i += 1; while i < b.len() && !b[i..].starts_with(b"*/") { i += 1; } i += 2; } b'(' | b'[' | b'{' => { depth += 1; if depth > MAX_BRACKET_DEPTH { return too_deep(); } prefix = 0; } b')' | b']' | b'}' => { depth = depth.saturating_sub(1); prefix = 0; } b'!' | b'~' | b'+' | b'-' => { prefix += 1; if prefix > MAX_PREFIX_RUN { return too_deep(); } } b'?' => { conditionals += 1; if conditionals > MAX_CONDITIONALS { return too_deep(); } prefix = 0; } c if c.is_ascii_whitespace() => {} _ => prefix = 0, } } None } /// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the /// functions field scripts call), with the form's `fields`. pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) } /// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh /// context; the first field write copies the snapshot, so neither the caller nor another /// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; if let Some(why) = refuse(script) { return failed(why); } // A document script too long or too deeply nested to run safely is skipped and reported, // as one that throws is; the others and the script still run (#3940). let mut skipped = Vec::new(); let kept: Vec<(usize, &str)> = doc_scripts .iter() .enumerate() .filter_map(|(i, d)| match refuse(d) { Some(why) => { skipped.push((i, why)); None } None => Some((i, d.as_str())), }) .collect(); let (kept, skipped) = (&kept, &skipped); #[cfg(not(target_arch = "wasm32"))] { std::thread::scope(|scope| { let spawned = std::thread::Builder::new() .name("pdfcraft-js".into()) .stack_size(SCRIPT_STACK) .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), } }) } #[cfg(target_arch = "wasm32")] run_here(script, event, doc, fields, kept, skipped, limits) } /// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator /// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's /// recursive parser would overflow the stack on a deeply nested one. struct NoCodeFromStrings; impl HostHooks for NoCodeFromStrings { fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) } } /// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), /// for runners that start the script itself through `eval`. pub(crate) struct CheckedCodeFromStrings; impl HostHooks for CheckedCodeFromStrings { fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); if units > MAX_SCRIPT_BYTES { return Err(JsNativeError::range().with_message("the code is too long to compile").into()); } // As one text: brackets left open in one parameter carry into the next. let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); source.push('\n'); source.push_str(&body.to_std_string_lossy()); match refuse(&source) { Some(why) => Err(JsNativeError::range().with_message(why).into()), None => Ok(()), } } } /// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { format!("document script {}: {error}", index.saturating_add(1)) } /// `doc_scripts` are the document scripts to run with their places among all of them; /// `skipped` the ones refused before running, with the reason. fn run_here( script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[(usize, &str)], skipped: &[(usize, String)], limits: Limits, ) -> Outcome { let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { Ok(ctx) => ctx, Err(e) => { let error = Some(format!("the script engine could not start: {e}")); return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; } }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { fields, changed: BTreeSet::new(), alerts: Vec::new(), console: Vec::new(), requests: Vec::new(), doc: doc.clone(), kept: 0, dropped: 0, })); let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; let ev = event_object(&mut ctx, event); ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; // One document script that throws (calling a method PdfCraft lacks, say) must not keep // every field script in the form from running: what it defined before the error, and // the other scripts, stay available (#3940). for &(i, d) in doc_scripts { if let Err(e) = ctx.eval(Source::from_bytes(d)) { doc_errors.push((i, doc_error(i, &e))); } } let r = ctx.eval(Source::from_bytes(script))?; if !r.is_undefined() { out.result = Some(text(&r, &mut ctx)?); } let rc = ev.get(js_string!("rc"), &mut ctx)?; out.rc = rc.to_boolean(); let v = ev.get(js_string!("value"), &mut ctx)?; out.value = text(&v, &mut ctx)?; let c = ev.get(js_string!("change"), &mut ctx)?; out.change = text(&c, &mut ctx)?; Ok(()) })(); if let Err(e) = result { out.error = Some(e.to_string()); } doc_errors.sort_by_key(|(i, _)| *i); out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); if let Some(h) = ctx.remove_data::() { let h = h.into_inner(); out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); out.alerts = h.alerts; out.console = h.console; out.requests = h.requests; if h.dropped > 0 { out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); } } out } pub mod formcalc; pub mod xfa; pub mod xfa_session; #[cfg(test)] mod tests;