From 23e1821144b1c265123518f1baa4d07a7a8c4a58 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:34:12 -0400 Subject: [PATCH 01/14] M12: XFA enter and exit scripts run whenever a field gains or loses focus A field's exit scripts run each time it loses focus, changed or not, and its enter scripts each time it gains it (Session::field_focus, the app's per-frame focus check, and the form_focus tool). Scripts that change nothing leave no edit; an exit its value change already ran is not run again; a radio button runs its own scripts. --- crates/automation/src/a11y.rs | 26 ++++++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 14 ++++ crates/automation/tests/it/automation.rs | 43 +++++++++++++ crates/engine/src/js.rs | 39 +++++++++++ crates/engine/src/lib.rs | 33 ++++++++-- crates/engine/src/tests.rs | 82 ++++++++++++++++++++++++ crates/engine/src/xfa.rs | 54 +++++++++++++++- crates/ui-egui/src/editing.rs | 34 ++++++++++ crates/ui-egui/src/forms_ui.rs | 4 ++ crates/ui-egui/tests/it/xfa.rs | 42 ++++++++++++ crates/xfa/src/fixtures.rs | 37 +++++++++++ crates/xfa/src/lib.rs | 2 +- crates/xfa/src/script.rs | 21 ++++++ crates/xfa/src/tests.rs | 23 +++++++ parity/acrobat-features.toml | 6 +- 16 files changed, 450 insertions(+), 11 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a7334f2069..c70bac2016 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -696,6 +696,32 @@ 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; returns whether the form changed and what + /// the scripts showed or asked for. + pub(crate) fn form_focus(&mut self, a: &Args) -> Result { + 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 changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?; + let out = self.session.take_js_output(id); + let reqs: Vec = out.requests.iter().map(request_json).collect(); + Ok(json!({ "changed": changed, "alerts": out.alerts, "console": out.console, "requests": reqs, "errors": out.errors })) + } + pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { let mut files = Vec::new(); for p in a.strs("paths")? { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1dae412e41..29d780b5ef 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -801,6 +801,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 = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..65aaac2cc5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -358,6 +358,20 @@ pub fn tools() -> Vec { "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. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. 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." }, + }), + &["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"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..a2c0c216c5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6803,6 +6803,49 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { check(&mut a, doc); } +/// 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}"); + } +} + #[test] fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a59743dbbb..f3f3a581a6 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -11,6 +11,15 @@ pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; +/// A field gaining or losing focus ([`Session::field_focus`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFocus { + /// The field gained focus: its `enter` scripts run. + Enter, + /// The field lost focus: its `exit` scripts run. + Exit, +} + /// What scripts produced for the viewer since it last looked. #[derive(Clone, Debug, Default, PartialEq)] pub struct JsOutput { @@ -221,6 +230,36 @@ impl Session { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() } + /// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form, + /// run the field's `enter` or `exit` scripts, as one undoable edit when they change something + /// (and none when they don't). An exit that the field's new value already ran (the value's + /// change runs it) isn't run a second time. Returns whether the document changed; what the + /// scripts showed or asked for is in [`Session::take_js_output`]. + pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result { + if self.js_off { + return Ok(false); + } + let doc = self.doc_mut(id)?; + let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) }; + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; + let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) }; + let activity = match focus { + FieldFocus::Enter => "enter", + FieldFocus::Exit => "exit", + }; + if !crate::xfa::has_event(cos, &tpl, &som, activity) { + doc.xfa_exited.remove(&som); + return Ok(false); + } + // Gaining focus starts afresh; losing it runs the exit unless the change just did. + if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit { + return Ok(false); + } + let before = doc.generation; + self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?; + Ok(self.get(id).is_some_and(|d| d.generation != before)) + } + /// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or /// the console (no target). Field changes and `resetForm` are applied as one undoable step; /// everything else the script asked for is returned. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..14c2e93d23 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -428,6 +428,10 @@ pub struct Document { xfa_template: Option>, /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). xfa_scripts_off: bool, + /// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`]) + /// since they last gained focus: their exit on losing focus has run already + /// ([`Session::field_focus`]). + xfa_exited: std::collections::HashSet, /// A content-only refresh keeps `info` from before the edit; an edit may have added or /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). fonts_stale: bool, @@ -2385,6 +2389,8 @@ struct EditCtx { xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. xfa_ran_away: bool, + /// An XFA enter or exit event whose scripts changed nothing: no edit to keep. + xfa_unchanged: bool, /// The edit took the XFA out of the form: what went with it, for the document's warnings. xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. @@ -2419,6 +2425,7 @@ impl EditCtx { xfa_out: Default::default(), xfa_datasets: None, xfa_ran_away: false, + xfa_unchanged: false, xfa_removed: Vec::new(), pages: 0, date: now.map(pdfcraft_cos::pdf_date), @@ -2585,10 +2592,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> 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); + let ran = if matches!(activity.as_str(), "enter" | "exit") { + xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty()) + } else { + xfa::on_event(doc, &tpl, som, activity, &mut run).map(|()| false) + }; cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; + cx.xfa_unchanged = ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { let (img, _) = embed_image(doc, name, image)?; @@ -3789,6 +3800,7 @@ impl Session { xfa, xfa_template: None, xfa_scripts_off: false, + xfa_exited: Default::default(), fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), @@ -3851,6 +3863,17 @@ 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()))))?; + // A field's enter or exit scripts that changed nothing leave no edit behind (no undo step, + // nothing unsaved); what they showed or asked for still goes out. + if cx.xfa_unchanged { + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + return Ok(()); + } // Redaction removed what its own interpreter places under the marks. The marks were drawn // over the page as the renderer shows it, so the renderer has the last word. if let Edit::ApplyRedactions { pages } = &edit { @@ -3889,7 +3912,8 @@ impl Session { }; // 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. + // to date first. The fields whose exit scripts ran are noted once the edit is kept. + let mut exited: Vec = Vec::new(); if let Some(tpl) = cx.xfa.clone() { if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { let datasets = &mut cx.xfa_datasets; @@ -3913,7 +3937,7 @@ impl Session { 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)) + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run, &mut exited)) .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; @@ -3984,6 +4008,7 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + doc.xfa_exited.extend(exited); if !cx.xfa_removed.is_empty() { let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..d928e429ce 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4675,6 +4675,88 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// The synthetic focus form ([`pdfcraft_xfa::fixtures::focus_template`]). +fn focus_form() -> Arc> { + Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())) +} + +/// The value of field `name` in document `id`. +fn field_text(s: &Session, id: DocId, name: &str) -> String { + s.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned())).unwrap_or_default() +} + +/// A field's exit scripts run every time it loses focus, changed or not, and its enter scripts +/// every time it gains it; each run that changes the form is one undo step. +#[test] +fn xfa_enter_and_exit_scripts_run_on_every_focus_change() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + for _ in 0..3 { + assert!(s.field_focus(id, "probe", 0, Enter).unwrap(), "the enter script counted"); + assert!(s.field_focus(id, "probe", 0, Exit).unwrap(), "the exit script counted"); + } + assert_eq!((field_text(&s, id, "exits"), field_text(&s, id, "enters")), ("3".into(), "3".into())); + assert!(s.get(id).unwrap().dirty); + s.undo(id).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "2", "each run is its own undo step"); +} + +/// A value typed into a field runs its exit with the change; losing focus then doesn't run it +/// again. The next visit runs it as usual. +#[test] +fn an_exit_the_fields_change_ran_does_not_run_twice() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("x".into()) }).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "1", "the change ran the exit"); + assert!(!s.field_focus(id, "probe", 0, Exit).unwrap(), "not again on losing focus"); + assert_eq!(field_text(&s, id, "exits"), "1"); + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "2", "the next visit's exit runs"); +} + +/// Scripts that change nothing leave no edit: the document stays saved, with nothing to undo, +/// and what they showed still reaches the viewer. A field without such scripts runs nothing. +#[test] +fn focus_scripts_that_change_nothing_leave_the_document_clean() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + s.field_focus(id, "quiet", 0, Enter).unwrap(); + assert!(!s.field_focus(id, "quiet", 0, Exit).unwrap(), "nothing changed"); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty && doc.can_undo().is_none(), "no edit kept"); + let out = s.take_js_output(id); + assert_eq!(out.alerts.iter().map(String::as_str).collect::>(), ["left quiet"]); + // A field without focus scripts, and a field that doesn't exist. + assert!(!s.field_focus(id, "exits", 0, Exit).unwrap()); + assert!(!s.field_focus(id, "nowhere", 0, Enter).unwrap()); + assert!(!s.get(id).unwrap().dirty); +} + +/// Each radio button of a group is a field of its own: reaching the second button runs its +/// enter script, reaching the first (which has none) runs nothing. Choosing the button changes +/// the group's value, which runs the group's scripts, not the button's: leaving the button +/// still runs its exit. +#[test] +fn a_radio_buttons_own_focus_scripts_run() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let group = s.get(id).unwrap().form.iter().find(|f| f.widgets.len() == 2).map(|f| f.name.clone()).expect("the radio group"); + assert!(!s.field_focus(id, &group, 0, Enter).unwrap(), "the first button has no enter script"); + assert!(s.field_focus(id, &group, 1, Enter).unwrap(), "the second button's ran"); + assert!(!s.field_focus(id, &group, 2, Enter).unwrap(), "there is no third button"); + assert_eq!(field_text(&s, id, "picks"), "1"); + s.apply(id, Edit::SetFieldValue { name: group.clone(), value: FieldValue::Radio(Some("L".into())) }).unwrap(); + assert!(s.field_focus(id, &group, 1, Exit).unwrap(), "the button's exit runs on leaving it"); + assert_eq!(field_text(&s, id, "drops"), "1"); +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 6ce76548f5..8743ac94ed 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -826,14 +826,24 @@ pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mu /// 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> { +/// than reverting it. The SOM paths whose exit scripts ran go into `exited`. +pub(crate) fn on_changes( + doc: &mut pdfcraft_cos::Document, + tpl: &Template, + names: &[String], + run: &mut XfaRun, + exited: &mut Vec, +) -> 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")) { + let exits = r.events_of(&som, "exit"); + if !exits.is_empty() { + exited.push(som.clone()); + } + for ev in r.events_of(&som, "change").into_iter().chain(exits) { r.run(&ev, &value)?; } for ev in r.events_of(&som, "validate") { @@ -874,6 +884,44 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s r.finish().map(|_| ()) } +/// A field gaining (`enter`) or losing (`exit`) focus: its scripts for that activity, then, when +/// they changed something, the calculations. Returns what changed; nothing (and no error) when +/// the field has no such script, so a focus change with nothing to run leaves the form alone. +pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result { + let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); + let events = r.events_of(som, activity); + if events.is_empty() { + return Ok(Changes::default()); + } + // The field's value, as the script's event sees it (`xfa.event.prevText`, `newText`). + let value = r.by_som().get(som).cloned().and_then(|name| r.fields().get(&name).map(|f| f.value.join("\n"))).unwrap_or_default(); + for ev in events { + r.run(&ev, &value)?; + } + if !r.changes.is_empty() { + // The calculations that follow get a budget of their own. + r.clock = Clock::start(OPEN_BUDGET_MS); + r.calculates()?; + } + r.finish() +} + +/// The SOM path of widget `widget` of field `field`: each radio button of a group has its own, +/// and a field's copies on several pages share one. +pub(crate) fn widget_som(doc: &pdfcraft_cos::Document, field: &pdfcraft_forms::Field, widget: usize) -> Option { + let som_of = |obj: pdfcraft_cos::ObjRef| { + let object = doc.get(obj); + object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string().map(|s| s.to_text()) + }; + field.widgets.get(widget).and_then(|w| som_of(w.obj)).or_else(|| som_of(field.obj)) +} + +/// Does the live form give SOM path `som` an `activity` script? +pub(crate) fn has_event(doc: &pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str) -> bool { + 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 == activity) +} + /// The SOM path of a generated field whose template has a click script. pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option { // Page-master copies have distinct PDF field names but share a SOM path. Reversing diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2fdc2e9d02..5e64b1e8dc 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -252,6 +252,9 @@ impl PdfCraftApp { pub(crate) fn process_pending_edits(&mut self) -> bool { let Some(i) = self.active else { return true }; let applied = self.apply_queued_edit(i); + // After the queued edit: a value typed into the field the focus left has been taken (and + // its change has run the field's exit) before the focus scripts run. + self.run_focus_scripts(i); if !applied { // Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal. self.deferred_commands.clear(); @@ -467,6 +470,37 @@ impl PdfCraftApp { false } + /// XFA enter and exit scripts for where the focus went on tab `i` since it was last looked at, + /// however it moved (a click, Tab or Shift+Tab, Escape, a click on the page, a script): the + /// field it left runs its exit, then the field it reached runs its enter. The engine skips an + /// exit the field's committed change already ran. A tab that closes runs nothing: its + /// document is going away, and a script changing it then would only ask to save it. + fn run_focus_scripts(&mut self, i: usize) { + let Some(view) = self.views.get_mut(i) else { return }; + let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget)); + if view.forms.focused == now { + return; + } + let left = std::mem::replace(&mut view.forms.focused, now.clone()); + let id = view.id; + use pdfcraft_engine::js::FieldFocus; + for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() { + match self.session.field_focus(id, &name, widget, focus) { + Ok(changed) => { + if changed + && let Some(info) = self.session.get(id).map(|d| d.info.clone()) + && let Some(view) = self.views.get_mut(i) + { + view.document_changed(&info); + } + let out = self.session.take_js_output(id); + self.handle_js(id, out); + } + Err(e) => self.notify_error(e), + } + } + } + /// Apply the edit queued on tab `i` (the active one). Returns false only when it was a /// field's typing and the field refused it (#166): the editor reopens with the text, and the /// user has been told why, so the typing isn't lost and the caller can stop. diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e0926721..42754753aa 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -67,6 +67,10 @@ pub struct FormView { /// The widget holding egui's keyboard focus for the focused check box or radio button, and /// whether it takes the arrows (radio buttons). The page keys still work around it. pub(crate) keys: Option<(egui::Id, bool)>, + /// The field and widget that had focus when it was last looked at: when the focus moves, the + /// one it left runs its XFA exit scripts and the one it reached its enter scripts + /// (`PdfCraftApp::run_focus_scripts`). + pub(crate) focused: Option<(String, usize)>, } /// A form message for the app to show, with document data kept separate so the visible diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index e66658b0e1..7ac6deea67 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -146,6 +146,48 @@ fn xfa_buttons_run_their_scripts_in_the_app() { assert!(h.query_by_label_contains("Hello 2").is_some(), "script console: {:?}", h.state().js_console.log); } +/// Every way the focus moves (a click, Tab, Shift+Tab, Escape) runs the field it left's exit +/// script and the field it reached's enter script, changed or not; typing a change and tabbing +/// out runs the exit once, not twice. A script's message shows as a notice. +#[test] +fn xfa_enter_and_exit_scripts_run_whenever_the_focus_moves() { + use egui::{Key, Modifiers}; + let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())); + wait_for_pages(&mut h); + let counts = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let v = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default(); + (v("enters"), v("exits")) + }; + let focus = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone()); + let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + }; + click_field(&mut h, "probe"); + assert_eq!(counts(&h), ("1".into(), "0".into()), "a click in runs the enter"); + for _ in 0..2 { + press(&mut h, Modifiers::NONE, Key::Tab); + assert_eq!(focus(&h).as_deref(), Some("quiet")); + press(&mut h, Modifiers::SHIFT, Key::Tab); + assert_eq!(focus(&h).as_deref(), Some("probe")); + } + press(&mut h, Modifiers::NONE, Key::Escape); + assert_eq!(focus(&h), None); + assert_eq!(counts(&h), ("3".into(), "3".into()), "three unchanged visits, three exits; console: {:?}", h.state().js_console.log); + // A change typed in: tabbing out runs the exit once. + click_field(&mut h, "probe"); + h.event(egui::Event::Text("typed".into())); + h.run_steps(2); + press(&mut h, Modifiers::NONE, Key::Tab); + assert_eq!(counts(&h), ("4".into(), "4".into())); + // Leaving `quiet` shows its message. + assert_eq!(focus(&h).as_deref(), Some("quiet")); + press(&mut h, Modifiers::NONE, Key::Escape); + assert!(h.query_by_label_contains("left quiet").is_some(), "console: {:?}", h.state().js_console.log); +} + #[test] fn messages_from_scripts_run_on_open_show_right_away() { let tpl = pdfcraft_xfa::fixtures::scripted_template() diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index a0307a6944..7ca2d9d0c4 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -658,3 +658,40 @@ if (this.rawValue == "1") { "## .to_string() } + +/// A form whose text field `probe` counts its enters in `enters` and its exits in `exits`, whose +/// text field `quiet` only shows a message when it is left, and whose radio button `size.large` +/// counts its enters in `picks` and its exits in `drops` (each button of a radio group is a field +/// with scripts of its own). +pub fn focus_template() -> String { + let js = "application/x-javascript"; + let count = |name: &str| format!(r#""#); + let counter = |name: &str| { + format!(r#"0"#) + }; + format!( + r##" + + + +"##, + count("enters"), + count("exits"), + count("picks"), + count("drops"), + counter("exits"), + counter("enters"), + counter("picks"), + counter("drops") + ) +} diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 93e668257f..3ccefe3c71 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -101,7 +101,7 @@ pub use parse::{measure, parse}; pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, - fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, + fields_by_som, form_tree, has_focus_scripts, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, }; #[derive(Debug, thiserror::Error)] diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index cb555dc81d..2bc1f2f8ad 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -459,6 +459,27 @@ pub fn has_scripts(tpl: &Template) -> bool { 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))) } +/// Does any field of the template (in the body, an exclusion group or a master page) have an +/// `enter` or `exit` script? Forms without one have nothing to run when the focus moves. +pub fn has_focus_scripts(tpl: &Template) -> bool { + /// Has field `f` a non-blank enter or exit script? + fn field(f: &Field) -> bool { + f.scripts.iter().any(|s| matches!(s.activity.as_str(), "enter" | "exit") && !s.text.trim().is_empty()) + } + /// Has any field among `nodes` (and their containers, `depth` levels down) one? + fn walk(nodes: &[Node], depth: usize) -> bool { + depth <= MAX_DEPTH + && nodes.iter().any(|n| match n { + Node::Subform(sf) => walk(&sf.children, depth + 1), + Node::Area(a) => walk(&a.children, depth + 1), + Node::Field(f) => field(f), + Node::ExclGroup(g) => g.fields.iter().any(field), + Node::Draw(_) => false, + }) + } + 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 { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..4b9e11de3a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2832,3 +2832,26 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// Forms with an `enter` or `exit` script, in the body, a radio group or a page master, have +/// something to run when the focus moves; forms with other scripts only, or blank ones, don't. +#[test] +fn focus_scripts_are_found_wherever_the_template_has_them() { + let has = |xml: &str| has_focus_scripts(&parse(xml).unwrap().0); + assert!(has(&crate::fixtures::focus_template())); + let clicks = + crate::fixtures::focus_template().replace(r#"activity="enter""#, r#"activity="click""#).replace(r#"activity="exit""#, r#"activity="click""#); + assert!(!has(&clicks), "click scripts only"); + let large = r#"L