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 03:16:54 -05:00 committed by GitHub
parent dfb4e26e1a
commit 11ffedcc98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 4059 additions and 60 deletions

View File

@ -211,6 +211,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Submit(u) => json!({ "submit": u }),
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::SaveAs => json!({ "save_as": true }),
}
}

View File

@ -771,7 +771,7 @@ pub fn tools() -> Vec<ToolDef> {
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.")
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),

View File

@ -2023,3 +2023,32 @@ fn measurement_snap_tool_covers_all_targets() {
assert!(ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":[180,180]}))["snap"].is_null());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let dir = workdir("xfa-scripts");
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
let field = |a: &mut Automation, n: &str| {
let f = ok(a, "form_fields", json!({ "doc": doc }));
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).cloned()
};
// Opening ran the initialize and calculate scripts.
assert_eq!(field(&mut a, "qty").unwrap()["value"], "2");
assert_eq!(field(&mut a, "total").unwrap()["value"], "10");
// Filling recalculates; a bad value shows its message (the tool reports alerts in js output? no: it is applied, the value stays).
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "4" } }));
assert_eq!(field(&mut a, "total").unwrap()["value"], "20");
// A button's XFA click script runs through js_run, like any button.
let before = ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap();
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "addRow" }));
assert!(r["error"].is_null(), "{r}");
assert!(field(&mut a, "amount_2").is_some());
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap(), before + 2);
// Undo takes the row away again.
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert!(field(&mut a, "amount_2").is_none());
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
assert_eq!(r["alerts"], json!(["Hello 4"]));
}

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();

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);

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
crates/engine/src/xfa.rs Normal file
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))
);
}
}

View File

@ -157,6 +157,8 @@ pub enum Request {
/// `field.setFocus()`.
Focus(String),
Beep,
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
SaveAs,
}
/// What running a script did.
@ -1138,5 +1140,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], d
out
}
pub mod xfa;
#[cfg(test)]
mod tests;

View File

@ -183,3 +183,234 @@ fn ordinary_nesting_still_runs() {
let s = format!("// {}\nevent.value = '{}'.length;", "(".repeat(1000), "[".repeat(1000));
assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000");
}
mod xfa_model {
use crate::Limits;
use crate::xfa::*;
fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode {
XfaNode {
name: name.into(),
som: som.into(),
kind: Some(XfaKind::Field),
value: value.into(),
numeric,
presence: "visible".into(),
..Default::default()
}
}
/// form1 > page1 > { qty, price, total, details (subform, hidden) > note, table > row[0..2] > { what, amount }, go (button) }
fn form() -> XfaNode {
let row = |i: usize| XfaNode {
name: "row".into(),
som: format!("form1[0].page1[0].table[0].row[{i}]"),
kind: Some(XfaKind::Subform),
repeatable: true,
occur_min: 1,
occur_max: None,
index: i,
children: vec![
field("what", &format!("form1[0].page1[0].table[0].row[{i}].what[0]"), if i == 0 { "Rent" } else { "" }, false),
field("amount", &format!("form1[0].page1[0].table[0].row[{i}].amount[0]"), if i == 0 { "10" } else { "5" }, true),
],
..Default::default()
};
XfaNode {
name: "form1".into(),
som: "form1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![XfaNode {
name: "page1".into(),
som: "form1[0].page1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![
field("qty", "form1[0].page1[0].qty[0]", "4", true),
field("price", "form1[0].page1[0].price[0]", "2.5", true),
field("total", "form1[0].page1[0].total[0]", "", true),
XfaNode {
name: "details".into(),
som: "form1[0].page1[0].details[0]".into(),
kind: Some(XfaKind::Subform),
presence: "hidden".into(),
children: vec![field("note", "form1[0].page1[0].details[0].note[0]", "", false)],
..Default::default()
},
XfaNode {
name: "table".into(),
som: "form1[0].page1[0].table[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![row(0), row(1)],
..Default::default()
},
field("go", "form1[0].page1[0].go[0]", "", false),
],
..Default::default()
}],
..Default::default()
}
}
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
run_xfa(script, &ev, &doc, &form(), Limits::default())
}
#[test]
fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() {
let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("10"));
let o = go("this.rawValue = xfa.form.form1.page1.qty.rawValue + 1;", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
}
#[test]
fn values_read_as_numbers_or_strings_and_null_when_empty() {
let o = go(
"typeof qty.rawValue + ':' + typeof this.rawValue + ':' + (total.rawValue === null) + ':' + typeof table.row.what.rawValue",
"form1[0].page1[0].price[0]",
"click",
);
assert_eq!(o.result.as_deref(), Some("number:number:true:string"));
}
#[test]
fn navigation_parent_resolve_node_and_nodes() {
let o = go(
"this.parent.name + '/' + this.parent.parent.className + '/' + this.resolveNode('qty').rawValue + '/' + xfa.resolveNode('$form.form1.page1.table.row[1].amount').rawValue + '/' + this.parent.nodes.length + '/' + xfa.resolveNodes('form1.page1.table.row[*]').length + '/' + xfa.resolveNode('$form..amount').somExpression",
"form1[0].page1[0].price[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("page1/subform/4/5/6/2/form1[0].page1[0].table[0].row[0].amount[0]"));
}
#[test]
fn presence_and_access_changes_are_effects() {
let o = go("details.presence = 'visible'; details.note.access = 'readOnly'; this.presence = 'nonsense';", "form1[0].page1[0].go[0]", "click");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(
o.effects,
vec![
XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() },
XfaEffect::SetAccess { som: "form1[0].page1[0].details[0].note[0]".into(), access: "readOnly".into() },
]
);
}
#[test]
fn instance_managers_add_remove_and_count() {
let o = go(
"var n = table._row.count; var r = table._row.addInstance(1); r.what.rawValue = 'New'; n + '/' + table._row.count + '/' + r.somExpression + '/' + r.index",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2/3/form1[0].page1[0].table[0].row[2]/2"));
assert_eq!(
o.effects,
vec![
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
XfaEffect::SetValue { som: "form1[0].page1[0].table[0].row[2].what[0]".into(), value: "New".into() },
]
);
// Through the instance manager property, from inside a row; removing renumbers.
let o = go(
"this.parent.instanceManager.removeInstance(0); xfa.resolveNode('table.row[0]').amount.rawValue",
"form1[0].page1[0].table[0].row[1].amount[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("5"), "the second row is now the first");
assert_eq!(o.effects, vec![XfaEffect::RemoveInstance { som: "form1[0].page1[0].table[0].row[0]".into() }]);
// setInstances, and the floor of one instance.
let o = go(
"table._row.setInstances(4); var a = table._row.count; table._row.setInstances(0); a + '/' + table._row.count",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.result.as_deref(), Some("4/1"));
}
#[test]
fn host_layout_event_and_app_calls_become_effects() {
let o = go(
"xfa.host.messageBox('Hi', 'T', 3, 1); xfa.host.resetData('qty, price'); xfa.host.print(1, '0', '2', 0, 0, 0, 0, 0); app.execMenuItem('SaveAs'); app.launchURL('https://x.test'); xfa.host.setFocus('qty'); xfa.form.recalculate(1); console.println('p' + xfa.layout.page(this) + '/' + xfa.layout.pageCount() + '/' + xfa.host.currentPage + '/' + xfa.event.name); this.border.fill.color.value = '255,0,0'; this.fillColor = 'x'; util.printf('%d', 7)",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(
o.effects,
vec![
XfaEffect::MessageBox("Hi".into()),
XfaEffect::ResetData(vec!["qty".into(), "price".into()]),
XfaEffect::Print,
XfaEffect::SaveAs,
XfaEffect::LaunchUrl("https://x.test".into()),
XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()),
XfaEffect::Recalculate,
]
);
assert_eq!(o.console, vec!["p2/3/1/click".to_string()]);
assert_eq!(o.result.as_deref(), Some("7"));
}
#[test]
fn field_names_never_shadow_javascript_builtins() {
let mut f = form();
f.children[0].children.push(field("Date", "form1[0].page1[0].Date[0]", "x", false));
f.children[0].children.push(field("eval", "form1[0].page1[0].eval[0]", "y", false));
let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let o = run_xfa(
"typeof new Date().getTime() + ':' + this.parent.Date.rawValue + ':' + this.parent.eval.rawValue",
&ev,
&XfaDoc::default(),
&f,
Limits::default(),
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("number:x:y"));
}
#[test]
fn validate_scripts_give_a_verdict_and_errors_are_reported() {
let o = go("this.rawValue === null || this.rawValue <= 3", "form1[0].page1[0].qty[0]", "validate");
assert_eq!(o.result_bool, Some(false));
let o = go("nosuch.rawValue = 1", "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some() && o.effects.is_empty());
let o = go("while (true) {}", "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some(), "the loop limit stops it");
let o = go(&"(".repeat(200), "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some());
// Adding instances is capped; a runaway loop can't blow the tree up.
let o = go("for (var i = 0; i < 5000; i++) table._row.addInstance(1); table._row.count", "form1[0].page1[0].go[0]", "click");
assert_eq!(o.result.as_deref(), Some("1002"));
}
#[test]
fn values_set_again_merge_and_hostile_loops_are_capped() {
// A calculate that sets its own value in a loop leaves one effect: the last value.
let o = go("for (var i = 0; i < 50000; i++) this.rawValue = i;", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "49999".into() }], "{:?}", o.error);
// Order-dependent effects in between keep the values on either side.
let o = go("qty.rawValue = 1; xfa.host.resetData(); qty.rawValue = 2; qty.rawValue = 3;", "form1[0].page1[0]", "click");
assert_eq!(o.effects.len(), 3, "{:?}", o.effects);
assert_eq!(o.effects.last(), Some(&XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "3".into() }));
// Message boxes and console lines in a loop stop at their caps, and say so.
let o = go("for (var i = 0; i < 100000; i++) { xfa.host.messageBox('m' + i); console.println('c' + i); }", "form1[0].page1[0]", "initialize");
let alerts = o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count();
assert_eq!(alerts, MAX_ALERTS);
assert_eq!(o.console.len(), MAX_CONSOLE);
assert!(o.notes.iter().any(|n| n.contains("messages")) && o.notes.iter().any(|n| n.contains("console")), "{:?}", o.notes);
// Distinct effects stop at the effect cap.
let o = go("for (var i = 0; i < 30000; i++) xfa.host.beep();", "form1[0].page1[0]", "click");
assert_eq!(o.effects.len(), MAX_EFFECTS);
assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes);
// Long messages are cut.
let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click");
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
}
}

