Skip to content
Open
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
23e1821
M12: XFA enter and exit scripts run whenever a field gains or loses f…
EricKaden Oct 24, 2026
5641420
M12: docstrings for the helpers the focus-script change touches
EricKaden Oct 24, 2026
66c70c0
M12: the focus-script check's comment lists only the ways the focus c…
EricKaden Oct 24, 2026
be08886
M12: a focus script that changes the field being edited shows in its …
EricKaden Oct 24, 2026
7e00c61
M12: the editor follows each focus script in turn, so an exit then an…
EricKaden Oct 24, 2026
2e8720c
M12: an exit skipped after a value change is skipped only until the n…
EricKaden Oct 24, 2026
3257bac
M12: a batched focus event that changes nothing keeps the batch's edi…
EricKaden Oct 24, 2026
e3a4ef8
M12: docstrings for the functions next to the focus-script change
EricKaden Oct 24, 2026
756a7fb
M12: XFA check boxes and radio buttons run their click scripts when a…
EricKaden Oct 24, 2026
262013a
M12: a form script event can't be part of a batch of edits
EricKaden Oct 24, 2026
e550622
M12: a check box or radio button set to the value it had runs no clic…
EricKaden Oct 24, 2026
629ba85
M12: docstrings for the functions next to the click-script change
EricKaden Oct 24, 2026
8e52d9e
M12: comments for the closures the click-script change adds
EricKaden Oct 24, 2026
9776ced
Merge remote-tracking branch 'upstream/main' into fix/xfa-check-box-c…
EricKaden Oct 24, 2026
0c88cc9
M12: the click-script UI test waits for the form as main's tests now do
EricKaden Oct 24, 2026
3ae1baa
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 24, 2026
a32b418
Merge remote-tracking branch 'upstream/main' into fix/xfa-check-box-c…
EricKaden Oct 24, 2026
9c5c9a5
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 24, 2026
c84be71
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 24, 2026
d25ad8b
Merge remote-tracking branch 'upstream/main' into fix/xfa-check-box-c…
EricKaden Oct 24, 2026
8e9b734
M6: a check box a batch sets twice is clicked once
EricKaden Oct 24, 2026
3f6471e
M6: a radio group's change and click scripts run before its button's,…
EricKaden Oct 24, 2026
6a29250
M12: a radio group's own enter and exit scripts run when the focus en…
EricKaden Oct 24, 2026
0a6d8fe
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 24, 2026
8ae36ad
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 24, 2026
aad694b
M12: a widget index the field doesn't have runs no focus scripts
EricKaden Oct 24, 2026
d8ab397
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 24, 2026
4f9ac46
M12: a radio button the group doesn't have runs none of the group's f…
EricKaden Oct 24, 2026
6a1e089
M12: committing the form runs the focus scripts of a move not yet run
EricKaden Oct 25, 2026
6236a6a
Merge remote-tracking branch 'origin/fix/xfa-check-box-click-scripts'…
EricKaden Oct 25, 2026
ab152be
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 25, 2026
af27c3a
M12: Revert drops the focus without running the field's exit
EricKaden Oct 25, 2026
343a866
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 25, 2026
f104429
M12: check box and radio exit scripts wait for the focus to leave
EricKaden Oct 25, 2026
1591b6b
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 25, 2026
149dd4e
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 25, 2026
9bb0bd5
M12: a focus script that runs away stops the rest without an error, a…
EricKaden Oct 25, 2026
5cf5957
Merge branch 'fix/xfa-radio-group-focus' into fix/xfa-toggle-exit-on-…
EricKaden Oct 25, 2026
3e339bb
M12: Revert forgets which check box or radio group had the focus
EricKaden Oct 25, 2026
a51feb4
M12: the parity notes say what focus handling still lacks
EricKaden Oct 25, 2026
9bb4ac1
M12: a focus move builds the live form once to find its scripts
EricKaden Oct 25, 2026
820e754
Merge branch 'fix/xfa-radio-group-focus' into fix/xfa-toggle-exit-on-…
EricKaden Oct 25, 2026
f098d19
Merge remote-tracking branch 'upstream/main' into fix/xfa-check-box-c…
EricKaden Oct 25, 2026
470a89b
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-…
EricKaden Oct 25, 2026
9d385c2
Merge branch 'fix/xfa-enter-exit-on-focus' into fix/xfa-radio-group-f…
EricKaden Oct 25, 2026
aca60f7
Merge remote-tracking branch 'origin/fix/xfa-check-box-click-scripts'…
EricKaden Oct 25, 2026
110cb28
Merge remote-tracking branch 'origin/fix/xfa-radio-group-focus' into …
EricKaden Oct 25, 2026
db13a3a
M12: XFA fields validate before they exit, and a list box validates a…
EricKaden Oct 25, 2026
c419cbb
M12: the focus-script lookup runs behind the guard, focus calculation…
EricKaden Oct 25, 2026
19ca4a9
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 26, 2026
7d574fb
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 27, 2026
ef3a4a1
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 28, 2026
7f01d1c
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 28, 2026
8c0edc5
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 28, 2026
1c9f9a1
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 29, 2026
44a2b8f
Merge remote-tracking branch 'upstream/main' into fix/xfa-validate-be…
EricKaden Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions crates/automation/src/a11y.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,13 +733,17 @@ impl Automation {
Ok(json!({ "scripts": list }))
}

