mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:17:44 -06:00
A script looping on app.execMenuItem could queue unlimited requests; they now follow the same count and byte limits as other requests.
1750 lines
71 KiB
Rust
1750 lines
71 KiB
Rust
//! 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<String>,
|
||
pub default: Vec<String>,
|
||
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<usize>,
|
||
/// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set.
|
||
pub text_color: Option<Vec<String>>,
|
||
pub fill_color: Option<Vec<String>>,
|
||
}
|
||
|
||
impl FieldState {
|
||
pub fn new(name: impl Into<String>, kind: FieldType, value: Vec<String>) -> 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<String>,
|
||
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<String>),
|
||
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<FieldState>,
|
||
pub alerts: Vec<String>,
|
||
pub console: Vec<String>,
|
||
pub requests: Vec<Request>,
|
||
/// The script's error (syntax error, exception, or a limit reached), if it failed.
|
||
pub error: Option<String>,
|
||
/// 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<String>,
|
||
/// The script's completion value (what the console prints), unless undefined.
|
||
pub result: Option<String>,
|
||
}
|
||
|
||
/// 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<Vec<FieldState>>,
|
||
changed: BTreeSet<usize>,
|
||
alerts: Vec<String>,
|
||
console: Vec<String>,
|
||
requests: Vec<Request>,
|
||
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::<String>())),
|
||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||
_ => 0,
|
||
};
|
||
if self.room(bytes) {
|
||
self.requests.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
type Shared = RefCell<Host>;
|
||
|
||
/// The host state, installed before scripts run.
|
||
fn host(ctx: &Context) -> JsResult<&Shared> {
|
||
ctx.get_data::<Shared>().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<String> {
|
||
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<String> {
|
||
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<String>) -> 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<f64> {
|
||
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::<f64>().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<Vec<String>> {
|
||
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<Vec<String>>, ctx: &mut Context) -> JsValue {
|
||
match c {
|
||
None => JsArray::from_iter([s("T")], ctx).into(),
|
||
Some(parts) => {
|
||
let vals: Vec<JsValue> =
|
||
parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::<f64>().unwrap_or(0.0)) }).collect();
|
||
JsArray::from_iter(vals, ctx).into()
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── field objects ───────────────────────────────────────────────────────────────────────────
|
||
|
||
fn field_index(ctx: &Context, name: &JsString) -> JsResult<usize> {
|
||
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<FieldState> {
|
||
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<JsValue> = 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<String>) {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> = 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<JsValue> = 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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
host(ctx)?.borrow_mut().request(Request::Print);
|
||
Ok(JsValue::undefined())
|
||
}
|
||
|
||
fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
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<JsValue> {
|
||
Ok(JsValue::undefined())
|
||
}
|
||
|
||
fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64))
|
||
}
|
||
|
||
fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64))
|
||
}
|
||
|
||
fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
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<String> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
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<Request> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
// No one to answer: as if the dialog was cancelled.
|
||
Ok(JsValue::null())
|
||
}
|
||
|
||
fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
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<JsValue> {
|
||
host(ctx)?.borrow_mut().console.clear();
|
||
Ok(JsValue::undefined())
|
||
}
|
||
|
||
fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||
// 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<JsValue> {
|
||
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<JsValue> {
|
||
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<i64> {
|
||
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<ScanPart> {
|
||
let chars: Vec<char> = fmt.chars().collect();
|
||
let mut parts: Vec<ScanPart> = 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<Vec<ScanToken<'_>>> {
|
||
// 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<i32>,
|
||
mo: Option<u32>,
|
||
d: Option<u32>,
|
||
h: Option<u32>,
|
||
mi: Option<u32>,
|
||
sec: Option<u32>,
|
||
pm: Option<bool>,
|
||
/// 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<Scanned> {
|
||
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<u32> {
|
||
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<JsValue> {
|
||
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<JsValue> {
|
||
let fmt: Vec<char> = text(&arg(args, 0), ctx)?.chars().collect();
|
||
let src: Vec<char> = 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<char> {
|
||
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<JsValue>)> = 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<String> {
|
||
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<Vec<FieldState>>, 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::<Vec<_>>().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<Vec<FieldState>>,
|
||
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::<Shared>() {
|
||
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;
|