1341
crates/js/src/xfa.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1757,6 +1757,9 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_
}
B::Named(n) => match n.as_str() {
"Print" => app.open_print(),
// What PdfCraft's XFA buttons use, and what a script's `execMenuItem("Save")`
// does too: the Save As dialog, so a click never overwrites the file unasked.
"SaveAs" => app.run_command("file.save_as"),
"NextPage" => app.views[index].step_page(true),
"PrevPage" => app.views[index].step_page(false),
"FirstPage" => app.views[index].go_to_page(0),

View File

@ -48,6 +48,7 @@ impl PdfCraftApp {
"The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.",
&[("u", &u)],
),
Request::SaveAs => self.run_command("file.save_as"),
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
}
}

View File

@ -744,6 +744,10 @@ impl PdfCraftApp {
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
self.recent.truncate(12);
}
// What the form's scripts said while it opened (messages, errors) shows now, not with
// the next edit.
let out = self.session.take_js_output(id);
self.handle_js(id, out);
Ok(())
}

View File

@ -1,20 +1,22 @@
//! Push buttons run their actions (Reset form, Named, …) without a JavaScript engine.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
/// Two pages; page 1 has a text field "name" (filled), a Reset button, a Next page button and a
/// Print button (a JavaScript one-liner).
fn fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [20 340 280 360] /P 3 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (reset) /Rect [20 300 100 320] /P 3 0 R /A << /S /ResetForm >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (next) /Rect [120 300 200 320] /P 3 0 R /A << /S /Named /N /NextPage >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (print) /Rect [220 300 280 320] /P 3 0 R /AA << /U << /S /JavaScript /JS (this.print\\({bUI: true}\\);) >> >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (save) /Rect [20 260 100 280] /P 3 0 R /A << /S /Named /N /Save >> >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
@ -48,9 +50,14 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
#[test]
fn buttons_reset_navigate_and_print() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let dir = std::env::temp_dir().join(format!("pdfcraft-buttons-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("buttons.pdf");
std::fs::write(&path, fixture()).unwrap();
let path_str = path.to_string_lossy().into_owned();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("buttons.pdf", None, fixture()).unwrap();
app.open_bytes("buttons.pdf", Some(path_str), fixture()).unwrap();
app.set_option("zoom", "150").unwrap();
app
});
@ -65,6 +72,15 @@ fn buttons_reset_navigate_and_print() {
assert_eq!(h.state().dialog, Some(Dialog::Print), "this.print() opens the Print dialog");
h.state_mut().dialog = None;
h.run_steps(2);
// A document's Save named action never writes the file unasked (PdfCraft's XFA buttons
// use SaveAs, which opens the Save As dialog, as a script's `execMenuItem` does): the
// reset above made the document dirty, and the click leaves it so.
let before = std::fs::read(&path).unwrap();
click_field(&mut h, "save");
h.run_steps(4);
h.get_by_label_contains("isn't supported");
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "not saved");
assert_eq!(std::fs::read(&path).unwrap(), before, "the file is untouched");
click_field(&mut h, "next");
h.run_steps(4);
assert_eq!(h.state().views[0].current, 1, "the NextPage action");

View File

@ -101,3 +101,53 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() {
let h = open(pdf("/AcroForm << /Fields [] /XFA 42 >>", "", &[]));
assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic));
}
/// Click the centre of a field's widget.
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
let p = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"));
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center()
};
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
}
#[test]
fn xfa_buttons_run_their_scripts_in_the_app() {
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
for _ in 0..40 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let names = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>()
};
assert!(!names(&h).iter().any(|n| n == "amount_2"));
click_field(&mut h, "addRow");
assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}", names(&h));
// The message box shows as a notice.
click_field(&mut h, "hello");
h.get_by_label_contains("Hello 2");
}
#[test]
fn messages_from_scripts_run_on_open_show_right_away() {
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace("if (qty.rawValue === null) qty.rawValue = 2;", r#"xfa.host.messageBox("Welcome to the form"); xfa.host.print();"#);
let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl));
h.run_steps(2);
h.get_by_label_contains("Welcome to the form");
assert_eq!(h.state().dialog, None, "an initialize script can't open the Print dialog");
let id = h.state().views[0].id;
assert!(h.state_mut().session.take_js_output(id).is_empty(), "nothing waits for the next edit");
}

View File

@ -48,6 +48,26 @@ the automation tools) works on it unchanged. The engine does this when it opens
Layout measures each container once per (node, width): width-less containers would otherwise
be measured exponentially often in their nesting. A measurement budget backs this up.
- **Scripts** (`script`): the live form as scripts see it (`form_tree`: the instance tree with
values, presence and access, and the `ScriptEvent`s in document order, built within
`MAX_FORM_NODES` objects and cut short, with `truncated` set, past that; `has_scripts` says
whether a template has any, and the engine skips all of this for forms without), the presence
and access overrides scripts make, rows added and removed in the data (`DataOp`s, applied to
the in-memory `DataNode` while an event runs and written by `write_data_ops` as one rewrite of
the datasets packet per event), and `rerender`, which lays the form out again from template,
data and overrides. The scripts themselves run in the `js` crate's XFA object model; the
engine runs initialize and calculate on open, change, exit, validate and calculate after a
field changes, and click for buttons.
Private keys PdfCraft writes (other viewers ignore them):
| Key | Where | Holds |
| --- | --- | --- |
| `/PCSom` | generated fields | the field's SOM path, so values go back to the data |
| `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again |
| `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written |
| `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` |
## API sketch
```rust
@ -63,9 +83,30 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
- **Data binding** is the default one only: explicit `bind ref` expressions, global binding and
data descriptions are not followed, and no standalone XML or XDP data file is imported or
exported.
- **Scripting.** No FormCalc or XFA JavaScript (`xfa.host`, `xfa.layout`, `instanceManager`);
rows are not added by button, validations and calculations don't run. Buttons map only the
common idioms (reset, print, save as, launchURL).
- **Scripting.** JavaScript only: FormCalc scripts (the default language of older forms) are
detected and reported, not run. The object model covers what forms commonly use (`xfa.form`
navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers,
`xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page numbers, `xfa.event`);
not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties
(reads give a sink that accepts writes), `execEvent`, or the change event per keystroke.
Validate failures show their message and keep the value, as Acrobat does for scripts.
A hidden instance of a repeating subform hides every instance (overrides apply to the template).
A repeating subform shows what its data holds once the data holds any instance, else its
`initial` count, and never fewer than one (`occur min="0"` still shows one row).
Each script runs in a fresh engine on its own thread; a form with hundreds of calculate
scripts pays that on every field change (a shared engine per event is the obvious next step).
- **Script limits.** Forms run their scripts on open without being asked, so the engine bounds
each event: a script's effects (values set again on the same object merge, the last wins;
10 000 effects, 100 message boxes, 1 000 console lines per script and per event), its loop
iterations (100 000 per call frame for initialize, calculate and validate, 1 000 000 for
click and change), 2 000 scripts and 50 relayouts per event, and 3 s (open, changes) or 5 s
(click) of scripts per event, after which the rest are skipped and reported. Only a click may
print, save, open a link or move the focus; other events asking are noted in the console.
What open-time scripts change is noted in the document's XFA warnings. Known gaps: the
engine's loop limit is per call frame, so loops inside nested calls can run far longer; such
a script is abandoned after 1 s (3 s for a click) on its thread, which keeps running until the
engine's own limits end it, and the document's scripts are turned off. Memory a script
allocates is not capped (the same holds for AcroForm JavaScript).
- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm;
only their data is read and written.
- Choice lists are text fields; signature, image, barcode and password fields are left blank;

