XFA: run the forms' JavaScript: calculations, validation, rows, visibility (#60) (#230)

* 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>
This commit is contained in:
ysrdevs
2026-10-08 04:16:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent dfb4e26e1a
commit 11ffedcc98
26 changed files with 4059 additions and 60 deletions
+16
View File
@@ -189,6 +189,22 @@ impl Session {
}
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();
+172 -8
View File
@@ -20,6 +20,7 @@ pub mod export;
pub mod js;
pub mod links;
pub mod ocr;
pub mod xfa;
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
@@ -222,6 +223,8 @@ pub struct Document {
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
pub xfa: Option<XfaLayout>,
/// The parsed template of a laid-out XFA form, for its scripts.
xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
/// (also in `info.warnings`, kept there when the document is re-read).
pub xfa_warnings: Vec<String>,
@@ -876,6 +879,12 @@ pub enum Edit {
ApplyScriptChanges {
changes: Vec<pdfcraft_forms::FieldChange>,
},
/// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the
/// script changes (values, rows, visibility) is applied and the form laid out again.
XfaEvent {
som: String,
activity: String,
},
/// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the
/// image (from [`ocr::OcrJob::run`]).
AddOcrText {
@@ -1074,6 +1083,7 @@ impl Edit {
Edit::MarkDecorative { .. } => "Mark figure as decorative".into(),
Edit::AddOcrText { .. } => "Recognize text".into(),
Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(),
Edit::XfaEvent { .. } => "Run form script".into(),
Edit::ConvertPdfA { level } => format!("Save as {}", level.label()),
Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"),
Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(),
@@ -1204,7 +1214,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
Err(EditError::NotPermitted("comments"))
}
}
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => {
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => {
if p.fill_forms() {
Ok(())
} else {
@@ -1269,6 +1279,16 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
struct EditCtx {
/// Field scripts run (`None`: JavaScript is off) and what they produced.
js: Option<js::JsRunner>,
/// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what
/// its scripts produced.
xfa: Option<Arc<pdfcraft_xfa::model::Template>>,
xfa_out: js::JsOutput,
/// The datasets stream this edit wrote (later writes in the edit replace it in place).
xfa_datasets: Option<pdfcraft_cos::ObjRef>,
/// A form script ran too long and was abandoned: the document's scripts go off.
xfa_ran_away: bool,
/// Pages in the document, for `xfa.layout.pageCount()`.
pages: usize,
date: Option<String>,
/// Today in local time, for date tokens.
today: (i64, u32, u32),
@@ -1287,7 +1307,18 @@ impl EditCtx {
// Real clock: mix in sub-second time so ids from two sessions don't collide.
seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32;
}
Self { js: None, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 }
Self {
js: None,
xfa: None,
xfa_out: Default::default(),
xfa_datasets: None,
xfa_ran_away: false,
pages: 0,
date: now.map(pdfcraft_cos::pdf_date),
today: (1970, 1, 1),
seed,
count: 0,
}
}
/// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
@@ -1417,6 +1448,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?,
},
Edit::XfaEvent { som, activity } => {
let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?;
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
ran.map_err(EditError::Invalid)?;
}
Edit::SetFieldImage { name, image } => {
let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?;
let px = match &*doc.get(img) {
@@ -1778,11 +1817,21 @@ fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum>
/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not
/// be written.
fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
/// (and set to the one written).
fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document, datasets: &mut Option<pdfcraft_cos::ObjRef>) -> Result<Vec<String>, String> {
let data = xfa_field_data(doc);
pdfcraft_xfa::write_datasets(doc, &data).map(|r| r.warnings).map_err(|e| e.to_string())
let r = pdfcraft_xfa::write_datasets_reusing(doc, &data, *datasets).map_err(|e| e.to_string())?;
if r.stream.is_some() {
*datasets = r.stream;
}
Ok(r.warnings)
}
/// What a document whose form script ran away is told.
const XFA_SCRIPTS_OFF: &str =
"A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)";
/// Most XFA warnings kept per document.
const MAX_XFA_WARNINGS: usize = 50;
@@ -1967,10 +2016,66 @@ impl Session {
}
(_, cos) => (bytes, info, cos, None),
};
// A laid-out XFA form: its initialize and calculate scripts run now, as on opening in
// Acrobat; what they change is one more revision, still nothing to save.
let mut script_output = js::JsOutput::default();
let mut ran_away = false;
let xfa_template = match (&xfa, &cos) {
(Some(_), Ok(Ok(c))) => xfa::template(c),
_ => None,
};
let (bytes, info, cos) = match (&xfa, cos) {
(Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() {
Some(tpl) => {
let mut work = cos.clone();
let pages = info.pages.len();
let ran = guard(|| {
let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false };
let changes = xfa::on_open(&mut work, &tpl, &mut run);
if run.ran_away {
ran_away = true;
}
changes
})
.unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})")));
match ran {
Ok(changes) if work.is_modified() => match rebase(&work) {
Ok((b, mut i, c)) => {
// The file as saved differs from what opening shows: say so.
if !changes.is_empty() {
i.warnings.push(format!(
"This form's scripts changed it on opening ({}); saving keeps those changes",
changes.describe()
));
}
(b, i, Ok(Ok(c)))
}
Err(e) => {
script_output.errors.push(format!("The form's scripts could not be applied: {e}"));
(bytes, info, Ok(Ok(cos)))
}
},
Ok(_) => (bytes, info, Ok(Ok(cos))),
Err(e) => {
script_output.errors.push(format!("The form's scripts could not run: {e}"));
(bytes, info, Ok(Ok(cos)))
}
}
}
None => (bytes, info, Ok(Ok(cos))),
},
(_, cos) => (bytes, info, cos),
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
// A script that ran away on open turns the form's scripts off for this document.
d.xfa_template = if ran_away { None } else { xfa_template };
d.js_output.append(script_output);
if ran_away {
d.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
note_warnings(&mut d.xfa_warnings, &xfa_warnings);
}
Ok(id)
@@ -1999,7 +2104,10 @@ impl Session {
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default();
let mut form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default();
if let Some(e) = editor.as_ref() {
xfa::mark_script_buttons(&e.cos, &mut form);
}
let marks = editor.as_ref().map(|e| pdfcraft_edit::marks_present(&e.cos)).unwrap_or_default();
let added = editor.as_ref().map(|e| pdfcraft_edit::list_added(&e.cos)).unwrap_or_default();
let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default();
@@ -2030,6 +2138,7 @@ impl Session {
config,
js_output: Default::default(),
xfa,
xfa_template: None,
xfa_warnings: Vec::new(),
});
Ok(id)
@@ -2048,6 +2157,10 @@ impl Session {
let name = doc.name.clone();
let is_xfa = doc.info.xfa.is_some();
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
if !js_off {
cx.xfa = doc.xfa_template.clone();
}
cx.pages = doc.info.pages.len();
cx.today = today;
let reason = doc.read_only_reason.clone().unwrap_or_default();
let signed = doc.is_signed();
@@ -2062,10 +2175,46 @@ impl Session {
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
// every calculation (and a reset recalculates). They read the data, so it is brought up
// to date first.
if let Some(tpl) = cx.xfa.clone() {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
let datasets = &mut cx.xfa_datasets;
let notes = guard(|| xfa_sync_datasets(&mut next, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
cx.xfa_out.errors.extend(notes);
}
fn collect(e: &Edit, changed: &mut Vec<String>, reset: &mut bool) {
match e {
Edit::SetFieldValue { name, .. } => changed.push(name.clone()),
Edit::ResetForm { .. } => *reset = true,
Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)),
_ => {}
}
}
let mut changed: Vec<String> = Vec::new();
let mut reset = false;
collect(&edit, &mut changed, &mut reset);
if !changed.is_empty() || reset {
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
// Every changed field's scripts, then the calculations once (after a reset,
// only those).
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
.unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})")));
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
if let Err(e) = ran {
cx.xfa_out.errors.push(e);
}
}
}
// XFA forms keep their values in the datasets packet too, for Adobe's viewers.
let mut xfa_notes = Vec::new();
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
xfa_notes = guard(|| xfa_sync_datasets(&mut next))
let datasets = &mut cx.xfa_datasets;
xfa_notes = guard(|| xfa_sync_datasets(&mut next, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
}
@@ -2106,6 +2255,11 @@ impl Session {
if let Some(js) = cx.js {
doc.js_output.append(js.output);
}
doc.js_output.append(cx.xfa_out);
if cx.xfa_ran_away {
doc.xfa_template = None;
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
Ok(())
}
@@ -2158,7 +2312,9 @@ impl Session {
} else {
editor.cos.bytes().clone()
};
let form = Arc::new(pdfcraft_forms::fields(&editor.cos));
let mut form = pdfcraft_forms::fields(&editor.cos);
xfa::mark_script_buttons(&editor.cos, &mut form);
let form = Arc::new(form);
match scope {
Scope::Comments => doc.info.annotations = comment_list(&editor.cos),
Scope::Form => {
@@ -2190,6 +2346,12 @@ impl Session {
/// Rebuild working bytes, inspection and renderer from the current edit state.
fn refresh(doc: &mut Document) -> Result<(), EditError> {
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
{
doc.xfa = Some(report);
}
let bytes = if editor.cos.is_modified() {
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
} else {
@@ -2205,7 +2367,9 @@ impl Session {
}
note_warnings(&mut info.warnings, &doc.xfa_warnings);
doc.info = info;
doc.form = Arc::new(pdfcraft_forms::fields(&editor.cos));
let mut form = pdfcraft_forms::fields(&editor.cos);
xfa::mark_script_buttons(&editor.cos, &mut form);
doc.form = Arc::new(form);
doc.marks = pdfcraft_edit::marks_present(&editor.cos);
doc.added = pdfcraft_edit::list_added(&editor.cos);
doc.links = pdfcraft_annot::links::list(&editor.cos);
+200
View File
@@ -1572,3 +1572,203 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings);
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| {
s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}"))
};
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
// On open: the initialize script set qty, the calculations ran, nothing is dirty.
assert_eq!(value(&s, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["0".to_string()]);
assert!(!s.get(id).unwrap().dirty);
assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden");
// A change recalculates.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert_eq!(value(&s, "total"), vec!["15".to_string()], "{:?}", s.take_js_output(id));
// A failing validate script shows its message; the value stays, as in Acrobat.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
// A button adds a row: new fields, same page, one undo step.
let before = names(&s).len();
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert_eq!(o.error, None);
let after = names(&s);
assert_eq!(after.len(), before + 2, "{after:?}");
assert!(after.iter().any(|n| n == "amount_2"));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run form script"));
s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
// Message boxes reach the viewer.
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
// The check box shows the hidden subform; undo hides it again.
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
s.undo(id).unwrap();
assert!(!names(&s).iter().any(|n| n == "note"));
s.redo(id).unwrap();
assert!(names(&s).iter().any(|n| n == "note"));
// Removing the row takes its amount out of the sum.
s.run_javascript(id, "", Some("removeRow")).unwrap();
assert!(!names(&s).iter().any(|n| n == "amount_2"));
assert_eq!(value(&s, "grand"), vec!["10".to_string()]);
// FormCalc is reported, not run.
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
assert!(o.error.as_deref().is_some_and(|e| e.contains("FormCalc")), "{o:?}");
assert!(o.alerts.is_empty());
// Saved and reopened: the visible subform, the values and the rows are what they were.
let saved = s.save_bytes(id).unwrap();
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
let n2: Vec<&str> = d2.form.iter().map(|f| f.name.as_str()).collect();
assert!(n2.contains(&"note") && !n2.contains(&"amount_2"), "{n2:?}");
assert_eq!(d2.form.iter().find(|f| f.name == "grand").unwrap().value, vec!["10".to_string()]);
assert_eq!(d2.form.iter().find(|f| f.name == "qty").unwrap().value, vec!["500".to_string()]);
assert!(!d2.dirty);
}
#[test]
fn xfa_scripts_stay_off_with_javascript_off() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_javascript(false);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone();
assert!(qty.is_empty(), "no initialize script ran");
assert!(s.run_javascript(id, "", Some("addRow")).is_err());
}
#[test]
fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() {
// A calculate script that adds a row every time it runs, a click script that loops on
// addInstance, and one that nests setInstances: each ends quickly with a bounded form.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">table._row.addInstance(1); table._row.count</script></calculate>"#,
)
.replace(
r#"table._row.addInstance(1);</script>"#,
r#"for (var i = 0; i < 100000; i++) { table._row.addInstance(1); table._row.setInstances(0); }</script>"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count();
assert!(rows(&s) <= 50, "{} rows", rows(&s));
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}");
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
}
#[test]
fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() {
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, r#"table._row.setInstances(1);"#)
.replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, r#"xfa.host.resetData("qty, table");"#);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count();
s.run_javascript(id, "", Some("addRow")).unwrap();
s.run_javascript(id, "", Some("addRow")).unwrap();
assert_eq!(rows(&s), 3);
s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("7".into()) }).unwrap();
// setInstances(1) removes two rows in one script: both go, nothing comes back.
s.run_javascript(id, "", Some("removeRow")).unwrap();
assert_eq!(rows(&s), 1, "{:?}", s.get(id).unwrap().form.iter().map(|f| &f.name).collect::<Vec<_>>());
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let data = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 1);
// resetData with names clears those fields (the row's amount too) and nothing else.
s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("9".into()) }).unwrap();
let o = s.run_javascript(id, "", Some("hello")).unwrap();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
assert!(value(&s, "qty").is_empty(), "{:?} {o:?}", value(&s, "qty"));
assert!(value(&s, "amount").is_empty());
assert_eq!(value(&s, "price"), vec!["9".to_string()]);
}
/// Streams in `bytes` (every revision) that hold an XFA datasets packet.
fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
cos.object_numbers()
.into_iter()
.filter(|&n| {
cos.try_get(n).ok().is_some_and(|o| match &*o {
pdfcraft_cos::Object::Stream(s) => s.decoded_within(1 << 24).is_ok_and(|b| b.windows(9).any(|w| w == b"datasets>")),
_ => false,
})
})
.count()
}
#[test]
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
// thousand messages and asking to print, open a link and save.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 50000; i++) this.rawValue = i;</script></calculate>"#,
)
.replace(
r#"if (qty.rawValue === null) qty.rawValue = 2;"#,
r#"for (var i = 0; i &lt; 1000; i++) xfa.host.messageBox("m" + i); xfa.host.print(); xfa.host.gotoURL("https://example.com/"); app.execMenuItem("SaveAs"); if (qty.rawValue === null) qty.rawValue = 2;"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
let doc = s.get(id).unwrap();
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]);
// One datasets stream for the scripts' revision, not one per value set.
assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes));
// What opening changed is noted with the form's other XFA notes.
assert!(doc.xfa_warnings.iter().any(|w| w.contains("scripts changed it on opening")), "{:?}", doc.xfa_warnings);
let out = s.take_js_output(id);
assert_eq!(out.alerts.len(), 100, "message boxes are capped");
assert!(out.errors.iter().any(|e| e.contains("more than 100 messages")), "{:?}", out.errors);
assert!(out.requests.is_empty(), "only a click may print, save or open links: {:?}", out.requests);
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
}
#[test]
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
// call) are abandoned at the time limit and turn the form's scripts off.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 1e9; i++) { for (var j = 0; j &lt; 1e9; j++) {} }</script></calculate>"#,
)
.replace(
r#"var t = 0; var rows"#,
r#"function f() { for (var j = 0; j &lt; 99999; j++) {} } for (var k = 0; k &lt; 5000; k++) f(); var t = 0; var rows"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("runaway.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(4), "{:?}", started.elapsed());
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors);
// The scripts stay off: a change runs nothing more.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
}
+755
View File
@@ -0,0 +1,755 @@
//! XFA scripting in the engine: a form's scripted events (initialize and calculate on open,
//! exit, validate and calculate after a field changes, click for buttons) run through
//! [`pdfcraft_js::xfa`] over the live form tree [`pdfcraft_xfa::form_tree`] builds, and what
//! they change or ask for is applied here: values go to the fields (and so to the datasets),
//! presence and access changes become overrides kept in the PDF, added or removed rows change
//! the datasets, and any of those lays the form out again.
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within};
use pdfcraft_xfa::model::Template;
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
use crate::js::JsOutput;
use crate::js::Request;
/// Most scripts run for one event (calculates of a whole form, say).
const MAX_SCRIPTS_PER_EVENT: usize = 2_000;
/// Most times the form is laid out again for one event (a script adding rows in a loop).
const MAX_RELAYOUTS_PER_EVENT: usize = 50;
/// Most message boxes, console lines and errors kept for one event.
const MAX_ALERTS_PER_EVENT: usize = 100;
const MAX_CONSOLE_PER_EVENT: usize = 1_000;
const MAX_ERRORS_PER_EVENT: usize = 100;
/// Time the scripts of one event may take in all before the rest are skipped: opening (and
/// the calculations every change runs) is held tighter than a click.
const OPEN_BUDGET_MS: u64 = 3_000;
const EVENT_BUDGET_MS: u64 = 5_000;
/// Engine limits for one script: initialize, calculate and validate scripts run without the
/// user asking (on open, on every change), so they get fewer loop iterations than a click.
fn limits_for(activity: &str) -> pdfcraft_js::Limits {
match activity {
"click" | "change" | "exit" | "enter" => pdfcraft_js::Limits::default(),
_ => pdfcraft_js::Limits { loop_iterations: 100_000, recursion: 64 },
}
}
/// Longest one script may run before it is abandoned (it can't be interrupted, only left
/// behind): scripts that run unasked are held tighter than a click's.
fn timeout_for(activity: &str) -> std::time::Duration {
std::time::Duration::from_millis(match activity {
"click" | "change" | "exit" | "enter" => 3_000,
_ => 1_000,
})
}
/// Requests only a click may make (anything else asking is noted and ignored).
fn click_only(e: &XfaEffect) -> Option<&'static str> {
match e {
XfaEffect::Print => Some("print"),
XfaEffect::SaveAs => Some("save"),
XfaEffect::LaunchUrl(_) => Some("open a link"),
XfaEffect::SetFocus(_) => Some("move the focus"),
_ => None,
}
}
/// A wall clock where there is one (not in the browser build, where the script and relayout
/// caps alone bound an event).
#[derive(Clone, Copy)]
struct Clock {
#[cfg(not(target_arch = "wasm32"))]
started: std::time::Instant,
budget_ms: u64,
}
impl Clock {
fn start(budget_ms: u64) -> Self {
Clock {
#[cfg(not(target_arch = "wasm32"))]
started: std::time::Instant::now(),
budget_ms,
}
}
fn spent(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.started.elapsed() >= std::time::Duration::from_millis(self.budget_ms)
}
#[cfg(target_arch = "wasm32")]
{
let _ = self.budget_ms;
false
}
}
}
/// What the scripts of an event changed in the form (for the note opening leaves).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Changes {
pub values: usize,
pub rows: usize,
pub visibility: usize,
}
impl Changes {
pub fn is_empty(&self) -> bool {
self.values == 0 && self.rows == 0 && self.visibility == 0
}
/// "3 values, 1 row" — what changed, for a note.
pub fn describe(&self) -> String {
let mut parts = Vec::new();
let n = |k: usize, one: &str, many: &str| format!("{k} {}", if k == 1 { one } else { many });
if self.values > 0 {
parts.push(n(self.values, "field value", "field values"));
}
if self.rows > 0 {
parts.push(n(self.rows, "added or removed row", "added or removed rows"));
}
if self.visibility > 0 {
parts.push(n(self.visibility, "shown, hidden or locked object", "shown, hidden or locked objects"));
}
parts.join(", ")
}
}
/// What a run of the form's scripts shares with the edit around it.
pub(crate) struct XfaRun<'a> {
pub page_count: usize,
pub out: &'a mut JsOutput,
/// The datasets stream this edit already wrote, replaced in place by later writes.
pub datasets: Option<pdfcraft_cos::ObjRef>,
/// A script ran past its time limit and was abandoned: the form's scripts should stay off.
pub ran_away: bool,
}
fn to_js(n: &FormNode) -> XfaNode {
XfaNode {
name: n.name.clone(),
som: n.som.clone(),
kind: n.kind.map(|k| match k {
NodeKind::Subform => XfaKind::Subform,
NodeKind::Field => XfaKind::Field,
NodeKind::ExclGroup => XfaKind::ExclGroup,
NodeKind::Draw => XfaKind::Draw,
NodeKind::Area => XfaKind::Area,
}),
value: n.value.clone(),
numeric: n.numeric,
presence: n.presence.clone(),
access: n.access.clone(),
repeatable: n.repeatable,
occur_min: n.occur_min,
occur_max: n.occur_max,
index: n.index,
children: n.children.iter().map(to_js).collect(),
}
}
fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> {
if depth > 64 {
return None;
}
if n.som == som && n.kind != Some(NodeKind::Field) {
return Some(n);
}
n.children.iter().find_map(|c| find_container(c, som, depth + 1))
}
fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
let parent = if parent_som.is_empty() { Some(root) } else { find_container(root, parent_som, 0) };
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
}
/// An instance SOM (`…table[0].row[2]`) split into its parent's data path and SOM, its name
/// and its index.
fn split_instance(som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> {
let mut path = pdfcraft_xfa::som_to_path(som);
let (name, index) = path.pop()?;
let parent_som = som.rsplit_once('.').map(|(p, _)| p).unwrap_or("").to_string();
Some((path, parent_som, name, index))
}
/// A SOM path without its indexes (`form.page1.qty`), for matching names scripts pass.
fn plain_som(som: &str) -> String {
let mut out = String::with_capacity(som.len());
let mut skip = false;
for c in som.chars() {
match c {
'[' => skip = true,
']' => skip = false,
_ if !skip => out.push(c),
_ => {}
}
}
out
}
/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
/// such as `table`) name this SOM or one of its ancestors?
fn som_matches(som: &str, name: &str) -> bool {
let plain = plain_som(som);
let n = plain_som(name.trim());
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
!want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
}
/// A generated field as the runner tracks it: kind, value and on state.
#[derive(Clone)]
struct FieldInfo {
kind: pdfcraft_forms::FieldKind,
value: Vec<String>,
on_state: String,
}
fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap<String, FieldInfo> {
pdfcraft_forms::fields(doc)
.into_iter()
.map(|f| {
let on_state = f.widgets.first().and_then(|w| w.on_state.clone()).unwrap_or_else(|| "1".into());
(f.name, FieldInfo { kind: f.kind, value: f.value, on_state })
})
.collect()
}
/// The data text of a field's value.
fn data_text(info: &FieldInfo) -> String {
match info.kind {
pdfcraft_forms::FieldKind::CheckBox => {
if info.value.is_empty() {
String::new()
} else {
info.on_state.clone()
}
}
_ => info.value.join("\n"),
}
}
/// Runs the scripts of one event (opening, a change, a click) against one document: what
/// they change is kept in memory (data, field values, overrides) and written once, when the
/// event ends or the form has to be laid out again.
struct Runner<'a, 'b> {
doc: &'a mut pdfcraft_cos::Document,
tpl: &'a Template,
run: &'a mut XfaRun<'b>,
clock: Clock,
formcalc_warned: bool,
runs: usize,
skipped: usize,
relayouts: usize,
relayouts_skipped: bool,
dropped_output: usize,
truncated_warned: bool,
data: Option<pdfcraft_xfa::DataNode>,
ov: pdfcraft_xfa::Overrides,
ov_dirty: bool,
pending: Vec<pdfcraft_xfa::DataOp>,
/// Field values to write, by name (the last wins).
field_changes: std::collections::BTreeMap<String, Vec<String>>,
by_som: Option<HashMap<String, String>>,
fields: Option<HashMap<String, FieldInfo>>,
/// The live form as last built; dropped when a script changed the form.
cached: Option<LiveForm>,
changes: Changes,
}
impl<'a, 'b> Runner<'a, 'b> {
fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self {
let data = pdfcraft_xfa::data_of(doc);
let ov = pdfcraft_xfa::overrides(doc);
Runner {
doc,
tpl,
run,
clock: Clock::start(budget_ms),
formcalc_warned: false,
runs: 0,
skipped: 0,
relayouts: 0,
relayouts_skipped: false,
dropped_output: 0,
truncated_warned: false,
data,
ov,
ov_dirty: false,
pending: Vec::new(),
field_changes: Default::default(),
by_som: None,
fields: None,
cached: None,
changes: Changes::default(),
}
}
fn live(&mut self) -> &LiveForm {
if self.cached.is_none() {
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
if live.truncated && !self.truncated_warned {
self.truncated_warned = true;
self.error(format!(
"The form has more than {} objects or 20000 scripted events; its scripts see only the first ones",
pdfcraft_xfa::MAX_FORM_NODES
));
}
self.cached = Some(live);
}
self.cached.get_or_insert_with(Default::default)
}
fn by_som(&mut self) -> &HashMap<String, String> {
if self.by_som.is_none() {
self.by_som = Some(pdfcraft_xfa::fields_by_som(self.doc));
}
self.by_som.get_or_insert_with(Default::default)
}
fn fields(&mut self) -> &mut HashMap<String, FieldInfo> {
if self.fields.is_none() {
self.fields = Some(field_infos(self.doc));
}
self.fields.get_or_insert_with(Default::default)
}
fn error(&mut self, e: String) {
if self.run.out.errors.len() < MAX_ERRORS_PER_EVENT {
self.run.out.errors.push(e);
} else {
self.dropped_output += 1;
}
}
fn console(&mut self, lines: impl IntoIterator<Item = String>) {
for l in lines {
if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT {
self.run.out.console.push(l);
} else {
self.dropped_output += 1;
}
}
}
fn alert(&mut self, m: String) {
if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT {
self.run.out.alerts.push(m);
} else {
self.dropped_output += 1;
}
}
/// Change the data (and the in-memory copy scripts read).
fn data_op(&mut self, op: pdfcraft_xfa::DataOp) {
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
self.cached = None;
}
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
/// whether it changed.
fn set_value(&mut self, som: &str, value: &str) -> bool {
let path = pdfcraft_xfa::som_to_path(som);
let name = self.by_som().get(som).cloned();
let Some(name) = name else {
// No widget (a hidden field, a draw, a row not laid out yet): the data alone.
if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) {
return false;
}
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: value.to_string() });
self.changes.values += 1;
return true;
};
let Some(info) = self.fields().get(&name).cloned() else { return false };
use pdfcraft_forms::FieldKind as K;
let new_value: Vec<String> = match info.kind {
K::CheckBox => {
if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state {
vec![info.on_state.clone()]
} else {
Vec::new()
}
}
K::Radio => {
let v = value.trim();
if v.is_empty() { Vec::new() } else { vec![v.to_string()] }
}
// Read-only fields still take values from scripts (calculated totals are read-only).
K::PushButton | K::Signature => return false,
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
if same {
return false;
}
let updated = FieldInfo { value: new_value.clone(), ..info };
let text = data_text(&updated);
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name, new_value);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
true
}
/// Write what the scripts changed so far: the data as one rewrite, the field values, the
/// overrides.
fn flush(&mut self) -> Result<(), String> {
let mut wrote = false;
if !self.pending.is_empty() {
let ops = std::mem::take(&mut self.pending);
let r = pdfcraft_xfa::write_data_ops(self.doc, &ops, self.run.datasets).map_err(|e| e.to_string())?;
if r.stream.is_some() {
self.run.datasets = r.stream;
}
self.console(r.warnings);
wrote = true;
}
if !self.field_changes.is_empty() {
let changes: Vec<pdfcraft_forms::FieldChange> = std::mem::take(&mut self.field_changes)
.into_iter()
.map(|(name, value)| pdfcraft_forms::FieldChange { name, value: Some(value), ..Default::default() })
.collect();
pdfcraft_forms::apply_script_changes(self.doc, &changes, &mut pdfcraft_forms::NoScripts).map_err(|e| e.to_string())?;
self.fields = None;
}
if self.ov_dirty {
pdfcraft_xfa::set_overrides(self.doc, &self.ov).map_err(|e| e.to_string())?;
self.ov_dirty = false;
}
if wrote {
// What the packet now holds (a value it could not take is reported above).
self.data = pdfcraft_xfa::data_of(self.doc);
self.cached = None;
}
Ok(())
}
/// Lay the form out again and give the new widgets their appearances.
fn relayout(&mut self) -> Result<(), String> {
if self.relayouts >= MAX_RELAYOUTS_PER_EVENT {
if !self.relayouts_skipped {
self.relayouts_skipped = true;
self.error(format!(
"The form's scripts asked to lay it out again more than {MAX_RELAYOUTS_PER_EVENT} times; the form is laid out once more when they finish"
));
}
return Ok(());
}
self.relayouts += 1;
self.flush()?;
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
self.console(report.warnings);
for f in pdfcraft_forms::fields(self.doc) {
pdfcraft_forms::redraw_field(self.doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
}
self.by_som = None;
self.fields = None;
self.cached = None;
Ok(())
}
/// `xfa.host.resetData`: the fields back to their defaults, in the form and the data.
fn reset(&mut self, names: &[String]) -> Result<(), String> {
self.flush()?;
let by_som = self.by_som().clone();
let listed: Option<Vec<String>> = if names.is_empty() {
None
} else {
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
};
if listed.as_ref().is_some_and(|l| l.is_empty()) {
return Ok(());
}
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
self.fields = None;
let infos = self.fields().clone();
for (som, name) in &by_som {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
}
let Some(info) = infos.get(name) else { continue };
if matches!(info.kind, pdfcraft_forms::FieldKind::PushButton | pdfcraft_forms::FieldKind::Signature) {
continue;
}
let path = pdfcraft_xfa::som_to_path(som);
let text = data_text(info);
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
}
}
self.cached = None;
Ok(())
}
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result<bool, String> {
let mut relayout = false;
// Instances of each repeating subform before the script ran, as the effects leave them.
let mut counts: HashMap<(String, String), usize> = HashMap::new();
{
let live = self.live();
for e in effects {
if let XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } = e
&& let Some((_, parent_som, name, _)) = split_instance(som)
{
let have = count_instances(&live.root, &parent_som, &name);
counts.entry((parent_som, name)).or_insert(have);
}
}
}
for e in effects {
// Only a click script may print, save, open links or move the focus (not the
// calculations a click sets off, nor anything run on open or on a change).
if let Some(what) = click_only(e)
&& ev.activity != "click"
{
let note = format!("{} ({}): a script may only {what} when a button is clicked; ignored", ev.som, ev.activity);
self.console([note]);
continue;
}
match e {
XfaEffect::SetValue { som, value } => {
self.set_value(som, value);
}
XfaEffect::SetPresence { som, presence } => {
if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.cached = None;
relayout = true;
}
}
XfaEffect::SetAccess { som, access } => {
if self.ov.access.insert(som.clone(), access.clone()).as_ref() != Some(access) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.cached = None;
relayout = true;
}
}
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => {
let Some((parent, parent_som, name, index)) = split_instance(som) else { continue };
let key = (parent_som, name.clone());
let have = counts.get(&key).copied().unwrap_or(0);
if matches!(e, XfaEffect::AddInstance { .. }) {
counts.insert(key, have + 1);
self.data_op(pdfcraft_xfa::DataOp::Instances { parent, name, count: (index + 1).max(have + 1) });
} else {
// The data holds every shown instance before one is taken out.
counts.insert(key, have.saturating_sub(1));
self.data_op(pdfcraft_xfa::DataOp::Instances { parent: parent.clone(), name: name.clone(), count: have });
let mut path = parent;
path.push((name, index));
self.data_op(pdfcraft_xfa::DataOp::Remove(path));
}
self.changes.rows += 1;
relayout = true;
}
XfaEffect::MessageBox(m) => self.alert(m.clone()),
XfaEffect::ResetData(names) => self.reset(names)?,
XfaEffect::Print => self.run.out.requests.push(Request::Print),
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
XfaEffect::SetFocus(som) => {
if let Some(name) = self.by_som().get(som).cloned() {
self.run.out.requests.push(Request::Focus(name));
}
}
XfaEffect::Beep => self.run.out.requests.push(Request::Beep),
XfaEffect::Recalculate => {}
XfaEffect::Relayout => relayout = true,
}
}
Ok(relayout)
}
/// Run one event script against the form as it is now. `None`: it was skipped (the
/// event's budget is spent).
fn run(&mut self, ev: &ScriptEvent, new_text: &str) -> Result<Option<pdfcraft_js::xfa::XfaOutcome>, String> {
if self.runs >= MAX_SCRIPTS_PER_EVENT || self.clock.spent() || self.run.ran_away {
self.skipped += 1;
return Ok(None);
}
self.runs += 1;
if ev.formcalc {
if !self.formcalc_warned {
self.error(format!("{}: FormCalc scripts don't run yet (only JavaScript ones)", ev.som));
self.formcalc_warned = true;
}
return Ok(Some(Default::default()));
}
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
let root_js = to_js(&self.live().root);
let o = run_xfa_within(&ev.script, &event, &doc_info, root_js, limits_for(&ev.activity), timeout_for(&ev.activity));
if o.abandoned {
self.run.ran_away = true;
}
self.console(o.console.iter().cloned());
for n in &o.notes {
self.error(format!("{} ({}): {n}", ev.som, ev.activity));
}
if let Some(e) = &o.error {
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
return Ok(Some(o));
}
if self.apply_effects(ev, &o.effects)? {
self.relayout()?;
}
Ok(Some(o))
}
/// Every calculate script, in document order: the field takes the script's result unless
/// the script set a value itself.
fn calculates(&mut self) -> Result<(), String> {
let events: Vec<ScriptEvent> = self.live().events.iter().filter(|e| e.activity == "calculate").cloned().collect();
for ev in &events {
let Some(o) = self.run(ev, "")? else { continue };
if o.error.is_some() || o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if *som == ev.som)) {
continue;
}
if let Some(result) = o.result {
self.set_value(&ev.som, &result);
}
}
Ok(())
}
fn events_of(&mut self, som: &str, activity: &str) -> Vec<ScriptEvent> {
self.live().events.iter().filter(|e| e.som == som && e.activity == activity).cloned().collect()
}
/// End the event: write what is left, lay the form out if a capped relayout is owed, and
/// report what the limits cut.
fn finish(mut self) -> Result<Changes, String> {
if self.relayouts_skipped {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout()?;
}
self.flush()?;
if self.skipped > 0 {
let why = if self.run.ran_away {
"one ran too long".to_string()
} else if self.runs >= MAX_SCRIPTS_PER_EVENT {
format!("more than {MAX_SCRIPTS_PER_EVENT} scripts ran")
} else {
format!("the scripts took more than {} seconds", self.clock.budget_ms / 1000)
};
self.run.out.errors.push(format!("The form's scripts were stopped: {why}; {} more were skipped", self.skipped));
}
if self.dropped_output > 0 {
self.run.out.errors.push(format!("The form's scripts produced too much output; {} more messages were left out", self.dropped_output));
}
Ok(self.changes)
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
let events: Vec<ScriptEvent> = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect();
for ev in &events {
r.run(ev, "")?;
}
r.calculates()?;
r.finish()
}
/// After fields `names` took new values: each one's change, exit and validate scripts, then
/// the calculations once (or only those, after a reset). A validate script that answers false,
/// or fails, shows its message; the value stays, as Acrobat marks the field invalid rather
/// than reverting it.
pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names: &[String], run: &mut XfaRun) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
for name in names {
let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone());
let Some(som) = som else { continue };
let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default();
for ev in r.events_of(&som, "change").into_iter().chain(r.events_of(&som, "exit")) {
r.run(&ev, &value)?;
}
for ev in r.events_of(&som, "validate") {
let Some(o) = r.run(&ev, &value)? else { continue };
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")));
}
}
}
r.calculates()?;
r.finish().map(|_| ())
}
/// A button's click script (or any event, by SOM path and activity), then calculations.
pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
let events = r.events_of(som, activity);
if events.is_empty() {
return Err(format!("{som} has no {activity} script"));
}
for ev in events {
r.run(&ev, "")?;
}
// The calculations that follow get a budget of their own.
r.clock = Clock::start(OPEN_BUDGET_MS);
r.calculates()?;
r.finish().map(|_| ())
}
/// The SOM path of field `name` when PdfCraft generated it from an XFA template and the
/// template has a click script for it (a button with a native action has none).
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, name: &str) -> Option<String> {
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, f)| f == name).map(|(s, _)| s)?;
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som)
}
/// The parsed template of a laid-out XFA form, kept for the document's life, when it has
/// scripts to run (`None` otherwise: nothing about the form is scripted).
pub(crate) fn template(doc: &pdfcraft_cos::Document) -> Option<Arc<Template>> {
pdfcraft_xfa::template_of(doc).ok().filter(pdfcraft_xfa::has_scripts).map(Arc::new)
}
/// Push buttons PdfCraft generated for XFA click scripts carry no PDF action (the script is
/// XFA's, not Acrobat JavaScript): give them one that runs the script by the field's SOM path.
pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pdfcraft_forms::Field]) {
for f in fields.iter_mut() {
if f.kind == pdfcraft_forms::FieldKind::PushButton
&& f.button.is_none()
&& std::iter::once(f.obj)
.chain(f.widgets.iter().map(|w| w.obj))
.any(|r| doc.get(r).as_dict().is_some_and(|d| d.contains(pdfcraft_xfa::CLICK_KEY)))
{
f.button = Some(pdfcraft_forms::af::ButtonAction::Script(String::new()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_names_match_indexed_som_paths() {
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
assert_eq!(
split_instance("form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}
}