/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
/// when `script` is left out, as one undoable edit; returns the document's scripts.
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.js_document_scripts(a)
}

/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
/// `script` is left out, as one undoable edit; returns the console output and errors.
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetFieldScript {
name: a.str("field")?.into(),
Expand All @@ -752,6 +756,36 @@ impl Automation {
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
}

/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
/// focus, running its XFA scripts for that event (a radio group's own too, unless
/// `within_group`: the focus moves between two of its buttons); returns whether the form
/// changed and what the scripts showed or asked for.
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::js::FieldFocus;
let focus = match a.str("event")? {
"enter" => FieldFocus::Enter,
"exit" => FieldFocus::Exit,
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
};
let name = a.str("field")?;
let doc = self.doc(a)?;
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
let widget = a.opt_int("widget")?.unwrap_or(1);
let widget = usize::try_from(widget)
.ok()
.and_then(|w| w.checked_sub(1))
.filter(|w| *w < field.widgets.len().max(1))
.ok_or_else(|| ToolError::InvalidArgs(format!("widget must be 1 to {} for {name:?}", field.widgets.len().max(1))))?;
let id = doc.id;
let crossing = !a.opt_bool("within_group")?.unwrap_or(false);
let changed = self.session.field_focus_crossing(id, name, widget, focus, crossing).map_err(failed)?;
let out = self.session.take_js_output(id);
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
Ok(json!({ "changed": changed, "alerts": out.alerts, "console": out.console, "requests": reqs, "errors": out.errors }))
}

/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
/// file; returns the path and the row and column counts.
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
let mut files = Vec::new();
for p in a.strs("paths")? {
Expand Down
1 change: 1 addition & 0 deletions crates/automation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@ impl Automation {
}
"form_fields" => self.form_fields(&a)?,
"form_fill" => self.form_fill(&a)?,
"form_focus" => self.form_focus(&a)?,
"form_set_image" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
Expand Down
15 changes: 15 additions & 0 deletions crates/automation/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,21 @@ pub fn tools() -> Vec<ToolDef> {
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t(
"form_focus",
"Move the focus into or out of a field",
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. A check box or radio group form_fill sets while the focus is on it runs its exit scripts here, when it loses the focus, not with the change. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. For a radio button the focus also enters or leaves its group, whose own enter scripts run before the button's and exit scripts after; set within_group when the focus moves between two buttons of one group, which runs only theirs. Returns changed, and the scripts' alerts, console output, requests and errors.",
)
.with(schema(
json!({
"doc": doc(),
"field": { "type": "string", "description": "Field name (from form_fields)." },
"event": { "type": "string", "enum": ["enter", "exit"] },
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
"within_group": { "type": "boolean", "description": "The focus moves between two buttons of one radio group (default false): the group's own enter and exit scripts don't run." },
}),
&["doc", "field", "event"],
)),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
Expand Down
129 changes: 129 additions & 0 deletions crates/automation/tests/it/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7379,6 +7379,8 @@ fn growable_xfa_text_paginates_after_fill_save_and_print() {
let _ = std::fs::remove_dir_all(dir);
}

/// A print button repeated on every page of an XFA form runs its own XFA click script (a print
/// request) through js_run, from each copy, before and after saving and reopening.
#[test]
fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
let dir = workdir("xfa-print-buttons");
Expand Down Expand Up @@ -7406,6 +7408,73 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
check(&mut a, doc);
}

/// form_focus runs a radio group's own enter and exit scripts when the focus enters or leaves the
/// group, and not when within_group says it moves between two of its buttons.
#[test]
fn a_radio_groups_own_focus_scripts_run_through_form_focus() {
let dir = workdir("xfa-group-focus");
std::fs::write(dir.join("group.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::group_focus_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap();
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let group = fields["fields"].as_array().unwrap().iter().find(|f| f["type"] == "radio").unwrap()["name"].clone();
let log = |a: &mut Automation| {
let fields = ok(a, "form_fields", json!({ "doc": doc }));
fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap_or(Value::Null)
};
ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter" }));
ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "exit", "within_group": true }));
ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "widget": 2, "event": "enter", "within_group": true }));
ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "widget": 2, "event": "exit" }));
assert_eq!(log(&mut a), json!("grp enter|r1 enter|r1 exit|r2 enter|r2 exit|grp exit"));
let _ = std::fs::remove_dir_all(&dir);
}