View File

@ -328,6 +328,8 @@ struct Want {
som: String,
path: DataPath,
text: String,
/// Create the node even for an empty value (an instance of a repeating subform).
ensure: bool,
}
fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec<Want> {
@ -350,11 +352,20 @@ fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec<Want> {
FieldData::Radio(sel) => sel.clone().unwrap_or_default(),
FieldData::None => continue,
};
out.push(Want { som, path, text });
out.push(Want { som, path, text, ensure: false });
}
out
}
/// What a rewrite of the datasets packet does.
#[derive(Clone, Copy)]
enum Op<'a> {
/// Put these values in (adding nodes as needed).
Values(&'a [Want]),
/// Take the node at this path out.
Remove(&'a DataPath),
}
/// Note that a value could not be written (once per kind of reason, naming a few fields).
fn skipped(warnings: &mut Vec<String>, reason: &str, som: &str) {
const MAX_WARNINGS: usize = 20;
@ -480,7 +491,11 @@ const WRAP_CLOSE: &str = "</pdfcraft-wrap>";
/// change: values go into the data nodes they bind to, missing nodes are added, and everything
/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as
/// written.
fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec<String>) -> Merged {
fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merged {
let wants: &[Want] = match op {
Op::Values(w) => w,
Op::Remove(_) => &[],
};
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
// A packet of an array may use the prefixes the XDP envelope declares: read it inside
// an element declaring the usual ones.
@ -506,6 +521,7 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec<String>)
let mut edits = Vec::new();
let mut data = None;
match datasets {
None if matches!(op, Op::Remove(_)) => return Merged::Unchanged,
None if within == Within::Xdp => {
let root = parsed.root_element();
match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) {
@ -522,6 +538,7 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec<String>)
}
Some(ds) => match ds.children().find(|n| is(n, "data")) {
Some(d) => data = Some(d),
None if matches!(op, Op::Remove(_)) => return Merged::Unchanged,
None => {
let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default();
let element = format!("<{prefix}data>{}</{prefix}data>", build_children(wants, warnings));
@ -536,7 +553,21 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec<String>)
},
}
if let Some(data) = data {
merge_values(src, data, wants, &mut edits, warnings);
match op {
Op::Values(w) => merge_values(src, data, w, &mut edits, warnings),
Op::Remove(path) => {
// The element at `path`, with the whitespace before it.
let mut node = Some(data);
for (name, idx) in path {
node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx));
}
if let Some(n) = node {
let r = n.range();
let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len());
edits.push(Edit { start, end: r.end, text: String::new() });
}
}
}
}
if edits.is_empty() {
return Merged::Unchanged;
@ -605,7 +636,11 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
}
}
if depth == w.path.len() {
// The data node exists: give it the value, unless it already holds it.
// The data node exists: give it the value, unless it already holds it. A node
// only wanted to exist (a row instance) is left as it is.
if w.ensure && w.text.is_empty() {
continue;
}
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
if node.children().any(|c| c.is_element()) {
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
@ -623,8 +658,8 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
}
} else {
// Missing: add the rest of the path below the deepest node that exists. An empty
// value needs no node.
if w.text.is_empty() {
// value needs no node, unless the node itself is wanted (a new instance).
if w.text.is_empty() && !w.ensure {
continue;
}
let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec();
@ -669,6 +704,8 @@ pub struct DatasetsWrite {
pub written: bool,
/// Values that could not be written, and why.
pub warnings: Vec<String>,
/// The datasets stream written, when one was.
pub stream: Option<ObjRef>,
}
/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly.
@ -696,14 +733,189 @@ fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec<String>) -> Option
/// Nothing is written when the document has no XFA entry or the data already holds the values;
/// values that can't be written are reported, never silently dropped.
pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result<DatasetsWrite, XfaError> {
write_datasets_reusing(doc, fields, None)
}
/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream
/// (written earlier in the same edit) rather than adding another.
pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option<ObjRef>) -> Result<DatasetsWrite, XfaError> {
let wants = wanted(doc, fields);
rewrite(doc, &[Op::Values(&wants)], reuse)
}
/// One change to the data, for [`write_data_ops`].
#[derive(Clone, Debug, PartialEq)]
pub enum DataOp {
/// Put `text` at the node `path`, adding the node (and the ones above it) as needed.
Value { path: DataPath, text: String },
/// Make sure the node at `parent` has at least `count` children named `name` (a script
/// added rows: the layout repeats the subform once per data instance).
Instances { parent: DataPath, name: String, count: usize },
/// Take the node at `path` out (a script removed a row).
Remove(DataPath),
}
impl DataNode {
/// The node at `path`, made (with empty siblings before it) where missing; `None` past
/// the index cap.
fn ensure(&mut self, path: &[(String, usize)]) -> Option<&mut DataNode> {
let mut node = self;
for (name, idx) in path {
if *idx >= MAX_INDEX {
return None;
}
let have = node.children.iter().filter(|c| &c.name == name).count();
for _ in have..=*idx {
node.children.push(DataNode { name: name.clone(), ..Default::default() });
}
node = node.children.iter_mut().filter(|c| &c.name == name).nth(*idx)?;
}
Some(node)
}
/// Apply `op` to this tree (the root is the `xfa:data` element), as [`write_data_ops`]
/// will to the packet, so scripts that run before it is written see the change.
pub fn apply(&mut self, op: &DataOp) {
match op {
DataOp::Value { path, text } => {
if let Some(n) = self.ensure(path)
&& n.children.is_empty()
{
n.text = text.clone();
}
}
DataOp::Instances { parent, name, count } => {
if let Some(&last) = count.checked_sub(1).as_ref() {
let mut path = parent.clone();
path.push((name.clone(), last.min(MAX_INDEX.saturating_sub(1))));
let _ = self.ensure(&path);
}
}
DataOp::Remove(path) => {
let Some(((name, idx), parent)) = path.split_last() else { return };
let mut node = self;
for (n, i) in parent {
let Some(next) = node.children.iter_mut().filter(|c| &c.name == n).nth(*i) else { return };
node = next;
}
if let Some(at) = node.children.iter().enumerate().filter(|(_, c)| &c.name == name).nth(*idx).map(|(at, _)| at) {
node.children.remove(at);
}
}
}
}
}
fn path_som(path: &DataPath) -> String {
path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::<Vec<_>>().join(".")
}
/// Make sure the data has at least `count` instances of `name` under `parent` (a script added
/// rows: the layout repeats the subform once per data instance).
pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result<DatasetsWrite, XfaError> {
write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None)
}
/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a
/// script gave a value), adding the node as needed.
pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result<DatasetsWrite, XfaError> {
write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None)
}
/// Remove the data node at `path` (a script removed a row).
pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result<DatasetsWrite, XfaError> {
write_data_ops(doc, &[DataOp::Remove(path.clone())], None)
}
/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script
/// event changed becomes a single new datasets stream. When the packet is already the stream
/// `reuse` (written earlier for the same event), that stream is replaced in place instead of
/// a new one being added. The stream written is in the report.
pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>) -> Result<DatasetsWrite, XfaError> {
// Runs of values (and instances) merge in one pass; a value set twice keeps the last.
let mut steps: Vec<Vec<Want>> = Vec::new();
let mut removes: Vec<Option<DataPath>> = Vec::new();
let mut run: Vec<Want> = Vec::new();
let flush = |run: &mut Vec<Want>, steps: &mut Vec<Vec<Want>>, removes: &mut Vec<Option<DataPath>>| {
if !run.is_empty() {
let mut seen = std::collections::HashSet::new();
let mut kept: Vec<Want> = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect();
kept.reverse();
steps.push(kept);
removes.push(None);
}
};
for op in ops {
match op {
DataOp::Value { path, text } if !path.is_empty() => {
// An empty value clears an existing node and needs no new one.
run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false });
}
DataOp::Instances { parent, name, count } => {
for k in 0..(*count).min(MAX_INDEX) {
let mut path = parent.clone();
path.push((name.clone(), k));
run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true });
}
}
DataOp::Remove(path) if !path.is_empty() => {
flush(&mut run, &mut steps, &mut removes);
steps.push(Vec::new());
removes.push(Some(path.clone()));
}
_ => {}
}
}
flush(&mut run, &mut steps, &mut removes);
let plan: Vec<Op> = steps
.iter()
.zip(&removes)
.map(|(w, r)| match r {
Some(p) => Op::Remove(p),
None => Op::Values(w),
})
.collect();
rewrite(doc, &plan, reuse)
}
/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream
/// `reuse`, else as a new object.
fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option<ObjRef>, bytes: &[u8]) -> ObjRef {
let stream = Object::Stream(Stream::flate(Dict::new(), bytes));
match (old.and_then(Object::as_ref), reuse) {
(Some(r), Some(keep)) if r == keep => {
doc.set(r, stream);
r
}
_ => doc.add(stream),
}
}
/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged).
fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec<String>) -> Option<String> {
let mut current: Option<String> = None;
for op in ops {
match merge(current.as_deref().unwrap_or(text), within, *op, warnings) {
Merged::Changed(t) => current = Some(t),
Merged::Unchanged => {}
// Later steps still apply to what merged so far; the reason is in the warnings.
Merged::Failed => {}
}
}
current
}
fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<DatasetsWrite, XfaError> {
let mut report = DatasetsWrite::default();
if ops.is_empty() {
return Ok(report);
}
let Some(root) = doc.root() else { return Ok(report) };
let catalog = doc.get(root);
let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) };
let acro_ref = acro_obj.as_ref();
let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) };
let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) };
let wants = wanted(doc, fields);
let warnings = &mut report.warnings;
match &*doc.resolve(&xfa) {
Object::Array(items) => {
@ -711,29 +923,26 @@ pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result<Datas
let at = (0..items.len())
.step_by(2)
.find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len());
let packet = match at {
match at {
Some(i) => {
let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) };
let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) };
match merge(&text, Within::Packet, &wants, warnings) {
Merged::Changed(t) => Some((i, encode(&t, encoding))),
Merged::Unchanged | Merged::Failed => None,
}
}
None => None,
};
match (at, packet) {
(Some(i), Some((_, bytes))) => {
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes)));
let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) };
let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding));
report.stream = Some(r);
if let Some(slot) = items.get_mut(i + 1) {
*slot = Object::Ref(r);
}
}
(Some(_), None) => return Ok(report),
(None, _) => {
// No datasets packet yet: before the postamble, or last.
let text = fresh_datasets(&build_children(&wants, warnings));
None => {
// No datasets packet yet: made from the first values, then the rest merged
// in; before the postamble, or last.
let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) };
let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) };
let fresh = fresh_datasets(&build_children(wants, warnings));
let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh);
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes())));
report.stream = Some(r);
let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len());
items.insert(pos, Object::Ref(r));
items.insert(pos, Object::String(PdfString::text("datasets")));
@ -743,8 +952,9 @@ pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result<Datas
}
Object::Stream(_) => {
let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) };
let Merged::Changed(new) = merge(&text, Within::Xdp, &wants, warnings) else { return Ok(report) };
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &encode(&new, encoding))));
let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) };
let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding));
report.stream = Some(r);
acro.set(b"XFA".to_vec(), Object::Ref(r));
}
_ => return Ok(report),

