Files
pdfcraft/crates/js/src/source.rs
T
FredAppsandDWAA1660 059dbd6e00 feat(forms): open, Will Save and Will Print actions, widget pointer/focus scripts, setItems/setAction/toSource (port of #344) (#6969)
* feat(js, forms): field setItems, setAction and a bounded toSource (#269)

Ported from #344 by @DWAA1660 onto the per-document script session.

- `field.setItems(items)` replaces a combo or list box's items (text, or
  [text, export] pairs). The value stays when an item still has it, and
  otherwise becomes the first item's. Written as the field's /Opt (pairs
  only where the export value differs), /I dropped, field redrawn.
- `field.setAction(trigger, script)` installs a JavaScript action for one
  trigger (MouseUp/Down/Enter/Exit, OnFocus/OnBlur, Keystroke, Validate,
  Calculate, Format), replacing only that trigger's action.
- `Object.prototype.toSource()` writes plain data (strings, numbers,
  booleans, null, undefined, arrays, objects) that eval reads back. It
  refuses functions, cycles, more than 64 levels, 10,000 values or 1 MiB
  with an error the script can catch.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(engine): run a form's open, Will Save and Will Print actions and its widgets' pointer and focus scripts (#269)

Ported from #344 by @DWAA1660 onto the per-document script session: each
runs in the document's retained realm, so state the document scripts and
earlier events left is kept.

- The catalog /OpenAction's JavaScript (and its /Next chain, bounded and
  cycle-safe; other action types skipped) runs once on opening, after the
  document scripts, for background opens and reloads too, and again on
  Revert in a fresh realm. Event Doc/Open.
- `Session::run_document_actions` runs /AA /WS (Will Save) or /AA /WP
  (Will Print). A failing action is reported and never stops the save or
  print. doc_save and doc_print run them first and report what they said
  (`will_save`, `will_print`).
- Scripts that run unasked (open, Will Save, Will Print, pointer over,
  focus) may only ask for highlight, zoom, focus or a page; a click's
  Mouse Down / Mouse Up keep their requests.
- `Session::field_trigger` runs a widget's Mouse Enter, Mouse Exit, Mouse
  Down, Mouse Up, Focus or Blur JavaScript (one undoable edit), reading only
  that widget's dictionary. `field_focus` now runs an AcroForm widget's On
  Focus / On Blur as well as XFA enter/exit.
- New tool `js_event`; form_focus's description says AcroForm focus
  scripts run too.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(ui): Will Save before saving, Will Print before printing, and widget pointer scripts in the app (#269, #247)

Ported from #344 by @DWAA1660.

- Save (both paths) runs the document's Will Save actions first, and Print
  its Will Print actions; what they change is saved or printed, and what
  they say is shown.
- The pointer moving onto or off a form widget queues its Mouse Enter /
  Mouse Exit script, a press its Mouse Down. A click on a check box or radio
  button queues its Mouse Up after the toggle, so the script reads the value
  the click stored (#247). The queue runs after the frame's edit.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* docs(parity): document actions and widget triggers are partly supported (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: check toSource's text (scripts can't eval it) and the trigger event names (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: a check box's Mouse Up reads its new value; keep the fixtures on screen and the realm across an in-place save (#247, #269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* fix(engine, forms, js, ui): review fixes for document actions and field triggers

- widget trigger actions follow /Next, each action its own edit; errors don't stop the chain
- setAction installs on the field the object stands for when names repeat
- toSource refuses non-plain objects and a __proto__ key
- doc_print checks the destination before Will Print; js_event returns only its own output
- combo/list clicks run Mouse Up; a refused click's Mouse Up doesn't run
- label crops are re-read when Will Print changed the document

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: run a pointer trigger on the field it hit and refresh the edited field's draft

A trigger now carries the field's object, so of fields sharing a name the one under the
pointer runs its own script (Session::field_trigger_target); js_event passes the field it
resolved. A trigger script changing the field being edited shows in its editor unless the
user already typed there, as focus scripts do.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: carry the field's identity through focus scripts and js_event

Focus transitions now track the field object, so moving between fields sharing a name is a
move and the field that gained or lost focus runs its own script
(Session::field_focus_target). js_event takes an optional field_id from form_fields to pick
one of fields sharing a name.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

---------

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
2026-11-11 13:09:37 +01:00

146 lines
6.0 KiB
Rust

//! `toSource()` on objects and arrays, which older forms use to keep state in a field or a
//! global as text (#269, ported from #344). It writes a JavaScript literal of the value; scripts
//! here can't `eval` it back, since strings never compile. Plain data only: strings, numbers,
//! booleans, null, undefined, arrays and objects of them. Functions and cycles are refused, and
//! so is a value past the depth, size or length bounds, rather than writing something that
//! reads as another value.
use super::host::{error, function, s, text};
use super::{Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, js_string};
use boa_engine::property::{PropertyDescriptor, PropertyKey};
/// Deepest nesting written.
const MAX_DEPTH: usize = 64;
/// Most values (each array item, property and the value itself) written.
const MAX_VALUES: usize = 10_000;
/// Longest source written, in bytes.
const MAX_BYTES: usize = 1 << 20;
/// Install `toSource` on `Object.prototype` (arrays inherit it), not enumerable, as in the
/// engines that had it.
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
let to_source = function(ctx, NativeFunction::from_fn_ptr(to_source));
let prototype = ctx.intrinsics().constructors().object().prototype();
prototype
.insert_property(js_string!("toSource"), PropertyDescriptor::builder().value(to_source).writable(true).configurable(true).enumerable(false));
Ok(())
}
fn to_source(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let mut writer = Writer { values: MAX_VALUES, bytes: MAX_BYTES, open: Vec::new() };
let out = writer.value(this, ctx)?;
Ok(s(&out))
}
/// What is left of the bounds, and the objects being written (to refuse a cycle).
struct Writer {
values: usize,
bytes: usize,
open: Vec<JsObject>,
}
impl Writer {
fn value(&mut self, v: &JsValue, ctx: &mut Context) -> JsResult<String> {
self.values = self.values.checked_sub(1).ok_or_else(|| error("toSource: the value has too many parts"))?;
let out = if v.is_callable() {
return Err(error("toSource: functions can't be written"));
} else if let Some(o) = v.as_object() {
self.object(&o, ctx)?
} else if let Some(text) = v.as_string() {
quote(&text.to_std_string_escaped())
} else if v.is_null() {
"null".into()
} else if v.is_undefined() {
"undefined".into()
} else {
text(v, ctx)?
};
self.charge(out.len())?;
Ok(out)
}
fn object(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
if self.open.iter().any(|open| open == o) {
return Err(error("toSource: the value refers to itself"));
}
if self.open.len() >= MAX_DEPTH {
return Err(error("toSource: the value is nested too deeply"));
}
if !o.is_array() && !plain(o, ctx) {
return Err(error("toSource: only plain objects, arrays, strings, numbers, booleans, null and undefined can be written"));
}
self.open.push(o.clone());
let out = if o.is_array() { self.array(o, ctx) } else { self.fields(o, ctx) };
self.open.pop();
out
}
fn array(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
let array = JsArray::from_object(o.clone())?;
let len = array.length(ctx)?;
if len > self.values as u64 {
return Err(error("toSource: the array is too long"));
}
let mut items = Vec::new();
for i in 0..len {
items.push(self.value(&array.get(i, ctx)?, ctx)?);
}
Ok(format!("[{}]", items.join(", ")))
}
fn fields(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
let mut items = Vec::new();
for key in o.own_property_keys(ctx)? {
let label = match &key {
PropertyKey::Symbol(_) => continue,
PropertyKey::String(k) => k.to_std_string_escaped(),
PropertyKey::Index(i) => i.get().to_string(),
};
// `"__proto__": v` in a literal sets the prototype rather than an own property.
if label == "__proto__" {
return Err(error("toSource: a property named __proto__ can't be written"));
}
self.charge(label.len())?;
let value = self.value(&o.get(key, ctx)?, ctx)?;
items.push(format!("{}:{value}", quote(&label)));
}
Ok(format!("({{{}}})", items.join(", ")))
}
/// Count `n` bytes against the length bound. Every part counts, including the parts of what
/// is joined later, so a value repeated many times can't build a long result first.
fn charge(&mut self, n: usize) -> JsResult<()> {
self.bytes = self.bytes.checked_sub(n).ok_or_else(|| error("toSource: the result would be too long"))?;
Ok(())
}
}
/// Whether `o` is a plain object: an ordinary one whose prototype is `Object.prototype` (or
/// none), so its own properties are all there is to it. A Date, a RegExp, a boxed value, a
/// proxy or a class instance holds more than a literal of its properties would give back.
fn plain(o: &JsObject, ctx: &Context) -> bool {
let object_prototype = ctx.intrinsics().constructors().object().prototype();
o.is_ordinary() && o.prototype().is_none_or(|p| p == object_prototype)
}
/// `text` as a double-quoted JavaScript string literal.
fn quote(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}