mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
* M12: run XFA forms' JavaScript: calculations, validation, rows, visibility (#60) Laid-out XFA forms had their fields but none of their behaviour: totals didn't compute, "Add row" buttons did nothing, and subforms a check box should reveal stayed hidden. The js crate gains the XFA scripting object model as native boa objects over a snapshot of the form tree: xfa.form navigation by name, SOM resolution (absolute, relative with the containing-subform search, descendants, indexes and wildcards), rawValue/presence/access, instance managers (_name, addInstance, removeInstance, setInstances, count), xfa.host (messageBox, resetData, print, setFocus, gotoURL), xfa.layout page numbers, xfa.event, app, console and util, plus unqualified sibling names as XFA resolves them. Scripts run with `this` bound to the node and yield their completion value; everything they change or ask for comes back as effects. The xfa crate builds the live form tree (instances from the data, values, presence and access with overrides) and the event table from the template, keeps presence and access overrides on the AcroForm (/PCXfaOverrides) so undo and reopening preserve them, adds and removes row instances in the datasets packet through the existing merge pipeline, and lays the form out again on demand. The engine runs initialize and calculate scripts on open (an appended revision, nothing dirty), exit, validate and calculate after a field changes (a failing validate shows its message and keeps the value, as Acrobat does), and click scripts for buttons through run_javascript, so the app's buttons and the js_run tool work unchanged. Values a script sets go to the fields and the data at once; rows and visibility changes re-lay the form out. All of it is one undo step. JavaScript off turns it off. FormCalc scripts are recognised and reported, not run. Bounds: script length and nesting as before, loop and recursion limits, at most 1000 instances added per run, 2000 scripts and 50 re-layouts per event; a calculate script that adds rows, or a click script looping on addInstance/setInstances, ends quickly with a bounded form (tested). Checked on IRCC's IMM 5669: opens with its page-number scripts, the Print button's xfa.host.print runs through js_run, fill and save keep the original bytes. * M12: Save and SaveAs named actions run the file commands (#60) XFA forms' Save buttons are app.execMenuItem("SaveAs"), which the layout turns into a SaveAs named action; the shell answered it with "the SaveAs action isn't supported yet". Named Save and SaveAs now run file.save and file.save_as, like Named Print opens the print dialog. The buttons test covers Save against a file on disk (SaveAs opens the native dialog, which a headless test must not). * M12: bound XFA form scripts against hostile forms (#60) Review fixes for the XFA scripting PR (#230): - Effects: values, presence and access set again on the same object merge (last wins, behind order-dependent effects); 10k effects, 100 message boxes and 1000 console lines per script and per event, reported when hit. An event's data writes are kept in memory and written as one rewrite of the datasets packet; later writes in the same edit replace that stream in place. - form_tree stops at 200k objects and says so; forms without scripts build no live form and run nothing. - Only click scripts may print, save, open links or move the focus; others are noted in the console. Open-time messages and errors show right after open. - Lower loop limits for initialize/calculate/validate, a time budget per event after which remaining scripts are skipped and reported, and a per-script time limit after which a runaway script is abandoned and the document's scripts turned off. - What open-time scripts change is noted in the XFA warnings. - A document's Named /Save no longer writes the file on click (SaveAs opens the Save As dialog, as a script's execMenuItem does). - XFA click scripts are no longer written as /S /JavaScript actions: the button carries /PCXfaClick and runs by SOM path. Private keys documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
15 KiB
Rust
340 lines
15 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, ScriptResult, Scripts};
|
|
pub use pdfcraft_js::{Limits, Outcome, Request};
|
|
|
|
use crate::{DocId, Edit, EditError, Session};
|
|
|
|
/// What scripts produced for the viewer since it last looked.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct JsOutput {
|
|
pub alerts: Vec<String>,
|
|
pub console: Vec<String>,
|
|
pub requests: Vec<Request>,
|
|
/// Script errors (as Acrobat's console shows them).
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
impl JsOutput {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
|
}
|
|
|
|
fn absorb(&mut self, o: &Outcome) {
|
|
self.alerts.extend(o.alerts.iter().cloned());
|
|
self.console.extend(o.console.iter().cloned());
|
|
self.requests.extend(o.requests.iter().cloned());
|
|
self.errors.extend(o.error.iter().cloned());
|
|
}
|
|
|
|
pub(crate) fn append(&mut self, other: JsOutput) {
|
|
self.alerts.extend(other.alerts);
|
|
self.console.extend(other.console);
|
|
self.requests.extend(other.requests);
|
|
self.errors.extend(other.errors);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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(doc, f);
|
|
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> {
|
|
after
|
|
.iter()
|
|
.filter_map(|a| {
|
|
let b = before.iter().find(|b| b.name == a.name)?;
|
|
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),
|
|
};
|
|
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.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,
|
|
/// The fields in the document as the forms code last read them (for `display`).
|
|
cos: pdfcraft_cos::Document,
|
|
}
|
|
|
|
impl JsRunner {
|
|
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner {
|
|
let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) };
|
|
JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() }
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
|
let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect();
|
|
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
|
|
let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default());
|
|
self.output.absorb(&o);
|
|
if o.error.is_some() {
|
|
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
|
|
return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
|
|
}
|
|
let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
|
|
ScriptResult {
|
|
rc: o.rc,
|
|
value: o.value.clone(),
|
|
changes: changes(&states, &after),
|
|
message: (!o.rc).then(|| o.alerts.last().cloned()).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()
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default).
|
|
pub fn set_javascript(&mut self, on: bool) {
|
|
self.js_off = !on;
|
|
}
|
|
|
|
pub fn javascript(&self) -> bool {
|
|
!self.js_off
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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.
|
|
if let Some(t) = target
|
|
&& let Some(tpl) = doc.xfa_template.clone()
|
|
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, t)
|
|
{
|
|
self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?;
|
|
let out = self.take_js_output(id);
|
|
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);
|
|
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 after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
|
|
let changes = changes(&states, &after);
|
|
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(_)));
|
|
let mut edits = Vec::new();
|
|
if !changes.is_empty() {
|
|
edits.push(Edit::ApplyScriptChanges { changes });
|
|
}
|
|
for names in resets {
|
|
let all: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
|
|
let names = (!names.is_empty())
|
|
.then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect());
|
|
edits.push(Edit::ResetForm { names });
|
|
}
|
|
if !edits.is_empty() {
|
|
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
|
|
}
|
|
Ok(o)
|
|
}
|
|
}
|
|
|
|
/// 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])> {
|
|
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;
|
|
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 == '_');
|
|
let breaks = match words.last() {
|
|
None => true,
|
|
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != 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)),
|
|
}
|
|
}
|
|
words
|
|
.into_iter()
|
|
.map(|(text, r)| {
|
|
let a = info.view_to_user(r[0], r[1]);
|
|
let b = info.view_to_user(r[2], r[3]);
|
|
let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64];
|
|
(text, rect)
|
|
})
|
|
.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).
|
|
pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> {
|
|
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 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, &doc.info.pages[page])).unwrap_or_default();
|
|
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();
|
|
for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) {
|
|
taken.push(c.name.clone());
|
|
out.push((page, c));
|
|
}
|
|
}
|
|
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(id, pages);
|
|
if found.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let names = found.iter().map(|(_, c)| c.name.clone()).collect();
|
|
let edits = found
|
|
.into_iter()
|
|
.map(|(page, c)| Edit::AddField {
|
|
page,
|
|
rect: c.rect,
|
|
kind: match c.kind {
|
|
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 },
|
|
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
|
|
},
|
|
name: Some(c.name),
|
|
})
|
|
.collect();
|
|
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
|
|
Ok(names)
|
|
}
|
|
}
|