Files
pdfcraft/crates/engine/src/js.rs
T
Harrison BoothandFredApps 3df6be4eff feat(js): expose document page boxes (#5501)
* 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>
2026-11-10 21:37:10 +01:00

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(&note) {
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 == &note) {
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());
}
}