From 8e50dcd0a3bb28d63bad2f142539f0883893f74a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:42:55 +0000 Subject: [PATCH] refactor(js): split xfa.rs into xfa/ New modules: model, host, nodes, instances, globals, run. --- crates/js/src/xfa.rs | 1653 +------------------------------- crates/js/src/xfa/globals.rs | 362 +++++++ crates/js/src/xfa/host.rs | 286 ++++++ crates/js/src/xfa/instances.rs | 279 ++++++ crates/js/src/xfa/model.rs | 228 +++++ crates/js/src/xfa/nodes.rs | 343 +++++++ crates/js/src/xfa/run.rs | 170 ++++ 7 files changed, 1685 insertions(+), 1636 deletions(-) create mode 100644 crates/js/src/xfa/globals.rs create mode 100644 crates/js/src/xfa/host.rs create mode 100644 crates/js/src/xfa/instances.rs create mode 100644 crates/js/src/xfa/model.rs create mode 100644 crates/js/src/xfa/nodes.rs create mode 100644 crates/js/src/xfa/run.rs diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 42b1688ed..cddf8f2eb 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -17,1639 +17,20 @@ use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; -/// What kind of template object a node is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XfaKind { - /// `xfa.form` itself. - Form, - Subform, - Field, - ExclGroup, - Draw, - Area, -} - -impl XfaKind { - pub(crate) fn class_name(self) -> &'static str { - match self { - XfaKind::Form => "form", - XfaKind::Subform => "subform", - XfaKind::Field => "field", - XfaKind::ExclGroup => "exclGroup", - XfaKind::Draw => "draw", - XfaKind::Area => "area", - } - } -} - -/// One object of the form, as the caller snapshots it. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaNode { - pub name: String, - /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). - pub som: String, - pub kind: Option, - /// Fields: the raw value (ISO dates, on values for buttons); empty when null. - pub value: String, - /// Numeric fields give numbers to scripts. - pub numeric: bool, - /// `visible`, `invisible`, `hidden` or `inactive`. - pub presence: String, - /// `open`, `readOnly`, `protected` or `nonInteractive`. - pub access: String, - /// Subforms: may have several instances (`occur max` other than 1). - pub repeatable: bool, - pub occur_min: usize, - pub occur_max: Option, - /// This instance's index among its same-named siblings. - pub index: usize, - pub children: Vec, - /// Fields: the value shown in the field's display picture, what `formattedValue` reads; - /// `None` when the picture doesn't change it (the raw value is read then). - pub formatted: Option, -} - -/// The event a script runs for. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaEvent { - /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … - pub activity: String, - /// The SOM of `this`. - pub target: String, - pub new_text: String, - pub prev_text: String, -} - -/// About the document. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaDoc { - pub file_name: String, - /// The current page, 0-based. - pub page: usize, - pub page_count: usize, -} - -/// Something a script changed or asked for. -#[derive(Clone, Debug, PartialEq)] -pub enum XfaEffect { - SetValue { - som: String, - value: String, - }, - SetPresence { - som: String, - presence: String, - }, - SetAccess { - som: String, - access: String, - }, - /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). - AddInstance { - som: String, - }, - /// The instance at this SOM is removed. - RemoveInstance { - som: String, - }, - MessageBox(String), - /// `xfa.host.resetData()`: the named fields, or every field when empty. - ResetData(Vec), - /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. - ShowPanel(crate::Panel), - Print, - SaveAs, - LaunchUrl(String), - SetFocus(String), - Beep, - /// `xfa.form.recalculate(true)`. - Recalculate, - /// `xfa.form.remerge()`, `xfa.layout.relayout()`. - Relayout, -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaOutcome { - pub effects: Vec, - pub console: Vec, - /// The script's error, if it failed. - pub error: Option, - /// The script's completion value (a calculate script's result; a validate script's verdict). - pub result: Option, - /// The completion value as a boolean, when it was one. - pub result_bool: Option, - /// Limits the script ran into (too many effects, message boxes or console lines): what - /// was left out, for the caller to report. - pub notes: Vec, - /// The script ran past its time limit and was left running on its own thread (the engine - /// can't interrupt it): the caller should run no more of this form's scripts. - pub abandoned: bool, -} - -/// Most nodes a snapshot may hold. -const MAX_NODES: usize = 200_000; -/// Most instances a script may add in one run. -pub(crate) const MAX_ADDED: usize = 1_000; -/// Deepest tree followed. -const MAX_DEPTH: usize = 64; -/// Most effects one run may produce (after values set again on the same object are merged). -pub const MAX_EFFECTS: usize = 10_000; -/// Most message boxes one run may show. -pub const MAX_ALERTS: usize = 100; -/// Most console lines one run may print. -pub const MAX_CONSOLE: usize = 1_000; -/// Longest message or console line kept, in characters. -pub(crate) const MAX_LINE: usize = 4_096; -/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names -/// and the values it sets. Past it, more are left out and noted. -pub(crate) const MAX_PAYLOAD: usize = 32 << 20; - -/// Longest value a script may put in a field (characters); longer ones are cut and noted. -const MAX_VALUE: usize = 65_536; - -/// `m` cut to [`MAX_LINE`] characters. -pub(crate) fn clip(m: String) -> String { - match m.char_indices().nth(MAX_LINE) { - Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), - None => m, - } -} - -/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. -pub(crate) fn clip_value(v: String) -> (String, bool) { - match v.char_indices().nth(MAX_VALUE) { - Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), - None => (v, false), - } -} - -/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and -/// named once, at most as many as a snapshot holds nodes. -pub(crate) fn reset_names(list: &str) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if out.len() >= MAX_NODES { - break; - } - let name = clip(name.to_string()); - if seen.insert(name.clone()) { - out.push(name); - } - } - out -} - -/// The (property, SOM) an effect setting a property of an object is merged by, see -/// [`XHost::set_at`]. -fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { - match e { - XfaEffect::SetValue { som, .. } => Some((0, som.clone())), - XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), - XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), - _ => None, - } -} - -/// Bytes of text `e` holds. -fn payload(e: &XfaEffect) -> usize { - match e { - XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { - som.len().saturating_add(s.len()) - } - XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), - XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), - XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, - } -} - -/// A script's completion value (what a calculate script puts in its field) cut to -/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. -pub(crate) fn clip_result(out: &mut XfaOutcome) { - if let Some(r) = out.result.take() { - let (r, cut) = clip_value(r); - if cut { - out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); - } - out.result = Some(r); - } -} - -/// One node of the flattened form, shared by the JavaScript and FormCalc engines. -pub(crate) struct HNode { - pub(crate) name: String, - pub(crate) som: String, - pub(crate) kind: XfaKind, - pub(crate) value: String, - pub(crate) numeric: bool, - pub(crate) presence: String, - pub(crate) access: String, - pub(crate) repeatable: bool, - pub(crate) occur_min: usize, - pub(crate) occur_max: Option, - pub(crate) index: usize, - pub(crate) parent: Option, - pub(crate) children: Vec, - /// Removed by `removeInstance`: skipped everywhere. - pub(crate) gone: bool, - /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. - pub(crate) formatted: Option, -} - -/// The form as a script sees and changes it, with the effects it records. -pub(crate) struct XHost { - pub(crate) nodes: Vec, - pub(crate) effects: Vec, - pub(crate) console: Vec, - pub(crate) doc: XfaDoc, - added: usize, - /// Index of `this`. - pub(crate) current: usize, - /// Where the effect setting a property of an object is in `effects`, by (property, SOM): - /// setting it again replaces that effect (the last value wins) unless something that - /// depends on order (rows added or removed, a reset) came after it. - set_at: HashMap<(u8, String), usize>, - /// Effects before this index are behind an order-dependent effect. - barrier: usize, - alerts: usize, - dropped_effects: usize, - clipped_values: usize, - dropped_alerts: usize, - dropped_console: usize, - /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. - kept: usize, - dropped_bytes: usize, -} - -impl XHost { - /// The host for a script on `root` (the template's root subform), with `this` at the node - /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. - pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); - XHost { - nodes, - effects: Vec::new(), - console: Vec::new(), - doc: doc.clone(), - added: 0, - current, - set_at: HashMap::new(), - barrier: 0, - alerts: 0, - dropped_effects: 0, - clipped_values: 0, - dropped_alerts: 0, - dropped_console: 0, - kept: 0, - dropped_bytes: 0, - } - } - - /// 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 <= MAX_PAYLOAD) { - Some(k) => { - self.kept = k; - true - } - None => { - self.dropped_bytes = self.dropped_bytes.saturating_add(1); - false - } - } - } - - /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] - /// bytes of text) effects are counted and dropped. - pub(crate) fn push(&mut self, e: XfaEffect) { - let e = self.clip_effect(e); - if self.room(payload(&e)) { - self.record(e); - } - } - - /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made - /// only when the effect that applies it will be kept, so a script never sees a change its - /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is - /// counted when there is no room. - fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { - let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); - if !replaces && self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return false; - } - self.room(bytes) - } - - /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or - /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. - pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { - if !self.reserve(&e, payload(&e)) { - return; - } - if let Some(n) = self.nodes.get_mut(i) { - match &e { - XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), - XfaEffect::SetAccess { access, .. } => n.access = access.clone(), - _ => {} - } - } - self.record(e); - } - - /// `e` with its text cut to the lengths an effect keeps. - fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { - match e { - XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), - XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), - XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), - XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), - XfaEffect::SetValue { som, value } => { - let (value, cut) = clip_value(value); - if cut { - self.clipped_values += 1; - } - XfaEffect::SetValue { som, value } - } - other => other, - } - } - - /// Keep `e`, whose bytes are already charged, against the count limits. - fn record(&mut self, e: XfaEffect) { - if let Some(key) = effect_key(&e) { - if let Some(&at) = self.set_at.get(&key) - && at >= self.barrier - && let Some(slot) = self.effects.get_mut(at) - { - *slot = e; - return; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - self.set_at.insert(key, self.effects.len()); - self.effects.push(e); - return; - } - if matches!(e, XfaEffect::MessageBox(_)) { - if self.alerts >= MAX_ALERTS { - self.dropped_alerts += 1; - return; - } - self.alerts += 1; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { - self.barrier = self.effects.len() + 1; - } - self.effects.push(e); - } - - pub(crate) fn print(&mut self, line: String) { - if self.console.len() >= MAX_CONSOLE { - self.dropped_console += 1; - } else if self.room(line.len()) { - self.console.push(line); - } - } - - /// What the limits left out. - pub(crate) fn notes(&self) -> Vec { - let mut out = Vec::new(); - if self.dropped_effects > 0 { - out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); - } - if self.dropped_alerts > 0 { - out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); - } - if self.dropped_console > 0 { - out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); - } - if self.dropped_bytes > 0 { - out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); - } - if self.clipped_values > 0 { - out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); - } - out - } - - /// Give node `i` the value `text` when it is a field or exclusion group (nothing else - /// holds a value; writing one into a subform would put text where another viewer expects - /// a data group). Returns whether it was. - pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get(i) else { return false }; - if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { - return false; - } - let som = n.som.clone(); - // Cut here, not only in the effect: later reads in the script see what is saved, and - // the snapshot stays bounded however often a long value is written. - let (text, cut) = clip_value(text); - if cut { - self.clipped_values += 1; - } - // The snapshot's copy and the effect's are charged together, before either is kept: - // past the byte budget or the effect cap the write is left out of both. - let effect = XfaEffect::SetValue { som, value: text.clone() }; - if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { - return true; - } - if let Some(n) = self.nodes.get_mut(i) { - n.value = text; - n.formatted = None; - } - self.record(effect); - true - } -} - -type Shared = RefCell; - -/// The form host of the script running in `ctx`. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) -} - -/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] -/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. -fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { - if depth > MAX_DEPTH || out.len() >= MAX_NODES { - return None; - } - let idx = out.len(); - out.push(HNode { - name: n.name.clone(), - som: n.som.clone(), - kind: n.kind.unwrap_or(XfaKind::Subform), - value: n.value.clone(), - numeric: n.numeric, - presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, - access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - formatted: n.formatted.clone(), - }); - let mut kids = Vec::new(); - for c in &n.children { - if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { - kids.push(ci); - } - } - if let Some(h) = out.get_mut(idx) { - h.children = kids; - } - Some(idx) -} - -// ── node objects ──────────────────────────────────────────────────────────────────────────── - -const IDX: &str = "__xfaNode"; - -fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { - let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; - let v = o.get(JsString::from(IDX), ctx)?; - let n = v.to_number(ctx)?; - if !n.is_finite() || n < 0.0 { - return Err(error("not an XFA node")); - } - Ok(n as usize) -} - -/// A Proxy standing for node `idx`. -fn node_object(ctx: &mut Context, idx: usize) -> JsResult { - let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); - let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; - Ok(proxy.into()) -} - -/// An object that takes any property read or write without complaint (`this.border.fill…`, -/// `this.fillColor = …`): what a script sets there changes nothing here. -fn sink(ctx: &mut Context) -> JsResult { - let target = ObjectInitializer::new(ctx).build(); - let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; - Ok(proxy.into()) -} - -fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - match text(&key, ctx)?.as_str() { - "value" | "toString" | "valueOf" => Ok(s("")), - _ => sink(ctx), - } -} - -fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn value_js(h: &HNode) -> JsValue { - if h.value.is_empty() { - return JsValue::null(); - } - if h.numeric - && let Some(n) = as_number(&h.value) - { - return JsValue::from(n); - } - s(&h.value) -} - -/// `(idx, name)` of the child of `parent` named `name`, first instance. -pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { - let p = h.nodes.get(parent)?; - p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) -} - -/// Every live instance named `name` under `parent`, in order. -pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { - h.nodes - .get(parent) - .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) - .unwrap_or_default() -} - -fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { - let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); - let arr = JsArray::from_iter(vals, ctx); - let arr_obj: JsObject = arr.into(); - fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - if !i.is_finite() || i < 0.0 { - return Ok(JsValue::null()); - } - let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; - if v.is_undefined() { - return Ok(JsValue::null()); - } - let idx = v.to_number(ctx)? as usize; - node_object(ctx, idx) - } - let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); - Ok(ObjectInitializer::new(ctx) - .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) - .property(js_string!("item"), item_fn, Attribute::all()) - .build() - .into()) -} - -/// The instance manager of the subform named `name` under `parent`. -fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { - let (count, min, max) = { - let h = host(ctx)?.borrow(); - let live = instances(&h, parent, name); - let first = live.first().and_then(|i| h.nodes.get(*i)); - (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) - }; - let key = ObjectInitializer::new(ctx) - .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) - .property(js_string!("name"), s(name), Attribute::READONLY) - .build(); - fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { - let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; - let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; - Ok((p, n)) - } - fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - match add_instance_of(ctx, p, &n)? { - Some(i) => node_object(ctx, i), - None => Ok(JsValue::null()), - } - } - fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - add_instance(this, args, key, ctx) - } - fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let i = arg(args, 0).to_number(ctx)?; - if i.is_finite() && i >= 0.0 { - remove_instance_of(ctx, p, &n, i as usize)?; - } - Ok(JsValue::undefined()) - } - fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let want = arg(args, 0).to_number(ctx)?; - if !want.is_finite() || want < 0.0 { - return Ok(JsValue::undefined()); - } - host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); - Ok(JsValue::undefined()) - } - fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) - } - fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let c = instances(&host(ctx)?.borrow(), p, &n).len(); - Ok(JsValue::from(c as f64)) - } - fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - set_instances(&JsValue::undefined(), args, key, ctx) - } - let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); - let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); - let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); - let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); - let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); - let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); - let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); - let _ = count; - let o = ObjectInitializer::new(ctx) - .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) - .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) - .property(js_string!("addInstance"), add, Attribute::all()) - .property(js_string!("insertInstance"), insert, Attribute::all()) - .property(js_string!("removeInstance"), remove, Attribute::all()) - .property(js_string!("setInstances"), set, Attribute::all()) - .property(js_string!("moveInstance"), mv, Attribute::all()) - .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) - .build(); - use boa_engine::property::PropertyDescriptor; - o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; - Ok(o.into()) -} - -/// Re-derive the SOM of `idx` and everything under it from its parent and index. -fn renumber(h: &mut XHost, idx: usize, depth: usize) { - if depth > MAX_DEPTH { - return; - } - let (parent_som, name, index, kids) = { - let Some(n) = h.nodes.get(idx) else { return }; - let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); - (parent_som, n.name.clone(), n.index, n.children.clone()) - }; - let som = child_som(parent_som, &name, index); - if let Some(n) = h.nodes.get_mut(idx) { - n.som = som; - } - for k in kids { - renumber(h, k, depth + 1); - } -} - -/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. -fn child_som(parent_som: String, name: &str, index: usize) -> String { - if name.starts_with('#') || name.is_empty() { - parent_som - } else if parent_som.is_empty() { - format!("{name}[{index}]") - } else { - format!("{parent_som}.{name}[{index}]") - } -} - -/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. -fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { - if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { - return None; - } - let (template, kids) = { - let n = h.nodes.get(src)?; - ( - HNode { - name: n.name.clone(), - som: String::new(), - kind: n.kind, - value: String::new(), - numeric: n.numeric, - presence: n.presence.clone(), - access: n.access.clone(), - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - // The copy holds no value, so nothing is shown in its picture either. - formatted: None, - }, - n.children.clone(), - ) - }; - let idx = h.nodes.len(); - h.nodes.push(template); - let mut copied = Vec::new(); - for k in kids { - // Nested repeating subforms copy their first instance only. - let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); - if skip { - continue; - } - if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { - copied.push(ci); - } - } - if let Some(n) = h.nodes.get_mut(idx) { - n.children = copied; - } - Some(idx) -} - -fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { - let mut h = host(ctx)?.borrow_mut(); - Ok(h.add_instance(parent, name)) -} - -fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { - let mut h = host(ctx)?.borrow_mut(); - h.remove_instance(parent, name, i); - Ok(()) -} - -impl XHost { - /// A new instance of the subform `name` under `parent`, after the last one; `None` when the - /// subform doesn't repeat, is at its maximum, or the run added its limit. - pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { - let h = self; - let live = instances(h, parent, name); - let &first = live.first()?; - let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { - return None; - } - // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. - let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); - let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); - let som = child_som(parent_som, &name, live.len()); - if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { - return None; - } - let new = clone_subtree(h, first, Some(parent), 0)?; - let last = live.last().copied().unwrap_or(first); - if let Some(n) = h.nodes.get_mut(new) { - n.index = live.len(); - } - // Keep instances together: right after the last one. - if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); - } - renumber(h, new, 0); - h.added += 1; - h.record(XfaEffect::AddInstance { som }); - Some(new) - } - - /// Add or remove instances of the subform `name` under `parent` until there are `want`. - /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that - /// changes nothing ends the loop. - pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { - let want = want.min(MAX_ADDED); - for _ in 0..=MAX_ADDED { - let have = instances(self, parent, name).len(); - if have < want { - if self.add_instance(parent, name).is_none() { - break; - } - } else if have > want && have > 0 { - self.remove_instance(parent, name, have - 1); - if instances(self, parent, name).len() == have { - break; - } - } else { - break; - } - } - } - - /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or - /// below one). - pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { - let h = self; - let live = instances(h, parent, name); - let Some(&gone) = live.get(i) else { return }; - let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); - if live.len() <= min { - return; - } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); - if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { - return; - } - if let Some(n) = h.nodes.get_mut(gone) { - n.gone = true; - } - // Later instances move down one. - for (k, &inst) in live.iter().enumerate().skip(i + 1) { - if let Some(n) = h.nodes.get_mut(inst) { - n.index = k - 1; - } - renumber(h, inst, 0); - } - h.record(XfaEffect::RemoveInstance { som }); - } -} - -/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, -/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. -pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { - let expr = expr.trim(); - if expr.is_empty() { - return vec![from]; - } - let root = 0usize; - let mut cur: Vec = vec![from]; - let mut rest = expr; - let starts = - [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; - for (prefix, at) in starts { - if let Some(r) = rest.strip_prefix(prefix) - && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) - { - cur = vec![at]; - rest = r; - break; - } - } - let mut depth = 0; - while !rest.is_empty() && depth < MAX_DEPTH { - depth += 1; - let descend = rest.starts_with(".."); - rest = rest.trim_start_matches('.'); - let end = rest.find(['.', '[']).unwrap_or(rest.len()); - let (name, after) = rest.split_at(end); - let name = name.trim(); - let (index, after) = match after.strip_prefix('[') { - Some(a) => { - let close = a.find(']').unwrap_or(a.len()); - let idx = a.get(..close).map(str::trim).unwrap_or(""); - (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) - } - None => (None, after), - }; - rest = after; - let mut next = Vec::new(); - for &c in &cur { - match name { - "" => {} - "parent" => { - if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { - next.push(p); - } - } - _ => { - let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; - // A relative name a node has no child for is looked for among its siblings - // and in each enclosing container, nearest first (so a field's script can - // say `resolveNode("otherField")`). - if found.is_empty() && depth == 1 && !descend { - let mut at = h.nodes.get(c).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - found = instances(h, p, name); - if !found.is_empty() { - break; - } - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - } - match index.as_deref() { - Some("*") => next.extend(found), - Some(i) => { - if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { - next.push(*k); - } - } - None => next.extend(found.first()), - } - } - } - } - // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the - // number of rows at every step. - let mut seen = std::collections::HashSet::with_capacity(next.len()); - next.retain(|i| seen.insert(*i)); - cur = next; - if cur.is_empty() { - break; - } - } - cur -} - -/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document -/// order, to [`MAX_DEPTH`]. -pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { - let mut out = Vec::new(); - if depth > MAX_DEPTH { - return out; - } - let Some(n) = h.nodes.get(from) else { return out }; - for &c in &n.children { - if let Some(k) = h.nodes.get(c) - && !k.gone - { - if k.name == name { - out.push(c); - } else { - out.extend(descendants_named(h, c, name, depth + 1)); - } - } - } - out -} - -/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows -/// it), attributes, relatives, methods, instance managers and children by name. -fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - let key = text(&key, ctx)?; - let snapshot = { - let h = host(ctx)?.borrow(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; - (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) - }; - let (name, som, kind, value, presence, access, index, parent, children) = snapshot; - Ok(match key.as_str() { - IDX => JsValue::from(idx as f64), - "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), - "name" => s(&name), - "somExpression" => s(&som), - "className" => s(kind.class_name()), - "index" | "instanceIndex" => JsValue::from(index as f64), - "presence" => s(&presence), - "access" => s(&access), - "isNull" => JsValue::from(value.is_empty()), - "mandatory" => s("disabled"), - "parent" => match parent { - Some(p) => node_object(ctx, p)?, - None => JsValue::null(), - }, - "nodes" => { - let live: Vec = { - let h = host(ctx)?.borrow(); - children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() - }; - list_object(ctx, &live)? - } - "all" => { - let live = match parent { - Some(p) => instances(&host(ctx)?.borrow(), p, &name), - None => vec![idx], - }; - list_object(ctx, &live)? - } - "instanceManager" => match parent { - Some(p) => instance_manager(ctx, p, &name)?, - None => JsValue::undefined(), - }, - "resolveNode" => resolver(ctx, idx, false)?, - "resolveNodes" => resolver(ctx, idx, true)?, - "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), - "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), - "getAttribute" - | "setAttribute" - | "execEvent" - | "execInitialize" - | "execCalculate" - | "execValidate" - | "getElement" - | "setElement" - | "isPropertySpecified" - | "loadXML" - | "saveXML" - | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), - "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), - "length" => JsValue::undefined(), - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" - | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" - | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" - | "desc" => sink(ctx)?, - other => { - if let Some(child) = other.strip_prefix('_') { - let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); - return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; - } - let found = child_named(&host(ctx)?.borrow(), idx, other); - match found { - Some(c) => node_object(ctx, c)?, - None => JsValue::undefined(), - } - } - }) -} - -fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::from(true)); - } - let key = text(&key, ctx)?; - let value = arg(args, 2); - let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; - let mut h = host(ctx)?.borrow_mut(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; - let som = n.som.clone(); - match key.as_str() { - "rawValue" | "value" | "formattedValue" => { - // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. - h.set_value(idx, value); - } - "presence" => { - let p = match value.as_str() { - "hidden" | "invisible" | "inactive" | "visible" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); - } - "access" => { - let a = match value.as_str() { - "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetAccess { som, access: a }); - } - _ => {} - } - Ok(JsValue::from(true)) -} - -fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(this, ctx)?; - Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. -fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { - let key = ObjectInitializer::new(ctx) - .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) - .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) - .build(); - fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; - let many = key.get(js_string!("many"), ctx)?.to_boolean(); - let expr = text(&arg(args, 0), ctx)?; - let found = resolve_som(&host(ctx)?.borrow(), from, &expr); - if many { - return list_object(ctx, &found); - } - match found.first() { - Some(i) => node_object(ctx, *i), - None => Ok(JsValue::null()), - } - } - Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) -} - -// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── - -/// Records effect `e` on the host, as [`XHost::push`] does. -fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { - host(ctx)?.borrow_mut().push(e); - Ok(()) -} - -/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and -/// returns 1. -fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - effect(ctx, XfaEffect::MessageBox(m))?; - Ok(JsValue::from(1.0)) -} - -/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when -/// the list is empty. -fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let list = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.print(…)`: asks to print; its arguments are ignored. -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Print)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.beep()`: asks to beep. -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Beep)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.gotoURL(url)`: asks to open the URL. -fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::LaunchUrl(u))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM -/// expression resolved from the current node; an expression that names no node is passed on -/// as written. -fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let target = arg(args, 0); - let som = match target.as_object() { - Some(_) if node_index(&target, ctx).is_ok() => { - let i = node_index(&target, ctx)?; - host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() - } - _ => { - let expr = text(&target, ctx)?; - let h = host(ctx)?.borrow(); - let found = resolve_som(&h, h.current, &expr); - found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) - } - }; - effect(ctx, XfaEffect::SetFocus(som))?; - Ok(JsValue::undefined()) -} - -/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives 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)?; - match crate::menu_item(&item) { - Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, - Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, - Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, - _ => {} - } - Ok(JsValue::undefined()) -} - -fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Recalculate)?; - Ok(JsValue::undefined()) -} - -fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Relayout)?; - Ok(JsValue::undefined()) -} - -fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::null()) -} - -fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) -} - -fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) -} - -fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(0.0)) -} - -fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { - let current = host(ctx)?.borrow().current; - resolver(ctx, current, many) -} - -/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. -fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { - let rw = Attribute::CONFIGURABLE; - let form = node_object(ctx, 0)?; - let this_node = node_object(ctx, current)?; - let host_obj = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) - .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) - .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) - .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) - .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) - .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) - .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) - .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) - .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("version"), s("11"), Attribute::READONLY) - .property(js_string!("variation"), s("Full"), Attribute::READONLY) - .property(js_string!("language"), s("en"), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) - .property(js_string!("validationsEnabled"), JsValue::from(true), rw) - .property(js_string!("title"), s(&doc.file_name), rw) - .build(); - let layout = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) - .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) - .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) - .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) - .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); - let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); - let template = sink(ctx)?; - let resolve_one = xfa_resolve(ctx, false)?; - let resolve_many = xfa_resolve(ctx, true)?; - let xfa = ObjectInitializer::new(ctx) - .property(js_string!("form"), form.clone(), rw) - .property(js_string!("host"), host_obj, rw) - .property(js_string!("layout"), layout, rw) - .property(js_string!("event"), ev.clone(), rw) - .property(js_string!("datasets"), datasets, rw) - .property(js_string!("record"), form.clone(), rw) - .property(js_string!("template"), template, rw) - .property(js_string!("resolveNode"), resolve_one, rw) - .property(js_string!("resolveNodes"), resolve_many, rw) - .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) - .build(); - ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; - ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(noop), 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!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(console_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(noop), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, 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)?; - // Unqualified names: the current node's children, its siblings, then each ancestor's - // children, the nearest first. Instance managers as `_name`. - let scopes: Vec = { - let h = host(ctx)?.borrow(); - let mut out = vec![current]; - let mut at = h.nodes.get(current).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - out.push(p); - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - out - }; - // Names that stay what JavaScript (or this host) means by them: a field called `Date` - // is reached as `this.parent.Date` or by resolveNode. - const RESERVED: &[&str] = &[ - "xfa", - "event", - "app", - "console", - "util", - "this", - "__xfa_this", - "__xfa_src", - "eval", - "arguments", - "globalThis", - "window", - "undefined", - "NaN", - "Infinity", - "Object", - "Function", - "Array", - "Number", - "String", - "Boolean", - "Symbol", - "Date", - "RegExp", - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "Math", - "JSON", - "Map", - "Set", - "WeakMap", - "WeakSet", - "Promise", - "Proxy", - "Reflect", - "parseInt", - "parseFloat", - "isNaN", - "isFinite", - "encodeURIComponent", - "decodeURIComponent", - "encodeURI", - "decodeURI", - "escape", - "unescape", - "BigInt", - "ArrayBuffer", - "DataView", - "Intl", - ]; - let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); - for scope in scopes { - let kids: Vec<(usize, String, bool)> = { - let h = host(ctx)?.borrow(); - h.nodes - .get(scope) - .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) - .unwrap_or_default() - }; - for (c, name, repeatable) in kids { - if !is_identifier(&name) || defined.contains(&name) { - continue; - } - let obj = node_object(ctx, c)?; - ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; - if repeatable { - let im = instance_manager(ctx, scope, &name)?; - ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; - } - defined.push(name); - } - } - Ok(()) -} - -fn is_identifier(name: &str) -> bool { - let mut chars = name.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') - && !matches!( - name, - "var" - | "let" - | "const" - | "function" - | "return" - | "if" - | "else" - | "for" - | "while" - | "new" - | "null" - | "true" - | "false" - | "undefined" - | "typeof" - ) -} - -/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. -pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-xfa-js".into()) - .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), 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, root.clone(), limits, &|| {}) -} - -/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready -/// (loops inside nested function calls can run for hours within the engine's per-frame loop -/// limit) is left on its thread, which ends when the engine's own limits stop it, and an -/// outcome with `abandoned` set comes back at once. In the browser build there are no -/// threads: the script runs to its limits. -pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { - if let Some(why) = refuse(script) { - return XfaOutcome { error: Some(why), ..Default::default() }; - } - let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) -} - -/// Longest the script engine may take to be ready for a script (its thread and context, and -/// for the first script in a process the engine's own set-up) before the script is abandoned. -/// It doesn't count against the script's time: on a busy machine the start alone can take -/// longer than the tightest limit, and abandoning a script turns the form's scripts off. -pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); - -/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its -/// outcome, counted from when it calls `started` (once the engine is ready); past that the -/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs -/// here. -pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { - run_within_limits(name, ENGINE_START_LIMIT, timeout, script) -} - -/// [`run_within`] with the engine's start-up limit given. -pub(crate) fn run_within_limits( - name: &str, - start_limit: std::time::Duration, - timeout: std::time::Duration, - script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, -) -> XfaOutcome { - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::mpsc::RecvTimeoutError; - use std::time::Instant; - enum Step { - Started, - Done(XfaOutcome), - } - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; - let (tx, rx) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { - // The receiver is gone when the caller stopped waiting: nothing to report then. - let started = tx.clone(); - let outcome = script(&move || { - let _ = started.send(Step::Started); - }); - let _ = tx.send(Step::Done(outcome)); - }); - if let Err(e) = spawned { - return failed(format!("the script engine could not start: {e}")); - } - // Wait for the engine within its start-up limit, then for the script within its own. - let (mut running, mut limit) = (false, start_limit); - let mut deadline = Instant::now().checked_add(limit); - loop { - let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); - match rx.recv_timeout(left) { - Ok(Step::Done(o)) => return o, - Ok(Step::Started) => { - // A second call changes nothing: the script's time has already begun. - if !running { - (running, limit) = (true, timeout); - deadline = Instant::now().checked_add(limit); - } - } - Err(RecvTimeoutError::Timeout) if running => { - return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); - } - Err(RecvTimeoutError::Timeout) => { - return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); - } - Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), - } - } - } - #[cfg(target_arch = "wasm32")] - { - let _ = (name, start_limit, timeout); - script(&|| {}) - } -} - -/// The first script in a process sets the engine up (its built-ins, parser and compiler come -/// into use): a trivial script takes that on, so a form's script is never timed with it. -pub(crate) fn warm_up() { - static WARM: std::sync::Once = std::sync::Once::new(); - // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. - WARM.call_once_force(|_| { - let mut ctx = Context::default(); - let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); - }); -} - -/// `started` is called once the engine is ready: a time limit on the script runs from then. -pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { - warm_up(); - let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { - Ok(ctx) => ctx, - Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, - }; - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - let host_state = XHost::new(root, &event.target, doc); - let current = host_state.current; - ctx.insert_data(RefCell::new(host_state)); - let mut out = XfaOutcome::default(); - let result = (|| -> JsResult<()> { - install(&mut ctx, event, doc, current)?; - // Ready for the script: its time starts now, the set-up above not charged to it. - started(); - ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; - // Direct eval inside a function called on the node: `this` is the node and the - // completion value is the script's last expression (what a calculate script yields). - let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; - if !r.is_undefined() { - // Best effort: a value that can't be made text (a symbol, say) is no failure. - out.result = Some(text(&r, &mut ctx).unwrap_or_default()); - if r.is_boolean() { - out.result_bool = Some(r.to_boolean()); - } else if r.is_null() { - out.result = Some(String::new()); - } - } - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.notes = h.notes(); - out.effects = h.effects; - out.console = h.console; - } - clip_result(&mut out); - out -} +mod globals; +mod host; +mod instances; +mod model; +mod nodes; +mod run; + +pub(crate) use host::{HNode, XHost}; +pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome}; +pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names}; +pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som}; +#[cfg(test)] +pub(crate) use run::run_within_limits; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use run::{ENGINE_START_LIMIT, warm_up}; +pub(crate) use run::{run_here, run_within}; +pub use run::{run_xfa, run_xfa_within}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs new file mode 100644 index 000000000..250794165 --- /dev/null +++ b/crates/js/src/xfa/globals.rs @@ -0,0 +1,362 @@ +use super::host::host; +use super::instances::instance_manager; +use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; +use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; +use super::*; + +// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── + +/// Records effect `e` on the host, as [`XHost::push`] does. +fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { + host(ctx)?.borrow_mut().push(e); + Ok(()) +} + +/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and +/// returns 1. +fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + effect(ctx, XfaEffect::MessageBox(m))?; + Ok(JsValue::from(1.0)) +} + +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. +fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let list = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Print)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.beep()`: asks to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Beep)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.gotoURL(url)`: asks to open the URL. +fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::LaunchUrl(u))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. +fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let target = arg(args, 0); + let som = match target.as_object() { + Some(_) if node_index(&target, ctx).is_ok() => { + let i = node_index(&target, ctx)?; + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + } + _ => { + let expr = text(&target, ctx)?; + let h = host(ctx)?.borrow(); + let found = resolve_som(&h, h.current, &expr); + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + } + }; + effect(ctx, XfaEffect::SetFocus(som))?; + Ok(JsValue::undefined()) +} + +/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives 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)?; + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, + _ => {} + } + Ok(JsValue::undefined()) +} + +pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Recalculate)?; + Ok(JsValue::undefined()) +} + +pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Relayout)?; + Ok(JsValue::undefined()) +} + +fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::null()) +} + +fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) +} + +fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) +} + +fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(0.0)) +} + +fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { + let current = host(ctx)?.borrow().current; + resolver(ctx, current, many) +} + +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { + let rw = Attribute::CONFIGURABLE; + let form = node_object(ctx, 0)?; + let this_node = node_object(ctx, current)?; + let host_obj = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) + .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) + .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) + .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) + .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) + .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) + .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) + .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) + .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("version"), s("11"), Attribute::READONLY) + .property(js_string!("variation"), s("Full"), Attribute::READONLY) + .property(js_string!("language"), s("en"), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) + .property(js_string!("validationsEnabled"), JsValue::from(true), rw) + .property(js_string!("title"), s(&doc.file_name), rw) + .build(); + let layout = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) + .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) + .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) + .build(); + let ev = ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), this_node.clone(), Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build(); + let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); + let template = sink(ctx)?; + let resolve_one = xfa_resolve(ctx, false)?; + let resolve_many = xfa_resolve(ctx, true)?; + let xfa = ObjectInitializer::new(ctx) + .property(js_string!("form"), form.clone(), rw) + .property(js_string!("host"), host_obj, rw) + .property(js_string!("layout"), layout, rw) + .property(js_string!("event"), ev.clone(), rw) + .property(js_string!("datasets"), datasets, rw) + .property(js_string!("record"), form.clone(), rw) + .property(js_string!("template"), template, rw) + .property(js_string!("resolveNode"), resolve_one, rw) + .property(js_string!("resolveNodes"), resolve_many, rw) + .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) + .build(); + ctx.register_global_property(js_string!("xfa"), xfa, rw)?; + ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(noop), 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!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(console_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(noop), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, 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)?; + // Unqualified names: the current node's children, its siblings, then each ancestor's + // children, the nearest first. Instance managers as `_name`. + let scopes: Vec = { + let h = host(ctx)?.borrow(); + let mut out = vec![current]; + let mut at = h.nodes.get(current).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + out.push(p); + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + out + }; + // Names that stay what JavaScript (or this host) means by them: a field called `Date` + // is reached as `this.parent.Date` or by resolveNode. + const RESERVED: &[&str] = &[ + "xfa", + "event", + "app", + "console", + "util", + "this", + "__xfa_this", + "__xfa_src", + "eval", + "arguments", + "globalThis", + "window", + "undefined", + "NaN", + "Infinity", + "Object", + "Function", + "Array", + "Number", + "String", + "Boolean", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Math", + "JSON", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Promise", + "Proxy", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "escape", + "unescape", + "BigInt", + "ArrayBuffer", + "DataView", + "Intl", + ]; + let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); + for scope in scopes { + let kids: Vec<(usize, String, bool)> = { + let h = host(ctx)?.borrow(); + h.nodes + .get(scope) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .unwrap_or_default() + }; + for (c, name, repeatable) in kids { + if !is_identifier(&name) || defined.contains(&name) { + continue; + } + let obj = node_object(ctx, c)?; + ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; + if repeatable { + let im = instance_manager(ctx, scope, &name)?; + ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; + } + defined.push(name); + } + } + Ok(()) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + && !matches!( + name, + "var" + | "let" + | "const" + | "function" + | "return" + | "if" + | "else" + | "for" + | "while" + | "new" + | "null" + | "true" + | "false" + | "undefined" + | "typeof" + ) +} diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs new file mode 100644 index 000000000..419d2141f --- /dev/null +++ b/crates/js/src/xfa/host.rs @@ -0,0 +1,286 @@ +use super::model::{ + MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, + effect_key, payload, +}; +use super::*; + +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +pub(crate) struct HNode { + pub(crate) name: String, + pub(crate) som: String, + pub(crate) kind: XfaKind, + pub(crate) value: String, + pub(crate) numeric: bool, + pub(crate) presence: String, + pub(crate) access: String, + pub(crate) repeatable: bool, + pub(crate) occur_min: usize, + pub(crate) occur_max: Option, + pub(crate) index: usize, + pub(crate) parent: Option, + pub(crate) children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, +} + +/// The form as a script sees and changes it, with the effects it records. +pub(crate) struct XHost { + pub(crate) nodes: Vec, + pub(crate) effects: Vec, + pub(crate) console: Vec, + pub(crate) doc: XfaDoc, + pub(super) added: usize, + /// Index of `this`. + pub(crate) current: usize, + /// Where the effect setting a property of an object is in `effects`, by (property, SOM): + /// setting it again replaces that effect (the last value wins) unless something that + /// depends on order (rows added or removed, a reset) came after it. + set_at: HashMap<(u8, String), usize>, + /// Effects before this index are behind an order-dependent effect. + barrier: usize, + alerts: usize, + dropped_effects: usize, + clipped_values: usize, + dropped_alerts: usize, + dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, +} + +impl XHost { + /// The host for a script on `root` (the template's root subform), with `this` at the node + /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. + pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + clipped_values: 0, + dropped_alerts: 0, + dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// 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 <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } + } + } + + /// Record an effect: values, presence and access set again on the same object replace the + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. + pub(crate) fn push(&mut self, e: XfaEffect) { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made + /// only when the effect that applies it will be kept, so a script never sees a change its + /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is + /// counted when there is no room. + pub(super) fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { + let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); + if !replaces && self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return false; + } + self.room(bytes) + } + + /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or + /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. + pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { + if !self.reserve(&e, payload(&e)) { + return; + } + if let Some(n) = self.nodes.get_mut(i) { + match &e { + XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), + XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + _ => {} + } + } + self.record(e); + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), + XfaEffect::SetValue { som, value } => { + let (value, cut) = clip_value(value); + if cut { + self.clipped_values += 1; + } + XfaEffect::SetValue { som, value } + } + other => other, + } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + pub(super) fn record(&mut self, e: XfaEffect) { + if let Some(key) = effect_key(&e) { + if let Some(&at) = self.set_at.get(&key) + && at >= self.barrier + && let Some(slot) = self.effects.get_mut(at) + { + *slot = e; + return; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + self.set_at.insert(key, self.effects.len()); + self.effects.push(e); + return; + } + if matches!(e, XfaEffect::MessageBox(_)) { + if self.alerts >= MAX_ALERTS { + self.dropped_alerts += 1; + return; + } + self.alerts += 1; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { + self.barrier = self.effects.len() + 1; + } + self.effects.push(e); + } + + pub(crate) fn print(&mut self, line: String) { + if self.console.len() >= MAX_CONSOLE { + self.dropped_console += 1; + } else if self.room(line.len()) { + self.console.push(line); + } + } + + /// What the limits left out. + pub(crate) fn notes(&self) -> Vec { + let mut out = Vec::new(); + if self.dropped_effects > 0 { + out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); + } + if self.dropped_alerts > 0 { + out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); + } + if self.dropped_console > 0 { + out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); + } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } + if self.clipped_values > 0 { + out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); + } + out + } + + /// Give node `i` the value `text` when it is a field or exclusion group (nothing else + /// holds a value; writing one into a subform would put text where another viewer expects + /// a data group). Returns whether it was. + pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { + let Some(n) = self.nodes.get(i) else { return false }; + if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { + return false; + } + let som = n.som.clone(); + // Cut here, not only in the effect: later reads in the script see what is saved, and + // the snapshot stays bounded however often a long value is written. + let (text, cut) = clip_value(text); + if cut { + self.clipped_values += 1; + } + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget or the effect cap the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text; + n.formatted = None; + } + self.record(effect); + true + } +} + +pub(super) type Shared = RefCell; + +/// The form host of the script running in `ctx`. +pub(super) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) +} + +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. +fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { + if depth > MAX_DEPTH || out.len() >= MAX_NODES { + return None; + } + let idx = out.len(); + out.push(HNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.unwrap_or(XfaKind::Subform), + value: n.value.clone(), + numeric: n.numeric, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, + access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + formatted: n.formatted.clone(), + }); + let mut kids = Vec::new(); + for c in &n.children { + if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { + kids.push(ci); + } + } + if let Some(h) = out.get_mut(idx) { + h.children = kids; + } + Some(idx) +} diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs new file mode 100644 index 000000000..b03e6aef0 --- /dev/null +++ b/crates/js/src/xfa/instances.rs @@ -0,0 +1,279 @@ +use super::host::{HNode, XHost, host}; +use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; +use super::nodes::{instances, node_object}; +use super::*; + +pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { + let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); + let arr = JsArray::from_iter(vals, ctx); + let arr_obj: JsObject = arr.into(); + fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + if !i.is_finite() || i < 0.0 { + return Ok(JsValue::null()); + } + let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; + if v.is_undefined() { + return Ok(JsValue::null()); + } + let idx = v.to_number(ctx)? as usize; + node_object(ctx, idx) + } + let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); + Ok(ObjectInitializer::new(ctx) + .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) + .property(js_string!("item"), item_fn, Attribute::all()) + .build() + .into()) +} + +/// The instance manager of the subform named `name` under `parent`. +pub(super) fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { + let (count, min, max) = { + let h = host(ctx)?.borrow(); + let live = instances(&h, parent, name); + let first = live.first().and_then(|i| h.nodes.get(*i)); + (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) + }; + let key = ObjectInitializer::new(ctx) + .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) + .property(js_string!("name"), s(name), Attribute::READONLY) + .build(); + fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { + let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; + let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; + Ok((p, n)) + } + fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + match add_instance_of(ctx, p, &n)? { + Some(i) => node_object(ctx, i), + None => Ok(JsValue::null()), + } + } + fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + add_instance(this, args, key, ctx) + } + fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let i = arg(args, 0).to_number(ctx)?; + if i.is_finite() && i >= 0.0 { + remove_instance_of(ctx, p, &n, i as usize)?; + } + Ok(JsValue::undefined()) + } + fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let want = arg(args, 0).to_number(ctx)?; + if !want.is_finite() || want < 0.0 { + return Ok(JsValue::undefined()); + } + host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); + Ok(JsValue::undefined()) + } + fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) + } + fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let c = instances(&host(ctx)?.borrow(), p, &n).len(); + Ok(JsValue::from(c as f64)) + } + fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + set_instances(&JsValue::undefined(), args, key, ctx) + } + let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); + let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); + let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); + let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); + let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); + let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); + let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); + let _ = count; + let o = ObjectInitializer::new(ctx) + .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) + .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) + .property(js_string!("addInstance"), add, Attribute::all()) + .property(js_string!("insertInstance"), insert, Attribute::all()) + .property(js_string!("removeInstance"), remove, Attribute::all()) + .property(js_string!("setInstances"), set, Attribute::all()) + .property(js_string!("moveInstance"), mv, Attribute::all()) + .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) + .build(); + use boa_engine::property::PropertyDescriptor; + o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; + Ok(o.into()) +} + +/// Re-derive the SOM of `idx` and everything under it from its parent and index. +fn renumber(h: &mut XHost, idx: usize, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let (parent_som, name, index, kids) = { + let Some(n) = h.nodes.get(idx) else { return }; + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + (parent_som, n.name.clone(), n.index, n.children.clone()) + }; + let som = child_som(parent_som, &name, index); + if let Some(n) = h.nodes.get_mut(idx) { + n.som = som; + } + for k in kids { + renumber(h, k, depth + 1); + } +} + +/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. +fn child_som(parent_som: String, name: &str, index: usize) -> String { + if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + } +} + +/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. +fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { + if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { + return None; + } + let (template, kids) = { + let n = h.nodes.get(src)?; + ( + HNode { + name: n.name.clone(), + som: String::new(), + kind: n.kind, + value: String::new(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, + }, + n.children.clone(), + ) + }; + let idx = h.nodes.len(); + h.nodes.push(template); + let mut copied = Vec::new(); + for k in kids { + // Nested repeating subforms copy their first instance only. + let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); + if skip { + continue; + } + if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { + copied.push(ci); + } + } + if let Some(n) = h.nodes.get_mut(idx) { + n.children = copied; + } + Some(idx) +} + +fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { + let mut h = host(ctx)?.borrow_mut(); + Ok(h.add_instance(parent, name)) +} + +fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { + let mut h = host(ctx)?.borrow_mut(); + h.remove_instance(parent, name, i); + Ok(()) +} + +impl XHost { + /// A new instance of the subform `name` under `parent`, after the last one; `None` when the + /// subform doesn't repeat, is at its maximum, or the run added its limit. + pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { + let h = self; + let live = instances(h, parent, name); + let &first = live.first()?; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { + return None; + } + // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. + let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let som = child_som(parent_som, &name, live.len()); + if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { + return None; + } + let new = clone_subtree(h, first, Some(parent), 0)?; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(h, new, 0); + h.added += 1; + h.record(XfaEffect::AddInstance { som }); + Some(new) + } + + /// Add or remove instances of the subform `name` under `parent` until there are `want`. + /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that + /// changes nothing ends the loop. + pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { + let want = want.min(MAX_ADDED); + for _ in 0..=MAX_ADDED { + let have = instances(self, parent, name).len(); + if have < want { + if self.add_instance(parent, name).is_none() { + break; + } + } else if have > want && have > 0 { + self.remove_instance(parent, name, have - 1); + if instances(self, parent, name).len() == have { + break; + } + } else { + break; + } + } + } + + /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or + /// below one). + pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { + let h = self; + let live = instances(h, parent, name); + let Some(&gone) = live.get(i) else { return }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return; + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { + return; + } + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(h, inst, 0); + } + h.record(XfaEffect::RemoveInstance { som }); + } +} diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs new file mode 100644 index 000000000..b9c6cedaa --- /dev/null +++ b/crates/js/src/xfa/model.rs @@ -0,0 +1,228 @@ +use super::*; + +/// What kind of template object a node is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XfaKind { + /// `xfa.form` itself. + Form, + Subform, + Field, + ExclGroup, + Draw, + Area, +} + +impl XfaKind { + pub(crate) fn class_name(self) -> &'static str { + match self { + XfaKind::Form => "form", + XfaKind::Subform => "subform", + XfaKind::Field => "field", + XfaKind::ExclGroup => "exclGroup", + XfaKind::Draw => "draw", + XfaKind::Area => "area", + } + } +} + +/// One object of the form, as the caller snapshots it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaNode { + pub name: String, + /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). + pub som: String, + pub kind: Option, + /// Fields: the raw value (ISO dates, on values for buttons); empty when null. + pub value: String, + /// Numeric fields give numbers to scripts. + pub numeric: bool, + /// `visible`, `invisible`, `hidden` or `inactive`. + pub presence: String, + /// `open`, `readOnly`, `protected` or `nonInteractive`. + pub access: String, + /// Subforms: may have several instances (`occur max` other than 1). + pub repeatable: bool, + pub occur_min: usize, + pub occur_max: Option, + /// This instance's index among its same-named siblings. + pub index: usize, + pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, +} + +/// The event a script runs for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaEvent { + /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … + pub activity: String, + /// The SOM of `this`. + pub target: String, + pub new_text: String, + pub prev_text: String, +} + +/// About the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaDoc { + pub file_name: String, + /// The current page, 0-based. + pub page: usize, + pub page_count: usize, +} + +/// Something a script changed or asked for. +#[derive(Clone, Debug, PartialEq)] +pub enum XfaEffect { + SetValue { + som: String, + value: String, + }, + SetPresence { + som: String, + presence: String, + }, + SetAccess { + som: String, + access: String, + }, + /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). + AddInstance { + som: String, + }, + /// The instance at this SOM is removed. + RemoveInstance { + som: String, + }, + MessageBox(String), + /// `xfa.host.resetData()`: the named fields, or every field when empty. + ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), + Print, + SaveAs, + LaunchUrl(String), + SetFocus(String), + Beep, + /// `xfa.form.recalculate(true)`. + Recalculate, + /// `xfa.form.remerge()`, `xfa.layout.relayout()`. + Relayout, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaOutcome { + pub effects: Vec, + pub console: Vec, + /// The script's error, if it failed. + pub error: Option, + /// The script's completion value (a calculate script's result; a validate script's verdict). + pub result: Option, + /// The completion value as a boolean, when it was one. + pub result_bool: Option, + /// Limits the script ran into (too many effects, message boxes or console lines): what + /// was left out, for the caller to report. + pub notes: Vec, + /// The script ran past its time limit and was left running on its own thread (the engine + /// can't interrupt it): the caller should run no more of this form's scripts. + pub abandoned: bool, +} + +/// Most nodes a snapshot may hold. +pub(super) const MAX_NODES: usize = 200_000; + +/// Most instances a script may add in one run. +pub(crate) const MAX_ADDED: usize = 1_000; + +/// Deepest tree followed. +pub(super) const MAX_DEPTH: usize = 64; + +/// Most effects one run may produce (after values set again on the same object are merged). +pub const MAX_EFFECTS: usize = 10_000; + +/// Most message boxes one run may show. +pub const MAX_ALERTS: usize = 100; + +/// Most console lines one run may print. +pub const MAX_CONSOLE: usize = 1_000; + +/// Longest message or console line kept, in characters. +pub(crate) const MAX_LINE: usize = 4_096; + +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; + +/// Longest value a script may put in a field (characters); longer ones are cut and noted. +pub(super) const MAX_VALUE: usize = 65_536; + +/// `m` cut to [`MAX_LINE`] characters. +pub(crate) fn clip(m: String) -> String { + match m.char_indices().nth(MAX_LINE) { + Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), + None => m, + } +} + +/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. +pub(crate) fn clip_value(v: String) -> (String, bool) { + match v.char_indices().nth(MAX_VALUE) { + Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), + None => (v, false), + } +} + +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// The (property, SOM) an effect setting a property of an object is merged by, see +/// [`XHost::set_at`]. +pub(super) fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { + match e { + XfaEffect::SetValue { som, .. } => Some((0, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + } +} + +/// Bytes of text `e` holds. +pub(super) fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, + } +} + +/// A script's completion value (what a calculate script puts in its field) cut to +/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. +pub(crate) fn clip_result(out: &mut XfaOutcome) { + if let Some(r) = out.result.take() { + let (r, cut) = clip_value(r); + if cut { + out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); + } + out.result = Some(r); + } +} diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs new file mode 100644 index 000000000..b9483f25d --- /dev/null +++ b/crates/js/src/xfa/nodes.rs @@ -0,0 +1,343 @@ +use super::globals::{recalculate, relayout}; +use super::host::{HNode, XHost, host}; +use super::instances::{instance_manager, list_object}; +use super::model::{MAX_DEPTH, XfaEffect}; +use super::*; + +// ── node objects ──────────────────────────────────────────────────────────────────────────── + +const IDX: &str = "__xfaNode"; + +pub(super) fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { + let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; + let v = o.get(JsString::from(IDX), ctx)?; + let n = v.to_number(ctx)?; + if !n.is_finite() || n < 0.0 { + return Err(error("not an XFA node")); + } + Ok(n as usize) +} + +/// A Proxy standing for node `idx`. +pub(super) fn node_object(ctx: &mut Context, idx: usize) -> JsResult { + let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); + let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; + Ok(proxy.into()) +} + +/// An object that takes any property read or write without complaint (`this.border.fill…`, +/// `this.fillColor = …`): what a script sets there changes nothing here. +pub(super) fn sink(ctx: &mut Context) -> JsResult { + let target = ObjectInitializer::new(ctx).build(); + let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; + Ok(proxy.into()) +} + +fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + match text(&key, ctx)?.as_str() { + "value" | "toString" | "valueOf" => Ok(s("")), + _ => sink(ctx), + } +} + +fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn value_js(h: &HNode) -> JsValue { + if h.value.is_empty() { + return JsValue::null(); + } + if h.numeric + && let Some(n) = as_number(&h.value) + { + return JsValue::from(n); + } + s(&h.value) +} + +/// `(idx, name)` of the child of `parent` named `name`, first instance. +pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { + let p = h.nodes.get(parent)?; + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) +} + +/// Every live instance named `name` under `parent`, in order. +pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { + h.nodes + .get(parent) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .unwrap_or_default() +} + +/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, +/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. +pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { + let expr = expr.trim(); + if expr.is_empty() { + return vec![from]; + } + let root = 0usize; + let mut cur: Vec = vec![from]; + let mut rest = expr; + let starts = + [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; + for (prefix, at) in starts { + if let Some(r) = rest.strip_prefix(prefix) + && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) + { + cur = vec![at]; + rest = r; + break; + } + } + let mut depth = 0; + while !rest.is_empty() && depth < MAX_DEPTH { + depth += 1; + let descend = rest.starts_with(".."); + rest = rest.trim_start_matches('.'); + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + let name = name.trim(); + let (index, after) = match after.strip_prefix('[') { + Some(a) => { + let close = a.find(']').unwrap_or(a.len()); + let idx = a.get(..close).map(str::trim).unwrap_or(""); + (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) + } + None => (None, after), + }; + rest = after; + let mut next = Vec::new(); + for &c in &cur { + match name { + "" => {} + "parent" => { + if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { + next.push(p); + } + } + _ => { + let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; + // A relative name a node has no child for is looked for among its siblings + // and in each enclosing container, nearest first (so a field's script can + // say `resolveNode("otherField")`). + if found.is_empty() && depth == 1 && !descend { + let mut at = h.nodes.get(c).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + found = instances(h, p, name); + if !found.is_empty() { + break; + } + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + } + match index.as_deref() { + Some("*") => next.extend(found), + Some(i) => { + if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { + next.push(*k); + } + } + None => next.extend(found.first()), + } + } + } + } + // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the + // number of rows at every step. + let mut seen = std::collections::HashSet::with_capacity(next.len()); + next.retain(|i| seen.insert(*i)); + cur = next; + if cur.is_empty() { + break; + } + } + cur +} + +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. +pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let Some(n) = h.nodes.get(from) else { return out }; + for &c in &n.children { + if let Some(k) = h.nodes.get(c) + && !k.gone + { + if k.name == name { + out.push(c); + } else { + out.extend(descendants_named(h, c, name, depth + 1)); + } + } + } + out +} + +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. +fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + let key = text(&key, ctx)?; + let snapshot = { + let h = host(ctx)?.borrow(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; + (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) + }; + let (name, som, kind, value, presence, access, index, parent, children) = snapshot; + Ok(match key.as_str() { + IDX => JsValue::from(idx as f64), + "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), + "name" => s(&name), + "somExpression" => s(&som), + "className" => s(kind.class_name()), + "index" | "instanceIndex" => JsValue::from(index as f64), + "presence" => s(&presence), + "access" => s(&access), + "isNull" => JsValue::from(value.is_empty()), + "mandatory" => s("disabled"), + "parent" => match parent { + Some(p) => node_object(ctx, p)?, + None => JsValue::null(), + }, + "nodes" => { + let live: Vec = { + let h = host(ctx)?.borrow(); + children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() + }; + list_object(ctx, &live)? + } + "all" => { + let live = match parent { + Some(p) => instances(&host(ctx)?.borrow(), p, &name), + None => vec![idx], + }; + list_object(ctx, &live)? + } + "instanceManager" => match parent { + Some(p) => instance_manager(ctx, p, &name)?, + None => JsValue::undefined(), + }, + "resolveNode" => resolver(ctx, idx, false)?, + "resolveNodes" => resolver(ctx, idx, true)?, + "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), + "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), + "getAttribute" + | "setAttribute" + | "execEvent" + | "execInitialize" + | "execCalculate" + | "execValidate" + | "getElement" + | "setElement" + | "isPropertySpecified" + | "loadXML" + | "saveXML" + | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), + "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), + "length" => JsValue::undefined(), + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" + | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" + | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" + | "desc" => sink(ctx)?, + other => { + if let Some(child) = other.strip_prefix('_') { + let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); + return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; + } + let found = child_named(&host(ctx)?.borrow(), idx, other); + match found { + Some(c) => node_object(ctx, c)?, + None => JsValue::undefined(), + } + } + }) +} + +fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::from(true)); + } + let key = text(&key, ctx)?; + let value = arg(args, 2); + let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; + let mut h = host(ctx)?.borrow_mut(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; + let som = n.som.clone(); + match key.as_str() { + "rawValue" | "value" | "formattedValue" => { + // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. + h.set_value(idx, value); + } + "presence" => { + let p = match value.as_str() { + "hidden" | "invisible" | "inactive" | "visible" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); + } + "access" => { + let a = match value.as_str() { + "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetAccess { som, access: a }); + } + _ => {} + } + Ok(JsValue::from(true)) +} + +fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(this, ctx)?; + Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) +} + +pub(super) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. +pub(super) fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { + let key = ObjectInitializer::new(ctx) + .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) + .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) + .build(); + fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; + let many = key.get(js_string!("many"), ctx)?.to_boolean(); + let expr = text(&arg(args, 0), ctx)?; + let found = resolve_som(&host(ctx)?.borrow(), from, &expr); + if many { + return list_object(ctx, &found); + } + match found.first() { + Some(i) => node_object(ctx, *i), + None => Ok(JsValue::null()), + } + } + Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) +} diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs new file mode 100644 index 000000000..2db77d706 --- /dev/null +++ b/crates/js/src/xfa/run.rs @@ -0,0 +1,170 @@ +use super::globals::install; +use super::host::{Shared, XHost}; +use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; +use super::*; + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), 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, root.clone(), limits, &|| {}) +} + +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + if let Some(why) = refuse(script) { + return XfaOutcome { error: Some(why), ..Default::default() }; + } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) +} + +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (name, start_limit, timeout); + script(&|| {}) + } +} + +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +pub(crate) fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + clip_result(&mut out); + out +}