mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 09:21:17 +00:00
* feat(js): expose document page boxes * Normalize page boxes for getPageBox * Correct page box test expectations and formatting * Use local page helper in box test --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
920 lines
44 KiB
Rust
920 lines
44 KiB
Rust
//! Acrobat JavaScript in documents: field scripts run during form edits ([`JsRunner`], wired
|
|
//! into `forms` through its [`pdfcraft_forms::Scripts`] hook), and button actions and console
|
|
//! input run on request ([`Session::run_javascript`]). What scripts ask of the viewer (alerts,
|
|
//! printing, navigation, links) is kept for the front end in [`JsOutput`].
|
|
//!
|
|
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
|
|
//! off, scripts don't run and AF calls keep working natively.
|
|
|
|
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
|
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
|
|
|
|
use crate::{DocId, Edit, EditError, Session};
|
|
|
|
/// A field gaining or losing focus ([`Session::field_focus`]).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum FieldFocus {
|
|
/// The field gained focus: its `enter` scripts run.
|
|
Enter,
|
|
/// The field lost focus: its `exit` scripts run.
|
|
Exit,
|
|
}
|
|
|
|
/// What scripts produced for the viewer since it last looked.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct JsOutput {
|
|
/// The messages scripts showed, in order, with their titles, icons and buttons.
|
|
pub alerts: Vec<Message>,
|
|
pub console: Vec<String>,
|
|
pub requests: Vec<Request>,
|
|
/// Script errors (as Acrobat's console shows them).
|
|
pub errors: Vec<String>,
|
|
/// Which of `errors` are document scripts' (#3940), told apart by where they came from and
|
|
/// not by their text, which a field script's error can share: each is reported once, however
|
|
/// often the document scripts run again.
|
|
pub doc_errors: Vec<String>,
|
|
}
|
|
|
|
/// Console lines one output keeps, from every script an edit runs together (a form's
|
|
/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise
|
|
/// be multiplied by the number of fields a document declares.
|
|
pub const MAX_CONSOLE_LINES: usize = 10_000;
|
|
/// Bytes of console text one output keeps, for the same reason.
|
|
pub const MAX_CONSOLE_BYTES: usize = 1 << 20;
|
|
/// Alerts one output keeps.
|
|
pub const MAX_ALERTS: usize = 1_000;
|
|
|
|
impl JsOutput {
|
|
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
|
}
|
|
|
|
/// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what
|
|
/// is past them is left out, which one closing line says.
|
|
fn say(&mut self, lines: impl IntoIterator<Item = String>) {
|
|
let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes");
|
|
// Once the notice is written the console is closed: nothing may follow it, and a later
|
|
// rejection must not add a second copy.
|
|
if self.console.last() == Some(¬e) {
|
|
return;
|
|
}
|
|
let mut bytes: usize = self.console.iter().map(String::len).sum();
|
|
let mut dropped = false;
|
|
for l in lines {
|
|
if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES {
|
|
dropped = true;
|
|
break;
|
|
}
|
|
bytes += l.len();
|
|
self.console.push(l);
|
|
}
|
|
if dropped && !self.console.iter().any(|l| l == ¬e) {
|
|
self.console.push(note);
|
|
}
|
|
}
|
|
|
|
/// Adds `alerts` within [`MAX_ALERTS`].
|
|
fn alert(&mut self, alerts: impl IntoIterator<Item = Message>) {
|
|
let room = MAX_ALERTS.saturating_sub(self.alerts.len());
|
|
self.alerts.extend(alerts.into_iter().take(room));
|
|
}
|
|
|
|
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
|
/// except a request to print, show a side panel, open Save As, or open or save an attached
|
|
/// file, which is noted on the console and dropped.
|
|
fn absorb(&mut self, o: &Outcome) {
|
|
self.alert(o.alerts.iter().cloned());
|
|
self.say(o.console.iter().cloned());
|
|
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
|
// button's or the console's may print, show or hide a side panel, open Save As, or open
|
|
// or save an attached file (#4088).
|
|
// The warnings go through `say` like any other console line, so they count against the
|
|
// line and byte caps instead of growing the console past them.
|
|
let mut notes = Vec::new();
|
|
for r in &o.requests {
|
|
match r {
|
|
Request::Print => notes
|
|
.push("Print requested by an automatic field script was ignored; use a button or the JavaScript console to print".to_string()),
|
|
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
|
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
|
Request::OpenAttachment { name, .. } => {
|
|
notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file"))
|
|
}
|
|
r => self.requests.push(r.clone()),
|
|
}
|
|
}
|
|
self.say(notes);
|
|
self.errors.extend(o.error.iter().cloned());
|
|
// The document scripts run before every field script, so a failing one would repeat
|
|
// once per calculation; it is reported once.
|
|
for e in &o.doc_errors {
|
|
if !self.doc_errors.contains(e) {
|
|
self.doc_errors.push(e.clone());
|
|
self.errors.push(e.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn append(&mut self, other: JsOutput) {
|
|
self.alert(other.alerts);
|
|
self.say(other.console);
|
|
self.requests.extend(other.requests);
|
|
// As in `absorb`: a document script's error, already reported, is not repeated; a field
|
|
// script's errors are each their own, even one worded like a document script's.
|
|
let mut docs = other.doc_errors;
|
|
for e in other.errors {
|
|
if let Some(k) = docs.iter().position(|d| *d == e) {
|
|
docs.swap_remove(k);
|
|
if self.doc_errors.contains(&e) {
|
|
continue;
|
|
}
|
|
self.doc_errors.push(e.clone());
|
|
}
|
|
self.errors.push(e);
|
|
}
|
|
}
|
|
|
|
/// Takes out the alerts, console lines, requests and errors. The record of the document
|
|
/// scripts' errors stays, so they are still reported once.
|
|
fn set_aside(&mut self) -> JsOutput {
|
|
JsOutput {
|
|
alerts: std::mem::take(&mut self.alerts),
|
|
console: std::mem::take(&mut self.console),
|
|
requests: std::mem::take(&mut self.requests),
|
|
errors: std::mem::take(&mut self.errors),
|
|
doc_errors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Puts back what [`JsOutput::set_aside`] took out and returns what was added since.
|
|
fn put_back(&mut self, waiting: JsOutput) -> JsOutput {
|
|
let own = self.set_aside();
|
|
self.alerts = waiting.alerts;
|
|
self.console = waiting.console;
|
|
self.requests = waiting.requests;
|
|
self.errors = waiting.errors;
|
|
own
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Runs `f` and returns its value with the script output `f` added to document `id`. The
|
|
/// output limits count that output alone, and the output that was waiting stays for
|
|
/// [`Session::take_js_output`]. When `f` fails, its output joins the waiting output.
|
|
pub fn with_own_js_output<T, E>(&mut self, id: DocId, f: impl FnOnce(&mut Session) -> Result<T, E>) -> Result<(T, JsOutput), E> {
|
|
let waiting = self.docs.iter_mut().find(|d| d.id == id).map(|d| d.js_output.set_aside());
|
|
let result = f(self);
|
|
let mut doc = self.docs.iter_mut().find(|d| d.id == id);
|
|
let own = doc.as_mut().zip(waiting).map(|(d, w)| d.js_output.put_back(w)).unwrap_or_default();
|
|
match result {
|
|
Ok(value) => Ok((value, own)),
|
|
Err(e) => {
|
|
if let Some(d) = doc {
|
|
d.js_output.append(own);
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn kind(k: FieldKind) -> pdfcraft_js::FieldType {
|
|
use pdfcraft_js::FieldType as T;
|
|
match k {
|
|
FieldKind::Text => T::Text,
|
|
FieldKind::CheckBox => T::CheckBox,
|
|
FieldKind::Radio => T::RadioButton,
|
|
FieldKind::PushButton => T::Button,
|
|
FieldKind::Combo => T::ComboBox,
|
|
FieldKind::List => T::ListBox,
|
|
FieldKind::Signature => T::Signature,
|
|
}
|
|
}
|
|
|
|
/// `field.display` from the first widget's annotation flags.
|
|
fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 {
|
|
let flags = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().and_then(|d| d.int(b"F"))).unwrap_or(4);
|
|
if flags & 2 != 0 {
|
|
pdfcraft_js::DISPLAY_HIDDEN
|
|
} else if flags & 32 != 0 {
|
|
pdfcraft_js::DISPLAY_NO_VIEW
|
|
} else if flags & 4 == 0 {
|
|
pdfcraft_js::DISPLAY_NO_PRINT
|
|
} else {
|
|
pdfcraft_js::DISPLAY_VISIBLE
|
|
}
|
|
}
|
|
|
|
/// A form field as scripts see it.
|
|
pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState {
|
|
field_state_with_display(f, display(doc, f))
|
|
}
|
|
|
|
fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState {
|
|
let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone());
|
|
s.default = f.default.clone();
|
|
s.readonly = f.read_only();
|
|
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
|
|
s.display = display;
|
|
s.rect = f.widgets.first().map(|w| w.rect);
|
|
s.char_limit = f.max_len;
|
|
s.options = match f.kind {
|
|
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
|
|
_ => f.options.clone(),
|
|
};
|
|
s
|
|
}
|
|
|
|
/// The changes a script made, against the fields as they were.
|
|
fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec<FieldChange> {
|
|
if after.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
// Outcomes contain only fields touched by the script. Index the original snapshot once,
|
|
// keeping the first match as the old linear lookup did for malformed duplicate names.
|
|
let mut by_name = std::collections::HashMap::with_capacity(before.len());
|
|
for field in before {
|
|
by_name.entry(field.name.as_str()).or_insert(field);
|
|
}
|
|
after
|
|
.iter()
|
|
.filter_map(|a| {
|
|
let b = by_name.get(a.name.as_str())?;
|
|
let c = FieldChange {
|
|
name: a.name.clone(),
|
|
value: (a.value != b.value).then(|| a.value.clone()),
|
|
read_only: (a.readonly != b.readonly).then_some(a.readonly),
|
|
required: (a.required != b.required).then_some(a.required),
|
|
display: (a.display != b.display).then_some(a.display),
|
|
rect: (a.rect != b.rect).then_some(a.rect).flatten(),
|
|
};
|
|
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Runs field scripts with the document's object model and keeps what they print or ask for.
|
|
pub struct JsRunner {
|
|
pub doc: pdfcraft_js::DocInfo,
|
|
pub doc_scripts: Vec<String>,
|
|
pub output: JsOutput,
|
|
/// Immutable document snapshot from the start of the edit (for widget `display`).
|
|
cos: pdfcraft_cos::Document,
|
|
// `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only
|
|
// its scalar display readings, not COS objects: large forms exceed the object cache and
|
|
// otherwise reparse every widget for every calculation. Values/flags still come from
|
|
// the live `fields` snapshot on each call. A new edit gets a new runner and cache.
|
|
displays: std::collections::HashMap<pdfcraft_cos::ObjRef, i32>,
|
|
}
|
|
|
|
impl JsRunner {
|
|
/// A runner for `cos`: what scripts read about the document (pages, info, whether it is
|
|
/// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer
|
|
/// highlights form fields (`app.runtimeHighlight`).
|
|
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner {
|
|
let xfa = pdfcraft_xfa::xfa_shape(cos).is_some();
|
|
let pages = pdfcraft_model::pages(cos);
|
|
let doc = pdfcraft_js::DocInfo {
|
|
file_name: file_name.to_string(),
|
|
num_pages: pages.len(),
|
|
page: 0,
|
|
info: info(cos),
|
|
highlight,
|
|
page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(),
|
|
page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(),
|
|
xfa,
|
|
};
|
|
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
|
|
// Keep them in the file and in Document JavaScripts, but do not initialize an
|
|
// AcroForm context with them: a viewer-compatibility alert would be misleading.
|
|
// Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts.
|
|
let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some();
|
|
let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) };
|
|
JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() }
|
|
}
|
|
}
|
|
|
|
/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`).
|
|
fn last_highlight(requests: &[Request]) -> Option<bool> {
|
|
requests.iter().rev().find_map(|r| match r {
|
|
Request::Highlight(on) => Some(*on),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The Info dictionary as `this.info` keys (lower-case).
|
|
fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
|
|
let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() };
|
|
d.iter()
|
|
.filter_map(|(k, v)| match &*cos.resolve(v) {
|
|
pdfcraft_cos::Object::String(s) => Some((String::from_utf8_lossy(k).to_lowercase(), s.to_text())),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
impl Scripts for JsRunner {
|
|
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
|
|
/// asked for; a refusal carries its last message.
|
|
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
|
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
|
|
self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult {
|
|
rc: true,
|
|
value: value.to_string(),
|
|
value_set: false,
|
|
changes: Vec::new(),
|
|
message: None,
|
|
})
|
|
}
|
|
|
|
fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option<ScriptResult> {
|
|
pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false))
|
|
}
|
|
}
|
|
|
|
impl JsRunner {
|
|
/// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None`
|
|
/// when the script failed.
|
|
fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option<ScriptResult> {
|
|
let states: Vec<_> = fields
|
|
.iter()
|
|
.map(|f| {
|
|
let display = match f.widgets.first() {
|
|
Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)),
|
|
None => pdfcraft_js::DISPLAY_VISIBLE,
|
|
};
|
|
field_state_with_display(f, display)
|
|
})
|
|
.collect();
|
|
let states = std::sync::Arc::new(states);
|
|
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
|
|
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
|
|
if log {
|
|
self.output.absorb(&o);
|
|
}
|
|
// The edit's later scripts read the field highlight this one set.
|
|
if let Some(on) = last_highlight(&o.requests) {
|
|
self.doc.highlight = on;
|
|
}
|
|
if o.error.is_some() {
|
|
return None;
|
|
}
|
|
Some(ScriptResult {
|
|
rc: o.rc,
|
|
value: o.value.clone(),
|
|
value_set: o.value_set,
|
|
changes: changes(&states, &o.changed),
|
|
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A document-level JavaScript: its name and source.
|
|
pub fn document_scripts(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
|
|
pdfcraft_forms::document_scripts_named(cos)
|
|
}
|
|
|
|
impl crate::Document {
|
|
/// Field Properties ▸ Actions: field `name`'s action per trigger.
|
|
pub fn field_actions(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, pdfcraft_forms::FieldAction)> {
|
|
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default()
|
|
}
|
|
|
|
/// Field Properties ▸ Actions: how many actions are chained after each trigger's first one.
|
|
pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> {
|
|
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default()
|
|
}
|
|
|
|
/// Document JavaScripts (name, source), in name order.
|
|
pub fn document_scripts(&self) -> Vec<(String, String)> {
|
|
self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum SignEvent {
|
|
/// Just before the field is signed (`preSign`): what it changes is signed too.
|
|
Before,
|
|
/// Once the field is signed (`postSign`).
|
|
After,
|
|
}
|
|
|
|
impl SignEvent {
|
|
/// The template's name for the event.
|
|
pub fn activity(self) -> &'static str {
|
|
match self {
|
|
SignEvent::Before => "preSign",
|
|
SignEvent::After => "postSign",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default).
|
|
pub fn set_javascript(&mut self, on: bool) {
|
|
self.js_off = !on;
|
|
}
|
|
|
|
/// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript).
|
|
pub fn javascript(&self) -> bool {
|
|
!self.js_off
|
|
}
|
|
|
|
/// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as
|
|
/// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the
|
|
/// viewer then calls this with the new setting.
|
|
pub fn set_field_highlight(&mut self, on: bool) {
|
|
self.field_highlight = on;
|
|
}
|
|
|
|
/// What scripts printed or asked for since the last call (alerts, console, print, …).
|
|
pub fn take_js_output(&mut self, id: DocId) -> JsOutput {
|
|
self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default()
|
|
}
|
|
|
|
/// The last field-highlight change (`app.runtimeHighlight`) scripts asked for in document
|
|
/// `id`, taken out of its waiting output; the rest stays for [`Session::take_js_output`].
|
|
pub fn take_js_highlight(&mut self, id: DocId) -> Option<bool> {
|
|
let doc = self.docs.iter_mut().find(|d| d.id == id)?;
|
|
let mut last = None;
|
|
doc.js_output.requests.retain(|r| match r {
|
|
Request::Highlight(on) => {
|
|
last = Some(*on);
|
|
false
|
|
}
|
|
_ => true,
|
|
});
|
|
last
|
|
}
|
|
|
|
/// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form,
|
|
/// run the field's `enter` or `exit` scripts, as one undoable edit when they change something
|
|
/// (and none when they don't). An exit that the field's new value already ran (the value's
|
|
/// change runs it) isn't run a second time. Returns whether the document changed; what the
|
|
/// scripts showed or asked for is in [`Session::take_js_output`].
|
|
pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
|
|
if self.js_off {
|
|
return Ok(false);
|
|
}
|
|
let doc = self.doc_mut(id)?;
|
|
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) };
|
|
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
|
|
let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) };
|
|
let activity = match focus {
|
|
FieldFocus::Enter => "enter",
|
|
FieldFocus::Exit => "exit",
|
|
};
|
|
if !crate::xfa::has_event(cos, &tpl, &som, activity) {
|
|
doc.xfa_exited.remove(&som);
|
|
return Ok(false);
|
|
}
|
|
// Gaining focus starts afresh; losing it runs the exit unless the change just did.
|
|
if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit {
|
|
return Ok(false);
|
|
}
|
|
let before = doc.generation;
|
|
self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?;
|
|
Ok(self.get(id).is_some_and(|d| d.generation != before))
|
|
}
|
|
|
|
/// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign`
|
|
/// before it is signed, `postSign` after, as one undoable step, as a form's signing events run
|
|
/// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when
|
|
/// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script.
|
|
/// What the scripts said or asked for is in [`Session::take_js_output`].
|
|
pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result<bool, EditError> {
|
|
if self.get(id).is_none() {
|
|
return Err(EditError::NoDocument);
|
|
}
|
|
let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) };
|
|
self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Whether signature field `field` of document `id` has XFA signing scripts for `event` that
|
|
/// [`Session::run_sign_event`] would run, without running them.
|
|
pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool {
|
|
self.sign_event_som(id, field, event).is_some()
|
|
}
|
|
|
|
/// The SOM path of signature field `field` when it has scripts for `event` to run.
|
|
fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option<String> {
|
|
if self.js_off {
|
|
return None;
|
|
}
|
|
let doc = self.get(id)?;
|
|
let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?);
|
|
let f = doc.form.iter().find(|f| f.name == field)?;
|
|
crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity())
|
|
}
|
|
|
|
/// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or
|
|
/// the console (no target). Field changes and `resetForm` are applied as one undoable step;
|
|
/// everything else the script asked for is returned.
|
|
pub fn run_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result<Outcome, EditError> {
|
|
if self.js_off {
|
|
return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into()));
|
|
}
|
|
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
|
let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
|
// A button PdfCraft generated from an XFA template runs its XFA click script (the app runs
|
|
// click scripts for buttons only, so other fields' click events don't run here either).
|
|
if let Some(t) = target
|
|
&& let Some(tpl) = doc.xfa_template.clone()
|
|
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
|
|
&& field.kind == crate::FormFieldKind::PushButton
|
|
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
|
|
{
|
|
// The click returns only the output of its own edit; output from opening or from earlier edits stays.
|
|
let ((), out) = self.with_own_js_output(id, |s| s.apply(id, Edit::XfaEvent { som, activity: "click".into() }))?;
|
|
return Ok(Outcome {
|
|
rc: true,
|
|
alerts: out.alerts,
|
|
console: out.console,
|
|
requests: out.requests,
|
|
error: out.errors.first().cloned(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
let runner = JsRunner::new(&cos, &doc.name, self.field_highlight);
|
|
let fields = pdfcraft_forms::fields(&cos);
|
|
let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect();
|
|
let event = match target {
|
|
Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") },
|
|
None => pdfcraft_js::Event::doc("Console"),
|
|
};
|
|
let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default());
|
|
let changes = changes(&states, &o.changed);
|
|
let resets: Vec<Vec<String>> = o
|
|
.requests
|
|
.iter()
|
|
.filter_map(|r| match r {
|
|
Request::Reset(n) => Some(n.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
o.requests.retain(|r| !matches!(r, Request::Reset(_)));
|
|
// The calculations the script's changes set off read the field highlight it asked for.
|
|
if let Some(on) = last_highlight(&o.requests) {
|
|
self.field_highlight = on;
|
|
}
|
|
let mut edits = Vec::new();
|
|
if !changes.is_empty() {
|
|
edits.push(Edit::ApplyScriptChanges { changes });
|
|
}
|
|
if !resets.is_empty() {
|
|
let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
|
edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) });
|
|
}
|
|
if !edits.is_empty() {
|
|
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
|
|
}
|
|
Ok(o)
|
|
}
|
|
}
|
|
|
|
/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the
|
|
/// defaults, so resetting the union once does what each call in turn would, and recalculates
|
|
/// once. `None` is the whole form (a call with no names). A field is named by a selector that
|
|
/// is its name or the name of a field above it, looked up in a set of the selectors, so the
|
|
/// work is the fields times their depth, not the fields times the selectors, which a script
|
|
/// controls.
|
|
fn reset_targets(fields: &[&str], requests: &[Vec<String>]) -> Option<Vec<String>> {
|
|
if requests.iter().any(Vec::is_empty) {
|
|
return None;
|
|
}
|
|
let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect();
|
|
let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p));
|
|
Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect())
|
|
}
|
|
|
|
/// A page's words for form-field detection, in user space (underscore runs split from the
|
|
/// text around them).
|
|
fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<pdfcraft_forms::detect::Word> {
|
|
page_words(text, info).into_iter().map(|(text, rect)| pdfcraft_forms::detect::Word { text, rect }).collect()
|
|
}
|
|
|
|
/// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are
|
|
/// words of their own.
|
|
pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> {
|
|
words_with(text, |x, y| info.view_to_user(x, y))
|
|
.into_iter()
|
|
.map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])]))
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
/// Characters read to tell underscore runs from other words.
|
|
static UNDERSCORE_CHARS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
|
|
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
|
|
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
|
|
let mut words: Vec<(String, [f32; 4])> = Vec::new();
|
|
let mut last_line = u32::MAX;
|
|
// A space glyph ends the word before it.
|
|
let mut gap = false;
|
|
// Whether the last word is an underscore run (a form's blank), which other glyphs don't join.
|
|
let mut last_under = false;
|
|
for (i, g) in text.glyphs.iter().enumerate() {
|
|
let line = text.line_of.get(i).copied().unwrap_or(0);
|
|
if g.text.trim().is_empty() {
|
|
gap = true;
|
|
continue;
|
|
}
|
|
let under = g.text.chars().all(|c| c == '_');
|
|
#[cfg(test)]
|
|
UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len()));
|
|
let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under;
|
|
gap = false;
|
|
last_line = line;
|
|
match words.last_mut() {
|
|
Some((w, r)) if !breaks => {
|
|
w.push_str(&g.text);
|
|
r[0] = r[0].min(g.rect[0]);
|
|
r[1] = r[1].min(g.rect[1]);
|
|
r[2] = r[2].max(g.rect[2]);
|
|
r[3] = r[3].max(g.rect[3]);
|
|
}
|
|
_ => {
|
|
words.push((g.text.clone(), g.rect));
|
|
last_under = under;
|
|
}
|
|
}
|
|
}
|
|
words
|
|
.into_iter()
|
|
.map(|(text, r)| {
|
|
let a = map(r[0], r[1]);
|
|
let b = map(r[2], r[3]);
|
|
(text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])])
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
impl Session {
|
|
/// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all)
|
|
/// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture
|
|
/// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`].
|
|
pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> {
|
|
self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect()
|
|
}
|
|
|
|
/// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright.
|
|
fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> {
|
|
let Some(doc) = self.get(id) else { return Vec::new() };
|
|
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
|
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
|
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
|
let mut taken: Vec<String> = doc.form.iter().map(|f| f.name.clone()).collect();
|
|
let all = doc.info.pages.len();
|
|
let list: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() };
|
|
let mut out = Vec::new();
|
|
for page in list {
|
|
let Some(info) = doc.info.pages.get(page) else { continue };
|
|
let shapes = pdfcraft_forms::detect::page_shapes(cos, page);
|
|
let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect();
|
|
let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) {
|
|
// Labels come from the page's own text. Words that only /ActualText names (a stamp
|
|
// drawn as outlines) are no labels, and leave the scan to be read.
|
|
let own = crate::ocr::own_text(&mut r, page).ok();
|
|
self.detect_on_scan(id, &mut r, page, own.as_ref(), &existing, &taken)
|
|
} else {
|
|
let res =
|
|
r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
|
let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default();
|
|
(pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0)
|
|
};
|
|
for c in found {
|
|
taken.push(c.name.clone());
|
|
out.push((page, c, turn));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Detect fields and add them, as one undoable step. Returns the new fields' names.
|
|
pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<String>, EditError> {
|
|
let found = self.detect_fields_turned(id, pages);
|
|
if found.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
|
|
let mut edits = Vec::with_capacity(found.len());
|
|
for (page, c, turn) in found {
|
|
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
|
|
let (kind, comb) = match c.kind {
|
|
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
|
|
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
|
|
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
|
|
};
|
|
// The page already draws the line or box the field sits on, so the field gets no
|
|
// border or fill of its own: an opaque fill would hide the printed form (#294, #2831).
|
|
let mut props = FieldProps {
|
|
appearance: Some(pdfcraft_forms::LookPatch { border: Some(None), fill: Some(None), ..Default::default() }),
|
|
..Default::default()
|
|
};
|
|
if turn != 0 {
|
|
// A turned field keeps its place: the rectangle is already the one on the page.
|
|
props.rotation = Some((0, turn));
|
|
props.rect = Some((0, c.rect));
|
|
}
|
|
if let Some(cells) = comb {
|
|
props.max_len = Some(Some(cells));
|
|
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
|
|
}
|
|
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
|
|
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
|
|
}
|
|
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
|
|
Ok(names)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod review_tests {
|
|
use super::*;
|
|
|
|
/// Many `resetForm` calls with many selectors over a big form: one reset of the union, found
|
|
/// by looking each field's name and parents up, not by comparing every field with every
|
|
/// selector.
|
|
#[test]
|
|
fn reset_requests_are_coalesced_and_matched_through_an_index() {
|
|
let fields: Vec<String> = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect();
|
|
let names: Vec<&str> = fields.iter().map(String::as_str).collect();
|
|
let requests: Vec<Vec<String>> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect();
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field");
|
|
let mut requests = requests;
|
|
requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]);
|
|
let hit = reset_targets(&names, &requests).unwrap();
|
|
assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name");
|
|
assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell"));
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
|
|
requests.push(Vec::new());
|
|
assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form");
|
|
}
|
|
|
|
/// A click returns its own alert when the output waiting in the document is full, and the
|
|
/// waiting output stays.
|
|
#[test]
|
|
fn a_click_returns_its_alert_when_the_waiting_output_is_full() {
|
|
let mut s = Session::new();
|
|
let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template());
|
|
let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap();
|
|
s.take_js_output(id);
|
|
s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output.alert(vec![Message::plain("waiting"); MAX_ALERTS]);
|
|
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
|
assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::<Vec<_>>(), ["Hello 2"]);
|
|
let out = s.take_js_output(id);
|
|
assert_eq!(out.alerts, vec![Message::plain("waiting"); MAX_ALERTS]);
|
|
}
|
|
|
|
/// With the console full and closed, an operation still gets its own lines. Output of an
|
|
/// operation that fails joins the waiting output.
|
|
#[test]
|
|
fn output_limits_count_only_the_operations_own_output() {
|
|
let mut s = Session::new();
|
|
let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template());
|
|
let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap();
|
|
s.take_js_output(id);
|
|
fn output(s: &mut Session, id: DocId) -> &mut JsOutput {
|
|
&mut s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output
|
|
}
|
|
output(&mut s, id).say(vec!["waiting".to_string(); MAX_CONSOLE_LINES + 1]);
|
|
let waiting = output(&mut s, id).console.clone();
|
|
assert!(waiting.last().unwrap().starts_with("more console lines were left out"));
|
|
let ((), own) = s
|
|
.with_own_js_output::<_, ()>(id, |s| {
|
|
output(s, id).say(["mine".to_string()]);
|
|
output(s, id).errors.push("own error".into());
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
assert_eq!(own.console, ["mine"]);
|
|
assert_eq!(own.errors, ["own error"]);
|
|
assert_eq!(output(&mut s, id).console, waiting);
|
|
assert!(output(&mut s, id).errors.is_empty());
|
|
let failed = s.with_own_js_output::<(), _>(id, |s| {
|
|
output(s, id).errors.push("failed edit".into());
|
|
Err("no")
|
|
});
|
|
assert_eq!(failed.unwrap_err(), "no");
|
|
let out = s.take_js_output(id);
|
|
assert_eq!(out.console, waiting);
|
|
assert_eq!(out.errors, ["failed edit"]);
|
|
}
|
|
|
|
/// A form of many calculated fields, each printing its own cap of console lines, keeps a
|
|
/// bounded console: past the budget, lines are left out and one closing line says so.
|
|
#[test]
|
|
fn console_output_of_one_edit_is_bounded() {
|
|
let mut out = JsOutput::default();
|
|
let line = "x".repeat(4_096);
|
|
let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() };
|
|
for _ in 0..1_000 {
|
|
out.absorb(&run);
|
|
}
|
|
let bytes: usize = out.console.iter().map(String::len).sum();
|
|
assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept");
|
|
assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len());
|
|
assert!(out.console.last().unwrap().starts_with("more console lines were left out"));
|
|
assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once");
|
|
assert_eq!(out.alerts.len(), MAX_ALERTS);
|
|
let mut merged = JsOutput::default();
|
|
merged.append(out);
|
|
assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1);
|
|
}
|
|
|
|
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
|
|
/// the word so far without reading the word again.
|
|
#[test]
|
|
fn a_long_underscore_run_is_read_in_one_pass() {
|
|
let n = 10_000;
|
|
let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph {
|
|
text: t.into(),
|
|
rect: [i as f32, 0.0, i as f32 + 1.0, 10.0],
|
|
quad: [[0.0; 2]; 4],
|
|
direction: [1.0, 0.0],
|
|
};
|
|
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
|
|
glyphs.push(glyph(n, "x"));
|
|
// Built by hand, not read from a page the renderer cut short, so the text is complete.
|
|
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false };
|
|
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
|
|
let words = words_with(&text, |x, y| [x, y]);
|
|
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
|
|
let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get);
|
|
assert!(read <= 4 * n, "{read} characters read for {n} glyphs");
|
|
}
|
|
|
|
/// Review on #3985: merging two outputs reports a failing document script once, as one run
|
|
/// does, while each field script error stays: told apart by where an error came from, so a
|
|
/// field script that throws a document script's very words is still reported.
|
|
#[test]
|
|
fn merged_output_reports_a_document_script_error_once() {
|
|
let doc = "document script 2: TypeError: not a callable function";
|
|
let field = "ReferenceError: x is not defined";
|
|
let run = |error: &str| {
|
|
let mut out = JsOutput::default();
|
|
out.absorb(&Outcome { error: Some(error.into()), doc_errors: vec![doc.into()], ..Outcome::default() });
|
|
out
|
|
};
|
|
let mut out = run(field);
|
|
out.append(run(field));
|
|
assert_eq!(out.errors, [field, doc, field]);
|
|
// CodeRabbit on #3985: a field script that throws the document script's text.
|
|
out.append(run(doc));
|
|
assert_eq!(out.errors, [field, doc, field, doc]);
|
|
let alone = run(doc);
|
|
assert_eq!(alone.errors, [doc, doc], "the field's error and the document script's");
|
|
let count = |o: &JsOutput| o.errors.iter().filter(|e| *e == doc).count();
|
|
let mut merged = run(field);
|
|
merged.append(alone);
|
|
assert_eq!(count(&merged), 2, "the field's copy stays; the document script's is listed once");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_field_names_diff_against_first_original() {
|
|
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
|
|
let mut second = first.clone();
|
|
second.value = vec!["second".into()];
|
|
second.display = pdfcraft_js::DISPLAY_HIDDEN;
|
|
let before = vec![first.clone(), second.clone()];
|
|
assert!(changes(&before, std::slice::from_ref(&first)).is_empty());
|
|
let actual = changes(&before, std::slice::from_ref(&second));
|
|
assert_eq!(
|
|
actual,
|
|
vec![FieldChange {
|
|
name: "duplicate".into(),
|
|
value: Some(second.value),
|
|
display: Some(pdfcraft_js::DISPLAY_HIDDEN),
|
|
..Default::default()
|
|
}]
|
|
);
|
|
let outcome = pdfcraft_js::run_shared(
|
|
"event.value = getField('duplicate').value; getField('duplicate').value = 'updated';",
|
|
&pdfcraft_js::Event::field("Calculate", "duplicate", ""),
|
|
&pdfcraft_js::DocInfo::default(),
|
|
std::sync::Arc::new(before.clone()),
|
|
&[],
|
|
Limits::default(),
|
|
);
|
|
assert_eq!(outcome.error, None);
|
|
assert_eq!(outcome.value, "first");
|
|
assert_eq!(outcome.changed.len(), 1);
|
|
assert_eq!(
|
|
changes(&before, &outcome.changed),
|
|
vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }]
|
|
);
|
|
assert!(changes(&before, &[]).is_empty());
|
|
let mut missing = first;
|
|
missing.name = "missing".into();
|
|
assert!(changes(&before, &[missing]).is_empty());
|
|
}
|
|
}
|