View File

@ -114,6 +114,52 @@ fn assemble(objs: Vec<Vec<u8>>) -> Vec<u8> {
out
}
/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price`
/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform;
/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons;
/// `grand`, the calculated sum of the amounts.
pub fn scripted_template() -> String {
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page1" layout="tb">
<event activity="initialize"><script contentType="application/x-javascript">if (qty.rawValue === null) qty.rawValue = 2;</script></event>
<field name="qty" w="2in" h="0.3in"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Qty</text></value></caption>
<validate><script contentType="application/x-javascript">this.rawValue === null || this.rawValue &lt;= 100</script><message><text name="scriptTest">Quantity must be 100 or less</text></message></validate></field>
<field name="price" w="2in" h="0.3in"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Price</text></value></caption><value><integer>5</integer></value></field>
<field name="total" w="2in" h="0.3in" access="readOnly"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Total</text></value></caption>
<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate></field>
<field name="more" w="2in" h="0.3in"><ui><checkButton/></ui><caption placement="right" reserve="1.5in"><value><text>More details</text></value></caption><items><integer>1</integer><integer>0</integer></items>
<event activity="change"><script contentType="application/x-javascript">details.presence = (this.rawValue == 1) ? "visible" : "hidden";</script></event></field>
<subform name="details" layout="tb" presence="hidden">
<field name="note" w="4in" h="0.3in"><ui><textEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Note</text></value></caption></field>
</subform>
<subform name="table" layout="table" columnWidths="3in 2in">
<subform name="row" layout="row"><occur min="1" max="-1"/>
<field name="item" h="0.3in"><ui><textEdit/></ui></field>
<field name="amount" h="0.3in"><ui><numericEdit/></ui></field>
</subform>
</subform>
<field name="grand" w="2in" h="0.3in" access="readOnly"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Sum</text></value></caption>
<calculate><script contentType="application/x-javascript">var t = 0; var rows = xfa.resolveNodes("table.row[*]"); for (var i = 0; i &lt; rows.length; i++) { t += (rows.item(i).amount.rawValue || 0); } t</script></calculate></field>
<field name="addRow" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Add row</text></value></caption>
<event activity="click"><script contentType="application/x-javascript">table._row.addInstance(1);</script></event></field>
<field name="removeRow" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Remove row</text></value></caption>
<event activity="click"><script contentType="application/x-javascript">if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }</script></event></field>
<field name="hello" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Hello</text></value></caption>
<event activity="click"><script contentType="application/x-javascript">xfa.host.messageBox("Hello " + qty.rawValue);</script></event></field>
<field name="legacy" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Legacy</text></value></caption>
<event activity="click"><script>$host.messageBox("FormCalc")</script></event></field>
</subform>
</subform>
</template>
</xdp:xdp>
"##
.to_string()
}
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
pub fn shell(xdp: &str) -> Vec<u8> {
let objs: Vec<Vec<u8>> = vec![

View File

@ -78,6 +78,8 @@ pub enum Action {
Print,
SaveAs,
Url(String),
/// Any other click script: run by the XFA scripting engine.
Script(String),
}
/// An interactive field to create as an AcroForm widget.
@ -97,6 +99,8 @@ pub struct Widget {
pub read_only: bool,
pub border: WidgetBorder,
pub value: Option<String>,
/// The template's default value (what a reset restores); data values are not defaults.
pub default: Option<String>,
pub action: Option<Action>,
/// Check boxes: the on and off values of the data; radio buttons: the on value.
pub items: Vec<String>,
@ -182,6 +186,14 @@ struct Layouter<'t> {
measures: Cell<usize>,
}
/// How many instances of a repeating subform to lay out: what the data holds once it holds
/// any (rows added or removed), else the template's initial count; never below one, never
/// above the maximum.
pub fn instance_count(occur: &Occur, from_data: usize) -> usize {
let n = if from_data > 0 { from_data } else { occur.initial };
n.min(occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES)
}
/// Index of child `i` among the earlier siblings with the same name (the SOM index).
fn sib_index(children: &[Node], i: usize) -> usize {
let name = children.get(i).and_then(|c| c.common().name.as_deref());
@ -302,6 +314,13 @@ fn date_pattern(picture: Option<&str>) -> String {
fn button_action(scripts: &[Script]) -> Option<Action> {
let s = scripts.iter().find(|s| s.activity == "click")?;
let t = &s.text;
// Only a single-statement idiom becomes a native PDF action; anything else is run by the
// XFA scripting engine.
let one = t.trim().trim_end_matches(';');
let single = !one.contains(';') && !one.contains('\n');
if !single {
return (!t.trim().is_empty()).then(|| Action::Script(t.clone()));
}
if t.contains("resetData") {
Some(Action::Reset)
} else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") {
@ -315,8 +334,10 @@ fn button_action(scripts: &[Script]) -> Option<Action> {
let body = &rest[q + 1..];
let end = body.find(quote)?;
Some(Action::Url(body[..end].to_string()))
} else {
} else if t.trim().is_empty() {
None
} else {
Some(Action::Script(t.clone()))
}
}
@ -475,7 +496,7 @@ impl Layouter<'_> {
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name),
_ => 0,
};
let instances = sf.occur.initial.max(from_data).min(sf.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES);
let instances = instance_count(&sf.occur, from_data);
let ctx_parent = ctx;
for i in 0..instances {
let ctx = ctx_parent.with_som(&sf.common, sib + i);
@ -550,7 +571,7 @@ impl Layouter<'_> {
(Some(name), Some(d)) if row.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name),
_ => 0,
};
let instances = row.occur.initial.max(from_data).min(row.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES);
let instances = instance_count(&row.occur, from_data);
for inst in 0..instances {
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1);
if self.make_room(h)?
@ -1155,7 +1176,8 @@ impl Layouter<'_> {
// The data's value wins over the template's default.
let data_value: Option<String> =
self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
let plain_value = data_value.clone().or_else(|| f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()));
let template_default = f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let plain_value = data_value.clone().or_else(|| template_default.clone());
let (kind, rect, border, value, tooltip) = match f.ui {
Ui::CheckButton => {
let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0));
@ -1227,6 +1249,7 @@ impl Layouter<'_> {
read_only,
border,
value,
default: template_default,
action,
items,
})))

View File

@ -14,18 +14,23 @@ pub mod model;
pub mod packets;
pub mod parse;
pub mod pdf;
pub mod script;
pub mod text;
use pdfcraft_cos::Document;
pub use data::{
DataNode, DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path,
write_datasets,
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, iso_to_pattern, parse_datasets, pattern_to_iso,
read_values, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing,
};
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
pub use pdf::{LAYOUT_KEY, SOM_KEY, existing_layout};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, existing_layout};
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
has_scripts, overrides, rerender, set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
pub enum XfaError {
@ -77,7 +82,11 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
let form = layout_xml(&packets.xdp)?;
let (tpl, parse_warnings) = parse(&packets.xdp)?;
let data = parse_datasets(&packets.xdp);
let laid = apply_overrides(&tpl, &overrides(doc));
let mut form = layout(&laid, data.as_ref())?;
form.warnings.splice(0..0, parse_warnings);
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}

View File

@ -332,6 +332,8 @@ pub struct Subform {
pub common: Common,
pub layout: Layout,
pub children: Vec<Node>,
/// `<event activity="…"><script>` on the subform (initialize, calculate, …).
pub scripts: Vec<Script>,
pub page_set: Option<PageSet>,
pub occur: Occur,
/// `<breakBefore targetType="pageArea">`: start on a new page.
@ -357,6 +359,7 @@ pub struct ExclGroup {
pub layout: Layout,
pub fields: Vec<Field>,
pub tooltip: Option<String>,
pub scripts: Vec<Script>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -379,6 +382,8 @@ pub enum Ui {
pub struct Script {
pub activity: String,
pub text: String,
/// FormCalc (the default language), not JavaScript.
pub formcalc: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@ -410,7 +415,16 @@ pub struct Field {
pub access: Access,
/// The edit picture clause (`<ui>…<picture>`), e.g. `date{YYYY-MM-DD}`.
pub picture: Option<String>,
/// `<event activity="…"><script>`: click, change, exit, initialize, …
pub scripts: Vec<Script>,
/// `<calculate><script>`: the value is the script's result.
pub calculate: Option<Script>,
/// `<validate><script>`: false (or an exception) shows `validate_message`; the value stays,
/// as Acrobat marks the field invalid rather than reverting it.
pub validate: Option<Script>,
pub validate_message: Option<String>,
/// Numeric fields (numericEdit, or a decimal/integer/float value) give scripts numbers.
pub numeric: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]

View File

@ -381,12 +381,16 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
let value_el = child(n, "value");
let max_chars =
value_el.and_then(|v| child(v, "text")).and_then(|t| t.attribute("maxChars")).and_then(|m| m.trim().parse::<usize>().ok()).filter(|m| *m > 0);
let mut scripts = Vec::new();
for ev in children(n, "event") {
if let Some(s) = child(ev, "script") {
scripts.push(Script { activity: ev.attribute("activity").unwrap_or("").to_string(), text: text_of(s) });
}
}
let scripts = parse_events(n);
let numeric = matches!(kind, Ui::NumericEdit)
|| value_el.is_some_and(|v| v.children().any(|c| c.is_element() && matches!(c.tag_name().name(), "decimal" | "integer" | "float")));
let validate_el = child(n, "validate");
let validate_message = validate_el
.and_then(|v| child(v, "message"))
.and_then(|m| children(m, "text").find(|t| t.attribute("name") == Some("scriptTest")).or_else(|| child(m, "text")))
.map(text_of)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
Field {
common: parse_common(n),
ui: kind,
@ -408,9 +412,30 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
},
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
scripts,
calculate: child(n, "calculate").and_then(|c| child(c, "script")).map(|s| parse_script(s, "calculate")),
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
validate_message,
numeric,
}
}
fn parse_script(s: XmlNode, activity: &str) -> Script {
let ct = s.attribute("contentType").unwrap_or("");
Script { activity: activity.to_string(), text: text_of(s).trim().to_string(), formcalc: !ct.to_ascii_lowercase().contains("javascript") }
}
/// `<event activity="…"><script>…</script></event>` children.
fn parse_events(n: XmlNode) -> Vec<Script> {
let mut scripts = Vec::new();
for ev in children(n, "event").take(64) {
if let Some(s) = child(ev, "script") {
let activity = ev.attribute("activity").unwrap_or("click");
scripts.push(parse_script(s, activity));
}
}
scripts
}
fn parse_occur(n: Option<XmlNode>) -> Occur {
let Some(n) = n else { return Occur::default() };
let int = |a: &str| n.attribute(a).and_then(|v| v.trim().parse::<i64>().ok());
@ -478,6 +503,7 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subfor
common,
layout: Layout::parse(n.attribute("layout")),
children: children_out,
scripts: parse_events(n),
page_set,
occur: parse_occur(child(n, "occur")),
break_before_page: break_to_page("breakBefore")
@ -511,6 +537,7 @@ fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<No
layout: Layout::parse(c.attribute("layout")),
fields: children(c, "field").map(|f| parse_field(f, warnings)).collect(),
tooltip: child(c, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
scripts: parse_events(c),
})),
_ => return None,
})

View File

@ -17,6 +17,10 @@ pub const SOM_KEY: &[u8] = b"PCSom";
/// Private key on the AcroForm: what laying the form out produced, so a reopened file is not
/// laid out again.
pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout";
/// Private key on generated push buttons whose template has a click script: the button has no
/// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the
/// field's SOM path.
pub const CLICK_KEY: &[u8] = b"PCXfaClick";
/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
/// markers). `None` for anything else.
@ -187,6 +191,8 @@ impl Emitter<'_> {
}
if let Some(v) = &w.value {
d.set(b"V".to_vec(), Object::String(PdfString::text(v)));
}
if let Some(v) = &w.default {
d.set(b"DV".to_vec(), Object::String(PdfString::text(v)));
}
if let WidgetKind::Date(pattern) = &w.kind {
@ -202,7 +208,7 @@ impl Emitter<'_> {
let checked = w.value.is_some();
d.set(b"V".to_vec(), Object::name(if checked { &on } else { "Off" }));
d.set(b"AS".to_vec(), Object::name(if checked { &on } else { "Off" }));
if checked {
if w.default.as_deref().is_some_and(|dv| crate::data::is_on(dv, &on)) {
d.set(b"DV".to_vec(), Object::name(&on));
}
let mut nd = Dict::new();
@ -254,7 +260,11 @@ impl Emitter<'_> {
d.set(b"FT".to_vec(), Object::name("Btn"));
ff |= 1 << 16;
mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption)));
if let Some(a) = &w.action {
if let Some(Action::Script(_)) = &w.action {
// An XFA click script is no Acrobat JavaScript: no PDF action, which other
// viewers would run; PdfCraft finds the script by the field's SOM path.
d.set(CLICK_KEY.to_vec(), Object::Bool(true));
} else if let Some(a) = &w.action {
let mut ad = Dict::new();
match a {
Action::Reset => {
@ -272,6 +282,7 @@ impl Emitter<'_> {
ad.set(b"S".to_vec(), Object::name("URI"));
ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec())));
}
Action::Script(_) => {}
}
d.set(b"A".to_vec(), Object::Dict(ad));
}

585
crates/xfa/src/script.rs Normal file
View File

@ -0,0 +1,585 @@
//! The form as scripts see it (XFA 3.3 part 6): the instance tree with values and presence, the
//! events a template declares, the presence overrides scripts make (kept in the PDF so they
//! survive undo and reopening), and laying the form out again after scripts changed it.
use std::collections::{BTreeMap, HashMap};
use pdfcraft_cos::{Dict, Document, Object, PdfString};
use crate::data::{DataNode, parse_datasets, som_to_path};
use crate::model::*;
use crate::{Report, XfaError};
/// Private key on the AcroForm: presence scripts set, by SOM path.
pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides";
const MAX_DEPTH: usize = 64;
const MAX_EVENTS: usize = 20_000;
/// Most objects of the live form built (the scripting engine's snapshot holds as many):
/// nested repeating subforms multiply, so the walk stops here and says so.
pub const MAX_FORM_NODES: usize = 200_000;
const MAX_OVERRIDES: usize = 10_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeKind {
Subform,
Field,
ExclGroup,
Draw,
Area,
}
/// One object of the live form.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FormNode {
pub name: String,
pub som: String,
pub kind: Option<NodeKind>,
pub value: String,
pub numeric: bool,
pub presence: String,
pub access: String,
pub repeatable: bool,
pub occur_min: usize,
pub occur_max: Option<usize>,
pub index: usize,
pub children: Vec<FormNode>,
}
/// A script the template runs for an event of one node instance.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptEvent {
pub som: String,
/// `initialize`, `calculate`, `validate`, `click`, `change`, `exit`, …
pub activity: String,
pub script: String,
pub formcalc: bool,
/// Validate scripts: the message shown when they fail.
pub message: Option<String>,
}
/// The live form as [`form_tree`] builds it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LiveForm {
/// The root subform's instance tree.
pub root: FormNode,
/// Every scripted event, in document order.
pub events: Vec<ScriptEvent>,
/// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events): what
/// is past that was left out.
pub truncated: bool,
}
/// What scripts changed about the template's objects, by SOM path.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Overrides {
pub presence: BTreeMap<String, String>,
pub access: BTreeMap<String, String>,
}
impl Overrides {
pub fn is_empty(&self) -> bool {
self.presence.is_empty() && self.access.is_empty()
}
}
fn presence_name(p: Presence) -> &'static str {
match p {
Presence::Visible => "visible",
Presence::Invisible => "invisible",
Presence::Hidden => "hidden",
Presence::Inactive => "inactive",
}
}
fn access_name(a: Access) -> &'static str {
match a {
Access::Open => "open",
Access::ReadOnly => "readOnly",
Access::Protected => "protected",
Access::NonInteractive => "nonInteractive",
}
}
fn acroform(doc: &Document) -> Option<(Option<pdfcraft_cos::ObjRef>, Dict)> {
let root = doc.root()?;
let catalog = doc.get(root);
let o = catalog.as_dict()?.get(b"AcroForm")?.clone();
let d = doc.resolve(&o).as_dict().cloned()?;
Some((o.as_ref(), d))
}
/// The overrides the document carries.
pub fn overrides(doc: &Document) -> Overrides {
let mut out = Overrides::default();
let Some((_, acro)) = acroform(doc) else { return out };
let Some(o) = acro.get(OVERRIDES_KEY).map(|o| doc.resolve(o)) else { return out };
let Some(d) = o.as_dict() else { return out };
for (which, map) in [(&b"Presence"[..], &mut out.presence), (&b"Access"[..], &mut out.access)] {
if let Some(m) = d.get(which).map(|m| doc.resolve(m))
&& let Some(m) = m.as_dict()
{
for (k, v) in m.iter().take(MAX_OVERRIDES) {
if let Some(v) = v.as_string() {
map.insert(String::from_utf8_lossy(k).into_owned(), v.to_text());
}
}
}
}
out
}
/// Store `ov` in the document (none: the key is removed).
pub fn set_overrides(doc: &mut Document, ov: &Overrides) -> Result<(), XfaError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some((acro_ref, mut acro)) = acroform(doc) else { return Ok(()) };
if ov.is_empty() {
acro.remove(OVERRIDES_KEY);
} else {
let mut d = Dict::new();
for (which, map) in [("Presence", &ov.presence), ("Access", &ov.access)] {
if map.is_empty() {
continue;
}
let mut m = Dict::new();
for (k, v) in map.iter().take(MAX_OVERRIDES) {
m.set(k.as_bytes().to_vec(), Object::String(PdfString::text(v)));
}
d.set(which.as_bytes().to_vec(), Object::Dict(m));
}
acro.set(OVERRIDES_KEY.to_vec(), Object::Dict(d));
}
match acro_ref {
Some(r) => doc.set(r, Object::Dict(acro)),
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
}
Ok(())
}
/// Index of child `i` among the earlier siblings with the same name.
fn sib_index(children: &[Node], i: usize) -> usize {
let name = children.get(i).and_then(|c| c.common().name.as_deref());
children.iter().take(i).filter(|c| c.common().name.as_deref() == name && name.is_some()).count()
}
fn child_som(parent: &str, name: Option<&str>, index: usize) -> String {
match name {
Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"),
Some(n) => format!("{n}[{index}]"),
None => parent.to_string(),
}
}
struct Walker<'a> {
data: Option<&'a DataNode>,
ov: &'a Overrides,
events: Vec<ScriptEvent>,
/// Objects built so far.
nodes: usize,
truncated: bool,
}
impl Walker<'_> {
/// Count one more object; false once the budget is spent.
fn take(&mut self) -> bool {
if self.nodes >= MAX_FORM_NODES {
self.truncated = true;
return false;
}
self.nodes += 1;
true
}
fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) {
if self.events.len() >= MAX_EVENTS {
self.truncated = true;
return;
}
for s in scripts {
if s.text.trim().is_empty() {
continue;
}
self.events.push(ScriptEvent {
som: som.to_string(),
activity: s.activity.clone(),
script: s.text.clone(),
formcalc: s.formcalc,
message: None,
});
}
if let Some(c) = calculate
&& !c.text.trim().is_empty()
{
self.events.push(ScriptEvent {
som: som.to_string(),
activity: "calculate".into(),
script: c.text.clone(),
formcalc: c.formcalc,
message: None,
});
}
if let Some(v) = validate
&& !v.text.trim().is_empty()
{
self.events.push(ScriptEvent {
som: som.to_string(),
activity: "validate".into(),
script: v.text.clone(),
formcalc: v.formcalc,
message: message.map(str::to_string),
});
}
}
fn presence(&self, som: &str, p: Presence) -> String {
self.ov.presence.get(som).cloned().unwrap_or_else(|| presence_name(p).to_string())
}
fn access(&self, som: &str, a: Access) -> String {
self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string())
}
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
let data_value = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
let value = match (radio, data_value) {
// A radio button's value is its on value when the group picked it.
(Some(selected), _) => {
let on = f.items.first().cloned().unwrap_or_default();
if selected == on { on } else { String::new() }
}
(None, Some(v)) => v,
(None, None) => f.value.plain().map(|v| v.trim().to_string()).unwrap_or_default(),
};
self.push_events(&som, &f.scripts, f.calculate.as_ref(), f.validate.as_ref(), f.validate_message.as_deref());
FormNode {
name: f.common.name.clone().unwrap_or_else(|| "field".into()),
som: som.clone(),
kind: Some(NodeKind::Field),
value,
numeric: f.numeric,
presence: self.presence(&som, f.common.presence),
access: self.access(&som, f.access),
repeatable: false,
occur_min: 1,
occur_max: Some(1),
index,
children: Vec::new(),
}
}
fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec<FormNode> {
let mut out = Vec::new();
if depth > MAX_DEPTH {
return out;
}
for (i, n) in nodes.iter().enumerate() {
if !self.take() {
break;
}
let sib = sib_index(nodes, i);
match n {
Node::Subform(sf) => {
let from_data = match (&sf.common.name, self.data) {
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(parent_som), name),
_ => 0,
};
let instances = crate::layout::instance_count(&sf.occur, from_data);
for k in 0..instances {
// The first instance was counted above.
if k > 0 && !self.take() {
break;
}
let index = sib + k;
let som = child_som(parent_som, sf.common.name.as_deref(), index);
self.push_events(&som, &sf.scripts, None, None, None);
let children = self.children(&sf.children, &som, depth + 1);
out.push(FormNode {
name: sf.common.name.clone().unwrap_or_else(|| "#subform".into()),
som: som.clone(),
kind: Some(NodeKind::Subform),
value: String::new(),
numeric: false,
presence: self.presence(&som, sf.common.presence),
access: "open".into(),
repeatable: sf.occur.max != Some(1),
occur_min: sf.occur.min,
occur_max: sf.occur.max,
index,
children,
});
}
}
Node::Area(a) => {
let som = child_som(parent_som, a.common.name.as_deref(), sib);
let children = self.children(&a.children, &som, depth + 1);
out.push(FormNode {
name: a.common.name.clone().unwrap_or_else(|| "#area".into()),
som: som.clone(),
kind: Some(NodeKind::Area),
presence: self.presence(&som, a.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
index: sib,
children,
..Default::default()
});
}
Node::Field(f) => out.push(self.field(f, parent_som, sib, None)),
Node::Draw(d) => {
let som = child_som(parent_som, d.common.name.as_deref(), sib);
out.push(FormNode {
name: d.common.name.clone().unwrap_or_else(|| "draw".into()),
som: som.clone(),
kind: Some(NodeKind::Draw),
value: d.value.plain().unwrap_or_default(),
presence: self.presence(&som, d.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
index: sib,
..Default::default()
});
}
Node::ExclGroup(g) => {
let som = child_som(parent_som, g.common.name.as_deref().or(Some("group")), sib);
let selected = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
self.push_events(&som, &g.scripts, None, None, None);
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
let mut children = Vec::new();
for (fi, f) in g.fields.iter().enumerate() {
if !self.take() {
break;
}
children.push(self.field(f, &som, sib_index(&fields, fi), Some(selected.as_deref().unwrap_or(""))));
}
out.push(FormNode {
name: g.common.name.clone().unwrap_or_else(|| "group".into()),
som: som.clone(),
kind: Some(NodeKind::ExclGroup),
value: selected.unwrap_or_default(),
presence: self.presence(&som, g.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
index: sib,
children,
..Default::default()
});
}
}
}
out
}
}
/// The live form: the root subform's instance tree (values from `data`, presence and access
/// from the template and `ov`) and every scripted event, in document order, within
/// [`MAX_FORM_NODES`] objects.
pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm {
let mut w = Walker { data, ov, events: Vec::new(), nodes: 0, truncated: false };
let root = Node::Subform(Box::new(tpl.root.clone()));
let mut nodes = w.children(std::slice::from_ref(&root), "", 0);
let mut root_node = nodes.pop().unwrap_or_default();
// Page masters' fields (Print and Save buttons, page numbers) belong to the layout model
// in XFA; they hang off the root here, under their page area's name, as the layouter
// names them, so their click scripts can run.
if let Some(ps) = &tpl.root.page_set {
for (i, area) in ps.areas.iter().enumerate() {
let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
let children = w.children(&area.items, &som, 1);
if children.is_empty() {
continue;
}
root_node.children.push(FormNode {
name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
som,
kind: Some(NodeKind::Area),
presence: "visible".into(),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
index: 0,
children,
..Default::default()
});
}
}
LiveForm { root: root_node, events: w.events, truncated: w.truncated }
}
/// Does the template declare any script (an event, calculation or validation)? Forms without
/// one need no live form and run nothing.
pub fn has_scripts(tpl: &Template) -> bool {
fn any(s: &[Script]) -> bool {
s.iter().any(|s| !s.text.trim().is_empty())
}
fn field(f: &Field) -> bool {
any(&f.scripts) || [&f.calculate, &f.validate].into_iter().flatten().any(|s| !s.text.trim().is_empty())
}
fn walk(nodes: &[Node], depth: usize) -> bool {
depth <= MAX_DEPTH
&& nodes.iter().any(|n| match n {
Node::Subform(sf) => any(&sf.scripts) || walk(&sf.children, depth + 1),
Node::Area(a) => walk(&a.children, depth + 1),
Node::Field(f) => field(f),
Node::ExclGroup(g) => any(&g.scripts) || g.fields.iter().any(field),
Node::Draw(_) => false,
})
}
any(&tpl.root.scripts) || walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
}
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
/// instance of a repeating subform hide them all).
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
fn walk(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let sibs: Vec<usize> = (0..nodes.len()).map(|i| sib_index(nodes, i)).collect();
for (i, n) in nodes.iter_mut().enumerate() {
let sib = sibs.get(i).copied().unwrap_or(0);
let name_default = match n {
Node::Field(_) => Some("field"),
Node::ExclGroup(_) => Some("group"),
_ => None,
};
let som = child_som(parent_som, n.common().name.as_deref().or(name_default), sib);
let presence = ov.presence.get(&som).map(|p| Presence::parse(Some(p)));
let access = ov.access.get(&som).cloned();
match n {
Node::Subform(s) => {
if let Some(p) = presence {
s.common.presence = p;
}
walk(&mut s.children, &som, ov, depth + 1);
}
Node::Area(a) => {
if let Some(p) = presence {
a.common.presence = p;
}
walk(&mut a.children, &som, ov, depth + 1);
}
Node::Field(f) => {
if let Some(p) = presence {
f.common.presence = p;
}
if let Some(a) = access {
f.access = match a.as_str() {
"readOnly" => Access::ReadOnly,
"protected" => Access::Protected,
"nonInteractive" => Access::NonInteractive,
_ => Access::Open,
};
}
}
Node::Draw(d) => {
if let Some(p) = presence {
d.common.presence = p;
}
}
Node::ExclGroup(g) => {
if let Some(p) = presence {
g.common.presence = p;
}
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
for (fi, f) in g.fields.iter_mut().enumerate() {
let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), sib_index(&fields, fi));
if let Some(p) = ov.presence.get(&fsom) {
f.common.presence = Presence::parse(Some(p));
}
}
}
}
}
}
let mut t = tpl.clone();
if !ov.is_empty() {
let mut root = vec![Node::Subform(Box::new(t.root.clone()))];
walk(&mut root, "", ov, 0);
if let Some(Node::Subform(r)) = root.pop() {
t.root = *r;
}
}
t
}
/// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`.
pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, out: &mut HashMap<String, String>, depth: usize) {
if depth > MAX_DEPTH || out.len() > 100_000 {
return;
}
let o = doc.get(r);
let Some(d) = o.as_dict() else { return };
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()));
let name = match (&t, prefix.is_empty()) {
(Some(t), true) => t.clone(),
(Some(t), false) => format!("{prefix}.{t}"),
(None, _) => prefix.to_string(),
};
if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()))
&& t.is_some()
{
out.insert(som, name.clone());
}
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
for k in kids.iter().filter_map(Object::as_ref) {
walk(doc, k, &name, out, depth + 1);
}
}
}
let mut out = HashMap::new();
let Some((_, acro)) = acroform(doc) else { return out };
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
for f in fields.iter().filter_map(Object::as_ref) {
walk(doc, f, "", &mut out, 0);
}
out
}
/// Lay the form out again from its template, data and overrides, replacing the pages and the
/// generated fields (a script added a row or hid a subform).
pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError> {
let packets = crate::read_packets(doc)?.ok_or(XfaError::NotXfa)?;
let data = parse_datasets(&packets.xdp);
let ov = overrides(doc);
let laid = apply_overrides(tpl, &ov);
let form = crate::layout(&laid, data.as_ref())?;
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}
// The AcroForm keeps only the fields PdfCraft did not generate.
let Some(root) = doc.root() else { return Err(XfaError::NotXfa) };
if let Some((acro_ref, mut acro)) = acroform(doc) {
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
let kept: Vec<Object> =
fields.into_iter().filter(|f| f.as_ref().is_none_or(|r| doc.get(r).as_dict().is_none_or(|d| !d.contains(crate::pdf::SOM_KEY)))).collect();
acro.set(b"Fields".to_vec(), Object::Array(kept));
match acro_ref {
Some(r) => doc.set(r, Object::Dict(acro)),
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
}
}
let written = crate::pdf::write_form(doc, &form)?;
let mut warnings = form.warnings;
warnings.extend(written.warnings);
warnings.dedup();
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
}
/// The parsed template of a document's XFA packets.
pub fn template_of(doc: &Document) -> Result<Template, XfaError> {
let packets = crate::read_packets(doc)?.ok_or(XfaError::NotXfa)?;
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
crate::parse(&packets.xdp).map(|(t, _)| t)
}
/// The data tree of a document's datasets packet.
pub fn data_of(doc: &Document) -> Option<DataNode> {
crate::read_packets(doc).ok().flatten().and_then(|p| parse_datasets(&p.xdp))
}