/// form_focus runs a field's XFA enter and exit scripts on every focus change; scripts that
/// change nothing leave no edit, a radio button's own scripts run for its widget, and unknown
/// fields, widgets and events are refused.
#[test]
fn xfa_focus_scripts_run_through_form_focus() {
let dir = workdir("xfa-focus");
std::fs::write(dir.join("focus.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "focus.pdf" }))["doc"].as_u64().unwrap();
let value = |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).map(|f| f["value"].clone()).unwrap()
};
for _ in 0..3 {
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "enter" }))["changed"], true);
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "exit" }))["changed"], true);
}
assert_eq!((value(&mut a, "enters"), value(&mut a, "exits")), (json!("3"), json!("3")));
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!(value(&mut a, "exits"), "2", "each run is one undo step");
// A script that only shows a message changes nothing: no edit (the document stays saved and
// what was undone can still be redone), and the message comes back.
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
let quiet = ok(&mut a, "form_focus", json!({ "doc": doc, "field": "quiet", "event": "exit" }));
assert_eq!((quiet["changed"].clone(), quiet["alerts"].clone()), (json!(false), json!(["left quiet"])), "{quiet}");
let state = &ok(&mut a, "doc_info", json!({ "doc": doc }))["document"];
assert_eq!((state["dirty"].clone(), state["redo"].clone()), (json!(false), json!("Run form script")), "{state}");
// The second radio button has an enter script of its own.
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let group = fields["fields"].as_array().unwrap().iter().find(|f| f["type"] == "radio").unwrap()["name"].clone();
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter" }))["changed"], false);
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter", "widget": 2 }))["changed"], true);
assert_eq!(value(&mut a, "picks"), "1");
for bad in [
json!({ "doc": doc, "field": "nowhere", "event": "enter" }),
json!({ "doc": doc, "field": group, "event": "enter", "widget": 3 }),
json!({ "doc": doc, "field": "probe", "event": "enter", "widget": 0 }),
json!({ "doc": doc, "field": "probe", "event": "click" }),
] {
assert!(a.call("form_focus", &bad).is_err(), "{bad}");
}
}

/// XFA scripts run through the tools: initialize and calculate on opening, calculations after
/// form_fill, and a button's click script through js_run, adding a row that undo takes away.
#[test]
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let dir = workdir("xfa-scripts");
Expand Down Expand Up @@ -7437,6 +7506,66 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
assert_eq!(r["alerts"], json!(["Hello 4"]));
}

/// Filling an XFA check box or radio group through form_fill clicks it, as the app does: the
/// change scripts, then the click scripts, a radio group's before those of the button now on.
#[test]
fn xfa_check_box_and_radio_clicks_run_through_form_fill() {
let dir = workdir("xfa-toggle");
std::fs::write(dir.join("toggle.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::toggle_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "toggle.pdf" }))["doc"].as_u64().unwrap();
// What the scripts have written to the form's log field so far.
let log = |a: &mut Automation| {
let f = ok(a, "form_fields", json!({ "doc": doc }));
f["fields"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == "log")
.map(|f| f["value"].as_str().unwrap_or_default().to_string())
.unwrap_or_default()
};
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "agree": true } }));
assert_eq!(log(&mut a), "change 1;click 1;");
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let answer = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "answer").cloned().expect("the radio group");
let no = answer["options"].as_array().and_then(|o| o.get(1)).and_then(|o| o.as_str().map(str::to_string)).unwrap_or_else(|| panic!("{answer}"));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "answer": no } }));
assert!(log(&mut a).ends_with("group click;no click;no exit;group exit;"), "{}", log(&mut a));
}

/// A check box filled while form_focus has put the focus on it keeps its exit for the focus
/// loss: filled twice, it runs its exit once, on form_focus exit. Filled without the focus on
/// it, its exit follows the change.
#[test]
fn a_check_box_filled_with_the_focus_runs_its_exit_on_form_focus_exit() {
let dir = workdir("xfa-toggle-focus");
std::fs::write(dir.join("toggle.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::toggle_focus_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "toggle.pdf" }))["doc"].as_u64().unwrap();
let log = |a: &mut Automation| {
let f = ok(a, "form_fields", json!({ "doc": doc }));
f["fields"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == "log")
.map(|f| f["value"].as_str().unwrap_or_default().to_string())
.unwrap_or_default()
};
ok(&mut a, "form_focus", json!({ "doc": doc, "field": "cb", "event": "enter" }));
for on in [true, false] {
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "cb": on } }));
}
assert_eq!(log(&mut a), "cb enter 0|cb change 1|cb click 1|cb change 0|cb click 0", "no exit while it has the focus");
ok(&mut a, "form_focus", json!({ "doc": doc, "field": "cb", "event": "exit" }));
assert!(log(&mut a).ends_with("|cb click 0|cb exit 0"), "{}", log(&mut a));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "cb": true } }));
assert!(log(&mut a).ends_with("|cb exit 0|cb change 1|cb click 1|cb exit 1"), "without the focus: {}", log(&mut a));
let _ = std::fs::remove_dir_all(&dir);
}

/// A form whose opening script runs away has its scripts turned off, and doc_info says so.
#[test]
fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
let dir = workdir("xfa-container-scripts");
Expand Down
Loading
Loading