View File

@ -6,9 +6,11 @@ use super::*;
use crate::data::{
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
};
use crate::fixtures::{shell, static_shell, template, template_with_data};
use crate::data::{add_data_instances, remove_data_instance, write_data_value};
use crate::fixtures::{scripted_template, shell, static_shell, template, template_with_data};
use crate::layout::Item;
use crate::model::*;
use crate::script::{NodeKind, Overrides, apply_overrides, fields_by_som, form_tree, overrides, rerender, set_overrides};
fn widgets(page: &Page) -> Vec<&Widget> {
page.items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w.as_ref()) } else { None }).collect()
@ -544,3 +546,219 @@ fn huge_som_indices_are_capped_not_built() {
assert_eq!(d.text_at(&som_to_path("form1[0].row[3].v[0]")), Some("3"));
assert!(d.get(&som_to_path("form1[0].row[2]")).is_some(), "the instances in between exist, empty");
}
#[test]
fn the_form_tree_carries_values_presence_instances_and_the_events() {
let xml = scripted_template();
let (tpl, _) = parse(&xml).unwrap();
let crate::script::LiveForm { root, events, truncated } = form_tree(&tpl, None, &Overrides::default());
assert!(!truncated);
assert_eq!((root.name.as_str(), root.som.as_str()), ("form", "form[0]"));
let page = &root.children[0];
assert_eq!(page.som, "form[0].page1[0]");
let by = |n: &str| page.children.iter().find(|c| c.name == n).unwrap_or_else(|| panic!("no {n}"));
assert_eq!((by("price").value.as_str(), by("price").numeric, by("total").access.as_str()), ("5", true, "readOnly"));
assert_eq!(by("details").presence, "hidden");
let table = by("table");
assert_eq!(table.children.len(), 1, "one row without data");
assert!(table.children[0].repeatable && table.children[0].occur_max.is_none());
assert_eq!(table.children[0].children[1].som, "form[0].page1[0].table[0].row[0].amount[0]");
let ev = |som: &str, act: &str| events.iter().find(|e| e.som == som && e.activity == act).unwrap_or_else(|| panic!("no {act} on {som}"));
assert!(ev("form[0].page1[0]", "initialize").script.contains("qty.rawValue = 2"));
assert!(ev("form[0].page1[0].total[0]", "calculate").script.contains("qty.rawValue * price.rawValue"));
assert_eq!(ev("form[0].page1[0].qty[0]", "validate").message.as_deref(), Some("Quantity must be 100 or less"));
assert!(!ev("form[0].page1[0].addRow[0]", "click").formcalc);
assert!(ev("form[0].page1[0].legacy[0]", "click").formcalc, "no contentType means FormCalc");
// Data adds rows and values; overrides change presence.
let data = parse_datasets("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><qty>7</qty><table><row><amount>1</amount></row><row><amount>2</amount></row><row><amount>3</amount></row></table></page1></form></xfa:data></xfa:datasets>").unwrap();
let mut ov = Overrides::default();
ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
let root = form_tree(&tpl, Some(&data), &ov).root;
let page = &root.children[0];
assert_eq!(page.children.iter().find(|c| c.name == "qty").unwrap().value, "7");
assert_eq!(page.children.iter().find(|c| c.name == "table").unwrap().children.len(), 3);
assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().presence, "visible");
assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().kind, Some(NodeKind::Subform));
}
#[test]
fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
assert!(overrides(&doc).is_empty());
let names = |doc: &Document| fields_by_som(doc).into_values().collect::<Vec<_>>();
assert!(!names(&doc).iter().any(|n| n == "note"), "details is hidden: {:?}", names(&doc));
let mut ov = Overrides::default();
ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
ov.access.insert("form[0].page1[0].qty[0]".into(), "readOnly".into());
set_overrides(&mut doc, &ov).unwrap();
assert_eq!(overrides(&doc), ov);
let (tpl, _) = parse(&scripted_template()).unwrap();
let laid = apply_overrides(&tpl, &ov);
let Node::Subform(page) = &laid.root.children[0] else { panic!() };
let Node::Subform(details) = page.children.iter().find(|c| c.common().name.as_deref() == Some("details")).unwrap() else { panic!() };
assert_eq!(details.common.presence, Presence::Visible);
let Node::Field(qty) = page.children.iter().find(|c| c.common().name.as_deref() == Some("qty")).unwrap() else { panic!() };
assert_eq!(qty.access, Access::ReadOnly);
// Laying out again shows the note field and keeps the others.
let report = rerender(&mut doc, &tpl).unwrap();
let after = names(&doc);
assert!(after.iter().any(|n| n == "note"), "{after:?}");
assert!(after.iter().any(|n| n == "qty"));
assert_eq!(report.fields, after.len());
// Reopening the written bytes finds one set of fields, not two.
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(names(&doc).len(), after.len());
assert_eq!(overrides(&doc), ov);
// Clearing removes the key.
let mut doc = doc;
set_overrides(&mut doc, &Overrides::default()).unwrap();
assert!(overrides(&doc).is_empty());
}
#[test]
fn data_instances_are_added_and_removed_in_the_packet() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let parent = som_to_path("form[0].page1[0].table[0]");
// No datasets packet yet: one is created with the rows.
assert!(add_data_instances(&mut doc, &parent, "row", 3).unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.count(&parent, "row"), 3);
// Asking for fewer changes nothing; more adds.
assert!(!add_data_instances(&mut doc, &parent, "row", 2).unwrap().written);
assert!(add_data_instances(&mut doc, &parent, "row", 4).unwrap().written);
let mut p = parent.clone();
p.push(("row".into(), 1));
p.push(("amount".into(), 0));
write_data_value(&mut doc, &p, "42").unwrap();
let data = crate::script::data_of(&doc).unwrap();
assert_eq!((data.count(&parent, "row"), data.text_at(&p)), (4, Some("42")));
// Remove the second row: the third's value moves up.
let mut second = parent.clone();
second.push(("row".into(), 1));
assert!(remove_data_instance(&mut doc, &second).unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.count(&parent, "row"), 3);
assert_eq!(data.text_at(&p), None, "the row holding 42 is gone");
// Removing what isn't there is nothing.
let mut tenth = parent.clone();
tenth.push(("row".into(), 9));
assert!(!remove_data_instance(&mut doc, &tenth).unwrap().written);
// The layout follows the data: three rows of fields.
let (tpl, _) = parse(&scripted_template()).unwrap();
rerender(&mut doc, &tpl).unwrap();
let names: Vec<String> = fields_by_som(&doc).into_values().collect();
assert_eq!(names.iter().filter(|n| n.starts_with("amount")).count(), 3, "{names:?}");
// Hostile: absurd indices are capped, not built.
assert!(add_data_instances(&mut doc, &parent, "row", 1_000_000).is_ok());
}
#[test]
fn formcalc_button_idioms_keep_their_native_actions() {
let xml = scripted_template()
.replace("<script>$host.messageBox(\"FormCalc\")</script>", "<script>$host.resetData()</script>")
.replace("xfa.host.messageBox(\"Hello \" + qty.rawValue);", "if (qty.rawValue > 1) { xfa.host.resetData(); }");
let form = layout_xml(&xml).unwrap();
let w: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let by = |n: &str| w.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no {n}"));
assert_eq!(by("legacy").action, Some(Action::Reset), "a one-statement FormCalc idiom is a native action");
assert!(matches!(by("hello").action, Some(Action::Script(_))), "anything else runs as a script: {:?}", by("hello").action);
assert!(matches!(by("addRow").action, Some(Action::Script(_))));
}
/// Five levels of subforms that each repeat 50 times, with a scripted field at the bottom:
/// 50⁵ instances if nothing stopped the walk.
fn nested_repeats_template() -> String {
let mut body = r#"<field name="leaf" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">1</script></calculate></field>"#.to_string();
for level in (0..5).rev() {
body = format!(r#"<subform name="s{level}" layout="tb"><occur min="1" max="-1" initial="50"/>{body}</subform>"#);
}
format!(
r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><pageSet><pageArea name="p"><contentArea x="0" y="0" w="8in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>{body}</subform></template></xdp:xdp>"#
)
}
#[test]
fn the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget() {
fn count(n: &crate::script::FormNode) -> usize {
1 + n.children.iter().map(count).sum::<usize>()
}
let (tpl, _) = parse(&nested_repeats_template()).unwrap();
assert!(crate::script::has_scripts(&tpl));
let started = std::time::Instant::now();
let live = form_tree(&tpl, None, &Overrides::default());
assert!(live.truncated, "the walk was cut short");
assert!(count(&live.root) <= crate::script::MAX_FORM_NODES + 1, "{} nodes", count(&live.root));
assert!(live.events.len() <= 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
// A template without scripts has nothing to run.
let plain_xml = nested_repeats_template().replace(r#"<calculate><script contentType="application/x-javascript">1</script></calculate>"#, "");
let (plain, _) = parse(&plain_xml).unwrap();
assert!(!crate::script::has_scripts(&plain));
}
#[test]
fn data_ops_of_one_event_are_one_datasets_rewrite() {
use crate::data::{DataOp, write_data_ops};
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let before = doc.object_numbers().len();
let ops = vec![
DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "1".into() },
DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "7".into() },
DataOp::Instances { parent: som_to_path("form[0].page1[0].table[0]"), name: "row".into(), count: 3 },
DataOp::Value { path: som_to_path("form[0].page1[0].table[0].row[2].amount[0]"), text: "5".into() },
DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[0]")),
];
let r = write_data_ops(&mut doc, &ops, None).unwrap();
assert!(r.written && r.stream.is_some(), "{r:?}");
assert_eq!(doc.object_numbers().len(), before + 1, "one new datasets stream");
let data = crate::data_of(&doc).unwrap();
assert_eq!(data.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7"), "the last value wins");
assert_eq!(data.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2);
assert_eq!(data.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5"));
// The in-memory model agrees with what was written.
let mut model = DataNode::default();
model.children.push(DataNode { name: "form".into(), ..Default::default() });
for op in &ops {
model.apply(op);
}
assert_eq!(model.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7"));
assert_eq!(model.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2);
assert_eq!(model.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5"));
// A second write for the same event replaces that stream instead of adding another; an
// empty value clears the node.
let again = write_data_ops(&mut doc, &[DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: String::new() }], r.stream).unwrap();
assert_eq!(again.stream, r.stream);
assert_eq!(doc.object_numbers().len(), before + 1);
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].qty[0]")), Some(""));
}
#[test]
fn script_buttons_carry_no_javascript_action_for_other_viewers() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let fields = top_fields(&doc);
let add_row = fields.iter().find(|(name, _)| name == "addRow").map(|(_, d)| d.clone()).expect("addRow");
assert!(add_row.contains(crate::CLICK_KEY), "found by its SOM path");
assert!(add_row.get(b"A").is_none() && add_row.get(b"AA").is_none(), "no XFA source as a PDF JavaScript action");
}
/// The generated fields' dictionaries by name (top-level fields only).
fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
let root = doc.root().unwrap();
let catalog = doc.get(root);
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap());
fields
.as_array()
.unwrap()
.iter()
.filter_map(|f| {
let d = doc.resolve(f).as_dict().cloned()?;
let t = d.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text())?;
Some((t, d))
})
.collect()
}

View File

@ -5129,9 +5129,9 @@ tier = "P1"
milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill"]
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time"]
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. Missing: no FormCalc or XFA JavaScript, so rows don't grow by button and calculations don't run; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite"]
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript runs in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; FormCalc (reported, not run); the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"