From 98d931030934a29026442da1091b0f91c8f7e7cc Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:59:10 -0400 Subject: [PATCH 001/140] Forms: app.runtimeHighlight, and XFA layout:ready scripts run on opening --- crates/automation/src/a11y.rs | 1 + crates/engine/src/js.rs | 20 +++++++++++++--- crates/engine/src/lib.rs | 24 ++++++++++++++----- crates/engine/src/tests.rs | 17 +++++++++++++ crates/engine/src/xfa.rs | 20 ++++++++++++++-- crates/js/src/lib.rs | 24 +++++++++++++++++++ crates/js/src/tests.rs | 36 +++++++++++++++++++++++++--- crates/js/src/xfa.rs | 23 ++++++++++++++++++ crates/ui-egui/src/js_ui.rs | 8 +++++++ crates/ui-egui/src/lib.rs | 6 +++++ crates/ui-egui/tests/ui_group/xfa.rs | 12 ++++++++++ crates/xfa/src/fixtures.rs | 22 +++++++++++++++++ crates/xfa/src/lib.rs | 2 +- crates/xfa/src/parse.rs | 19 +++++++++++---- crates/xfa/src/tests.rs | 20 ++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 16 files changed, 236 insertions(+), 22 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..a7c05e14e9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -656,6 +656,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::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), } } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index b797bc8d83..84a3dc6b4d 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -111,8 +111,15 @@ pub struct JsRunner { } impl JsRunner { - pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { - let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; + /// `highlight`: whether the viewer highlights form fields (`app.runtimeHighlight`). + pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pdfcraft_model::pages(cos).len(), + page: 0, + info: info(cos), + highlight, + }; JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() } } } @@ -175,6 +182,13 @@ impl Session { !self.js_off } + /// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as + /// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the + /// viewer then calls this with the new setting. + pub fn set_field_highlight(&mut self, on: bool) { + self.field_highlight = on; + } + /// What scripts printed or asked for since the last call (alerts, console, print, …). pub fn take_js_output(&mut self, id: DocId) -> JsOutput { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() @@ -205,7 +219,7 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name); + let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); let fields = pdfcraft_forms::fields(&cos); let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); let event = match target { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c6..dbd35e55df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2252,6 +2252,8 @@ struct EditCtx { xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pages: usize, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + highlight: bool, date: Option, /// Today in local time, for date tokens. today: (i64, u32, u32), @@ -2281,6 +2283,7 @@ impl EditCtx { xfa_ran_away: false, xfa_removed: Vec::new(), pages: 0, + highlight: false, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, @@ -2438,7 +2441,8 @@ 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 mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight: cx.highlight }; let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; @@ -2935,6 +2939,9 @@ pub struct Session { date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. date_language: Option, + /// Highlight fields, as the viewer shows it: what forms' scripts read as + /// `app.runtimeHighlight` (off by default, as in a session without a viewer). + field_highlight: bool, } /// Immutable session state needed while a document is prepared away from its owning thread. @@ -2943,6 +2950,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + highlight: bool, } /// A fully prepared document that has not yet been registered in a session. @@ -3177,7 +3185,7 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// fields take the data's values. fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight: cx.highlight }; let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; @@ -3337,7 +3345,7 @@ impl Session { /// Snapshot the session state that affects open-time scripts, dates and signature trust. pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), highlight: self.field_highlight } } /// Prepare a document without adding it to this session. @@ -3466,7 +3474,8 @@ impl Session { 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 mut run = + xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false, highlight: context.highlight }; let changes = xfa::on_open(&mut work, &tpl, &mut run); if run.ran_away { ran_away = true; @@ -3640,6 +3649,7 @@ impl Session { let now = self.now(); let today = self.today(); let js_off = self.js_off; + let highlight = self.field_highlight; let doc = self.doc_mut(id)?; doc.edit_notices.clear(); let name = doc.name.clone(); @@ -3649,6 +3659,7 @@ impl Session { cx.xfa = doc.xfa_template.clone(); } cx.pages = doc.info.pages.len(); + cx.highlight = highlight; cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let signed = doc.is_signed(); @@ -3679,7 +3690,7 @@ impl Session { } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); + cx.js = Some(js::JsRunner::new(&next, &name, highlight)); } cx.renamed_fields.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. @@ -3744,7 +3755,8 @@ impl Session { 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 }; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight: cx.highlight }; // 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)) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..4ceefebbef 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6106,3 +6106,20 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +#[test] +fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); + let mut s = Session::new(); + let id = s.open("ready.pdf", None, bytes.clone(), 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_default(); + let out = s.take_js_output(id); + assert_eq!(out.requests, vec![js::Request::Highlight(true)], "the form turns field highlighting on: {out:?}"); + assert_eq!(value(&s, "stamp"), vec!["laid out".to_string()]); + assert!(value(&s, "pageNo").iter().all(|v| v.is_empty()), "page numbers are the layout's: {:?}", value(&s, "pageNo")); + assert!(value(&s, "note").iter().all(|v| v.is_empty()), "form:ready is another event"); + // With highlighting already on, the form has nothing to ask for. + s.set_field_highlight(true); + let id = s.open("ready.pdf", None, bytes, None).expect("opens"); + assert!(s.take_js_output(id).requests.is_empty()); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..6a9fe2a8c6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -127,6 +127,8 @@ pub(crate) struct XfaRun<'a> { pub datasets: Option, /// A script ran past its time limit and was abandoned: the form's scripts should stay off. pub ran_away: bool, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + pub highlight: bool, } fn to_js(n: &FormNode) -> XfaNode { @@ -307,7 +309,7 @@ impl<'a, 'b> Runner<'a, 'b> { /// The script engine for this event. fn session(&mut self) -> &mut XfaSession { - let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; + let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count, highlight: self.run.highlight }; self.session.get_or_insert_with(|| XfaSession::new(doc_info)) } @@ -632,6 +634,10 @@ impl<'a, 'b> Runner<'a, 'b> { } } XfaEffect::Beep => self.run.out.requests.push(Request::Beep), + XfaEffect::Highlight(on) => { + self.run.highlight = *on; + self.run.out.requests.push(Request::Highlight(*on)); + } XfaEffect::Recalculate => {} XfaEffect::Relayout => relayout = true, } @@ -764,13 +770,23 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, } } -/// On open: initialize scripts, then calculations. Returns what they changed. +/// On open: initialize scripts, then layout:ready scripts (the form is laid out by now), then +/// calculations. Returns what they changed. +/// +/// layout:ready runs once here, where forms set themselves up (turning field highlighting on, +/// say); a viewer runs it after every layout. Scripts that number pages are left out: the +/// layout gives each page its number itself. pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect(); for ev in &events { r.run(ev, "")?; } + let ready: Vec = + r.live().events.iter().filter(|e| e.activity == "layoutReady" && !pdfcraft_xfa::numbers_pages(&e.script)).cloned().collect(); + for ev in &ready { + r.run(ev, "")?; + } r.calculates()?; r.finish() } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..9ace0cbc4f 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -141,6 +141,8 @@ pub struct DocInfo { pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, + /// Whether the viewer highlights form fields (`app.runtimeHighlight`). + pub highlight: bool, } /// Something a script asked the viewer to do. @@ -159,6 +161,8 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. + Highlight(bool), } /// What running a script did. @@ -559,6 +563,23 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } +/// `app.runtimeHighlight`: whether the viewer highlights form fields. +fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) +} + +/// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. Later reads +/// in the script see the new setting. +fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + let mut h = host(ctx)?.borrow_mut(); + if h.doc.highlight != on { + h.doc.highlight = on; + h.requests.push(Request::Highlight(on)); + } + Ok(JsValue::undefined()) +} + fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); @@ -926,7 +947,10 @@ fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; ctx.register_global_property(js_string!("info"), info, rw)?; + let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); + let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); let app = ObjectInitializer::new(ctx) + .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..43628137c6 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -16,7 +16,7 @@ fn fields() -> Vec { } fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false } } fn go(script: &str, event: &Event) -> Outcome { @@ -382,7 +382,7 @@ mod xfa_model { 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 }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3, ..Default::default() }; run_xfa(script, &ev, &doc, &form(), Limits::default()) } @@ -596,7 +596,7 @@ mod formcalc { 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 }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3, ..Default::default() }; run_formcalc(script, &ev, &doc, &form(), Limits::default()) } @@ -1132,3 +1132,33 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +mod runtime_highlight { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; + + #[test] + fn app_runtime_highlight_reads_the_viewers_setting_and_asks_to_change_it() { + let ev = Event::field("Calculate", "total", ""); + let on = DocInfo { highlight: true, ..doc() }; + let o = run("event.value = String(app.runtimeHighlight);", &ev, &on, &fields(), &[], Limits::default()); + assert_eq!((o.value.as_str(), o.requests.len()), ("true", 0)); + let o = go("var was = app.runtimeHighlight; app.runtimeHighlight = true; event.value = was + ',' + app.runtimeHighlight;", &ev); + assert_eq!(o.error, None); + assert_eq!(o.value, "false,true", "the script reads back what it set"); + assert_eq!(o.requests, vec![Request::Highlight(true)]); + } + + #[test] + fn xfa_app_runtime_highlight_reads_the_setting_and_records_a_change() { + let ev = XfaEvent { activity: "layoutReady".into(), target: "form1[0]".into(), ..Default::default() }; + let root = XfaNode { name: "form1".into(), som: "form1[0]".into(), ..Default::default() }; + let script = "var was = app.runtimeHighlight; if (!app.runtimeHighlight) { app.runtimeHighlight = true; } was + ',' + app.runtimeHighlight"; + let o = run_xfa(script, &ev, &XfaDoc::default(), &root, Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("false,true")); + assert_eq!(o.effects, vec![XfaEffect::Highlight(true)]); + let o = run_xfa(script, &ev, &XfaDoc { highlight: true, ..Default::default() }, &root, Limits::default()); + assert_eq!((o.result.as_deref(), o.effects.len()), (Some("true,true"), 0), "already on: nothing to ask for"); + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..53fd7582f0 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -84,6 +84,8 @@ pub struct XfaDoc { /// The current page, 0-based. pub page: usize, pub page_count: usize, + /// Whether the viewer highlights form fields (`app.runtimeHighlight`). + pub highlight: bool, } /// Something a script changed or asked for. @@ -121,6 +123,8 @@ pub enum XfaEffect { Recalculate, /// `xfa.form.remerge()`, `xfa.layout.relayout()`. Relayout, + /// `app.runtimeHighlight = on`. + Highlight(bool), } /// What running a script did. @@ -1000,6 +1004,22 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `app.runtimeHighlight`: whether the viewer highlights form fields. +fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) +} + +/// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. +fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + let mut h = host(ctx)?.borrow_mut(); + if h.doc.highlight != on { + h.doc.highlight = on; + h.push(XfaEffect::Highlight(on)); + } + Ok(JsValue::undefined()) +} + fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); effect(ctx, XfaEffect::MessageBox(m))?; @@ -1180,7 +1200,10 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> ctx.register_global_property(js_string!("xfa"), xfa, rw)?; ctx.register_global_property(js_string!("event"), ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); + let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); let app = ObjectInitializer::new(ctx) + .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index df461fc6e7..669077f387 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -67,6 +67,14 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + // As the Highlight fields button does: this document and the next ones. + Request::Highlight(on) => { + if let Some(i) = view { + self.views[i].highlight_fields = on; + } + self.view_defaults.highlight_fields = on; + self.session.set_field_highlight(on); + } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db9247591..55935ea092 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1166,6 +1166,7 @@ impl PdfCraftApp { if let Some(ctx) = &self.ctx { worker.set_context(ctx); } + self.session.set_field_highlight(self.view_defaults.highlight_fields); let receiver = worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; self.next_open_token = token; @@ -1283,6 +1284,8 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; + // A new document opens with the remembered Highlight fields, which its scripts read. + self.session.set_field_highlight(self.view_defaults.highlight_fields); let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { @@ -3128,6 +3131,9 @@ impl eframe::App for PdfCraftApp { for view in &mut self.views { view.begin_render_frame(); } + // Forms' scripts read Highlight fields as `app.runtimeHighlight`. + let highlight = self.active.and_then(|i| self.views.get(i)).map_or(self.view_defaults.highlight_fields, |v| v.highlight_fields); + self.session.set_field_highlight(highlight); // The window shows the active document's name (or title, if it asks for that). let title = self .active diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 6565a9816f..45a609b5ec 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -231,3 +231,15 @@ fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() { h.get_by_label("Highlight fields"); h.get_by_label("Security settings"); } + +/// A form that turns field highlighting on (`app.runtimeHighlight = true` in a `layout:ready` +/// script) gets it, and so does the next document, as with the Highlight fields button. +#[test] +fn a_form_that_turns_field_highlighting_on_gets_it() { + let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); + h.run_steps(4); + assert!(h.state().views[0].highlight_fields, "the form's fields are highlighted"); + h.state_mut().open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap(); + h.run_steps(4); + assert!(h.state().views[1].highlight_fields, "the next document opens highlighted"); +} diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc216..191f5c6afe 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -118,6 +118,28 @@ fn assemble(objs: Vec>) -> Vec { /// (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. +/// A form with `layout:ready` scripts: the root subform turns field highlighting on (as forms +/// do with `app.runtimeHighlight`), `stamp` takes a value, and `pageNo` numbers its page (left to +/// the layout). `note` has a `form:ready` script, a different event. +pub fn layout_ready_template() -> String { + r##" + + + +"## + .to_string() +} + pub fn scripted_template() -> String { r##" diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..8a2a79e2fc 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -97,7 +97,7 @@ pub use data::{ }; 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 parse::{measure, numbers_pages, 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, fields_by_som, form_tree, diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 4582d59b22..ea72eb0919 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -489,6 +489,9 @@ fn parse_events(n: XmlNode) -> Vec + From 257c843eed0fffe5599df411649eab523dd799bd Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:39:31 -0400 Subject: [PATCH 004/140] Forms: doc comments for the functions this change touches --- crates/automation/src/a11y.rs | 1 + crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests.rs | 3 +++ crates/js/src/lib.rs | 2 ++ crates/js/src/tests.rs | 9 +++++++++ crates/js/src/xfa.rs | 2 ++ crates/ui-egui/src/lib.rs | 7 +++++++ crates/xfa/src/tests.rs | 2 ++ 8 files changed, 30 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a7c05e14e9..39253bbf54 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -646,6 +646,7 @@ impl Automation { } } +/// A script's request as `js_run` reports it: one small object per kind of request. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dbd35e55df..e9de677192 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2265,6 +2265,8 @@ struct EditCtx { } impl EditCtx { + /// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the + /// ids it makes. fn new(now: Option, salt: u64) -> Self { let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; if let Some(t) = now { @@ -3375,6 +3377,8 @@ impl Session { Ok(self.commit_open(prepared)) } + /// What [`OpenContext::prepare_open`] runs inside its panic guard: read, decrypt and lay out + /// the document, and run an XFA form's opening scripts. fn prepare_open_unguarded( context: &OpenContext, name: String, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3a34b00d56..8ea120b4b6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6107,6 +6107,9 @@ fn undo_never_makes_an_unsaved_document_clean() { assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } +/// layout:ready scripts run once on opening: the root's turns field highlighting on (only when it +/// is off), a field's sets a value and a later script reads the new highlight, while page +/// numbering is left to the layout and form:ready is another event. #[test] fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template())); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 9ace0cbc4f..3105321b15 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -911,6 +911,8 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// The AcroForm object model as globals: the document's methods and properties (`getField`, +/// `numPages`, `info`, …), `app`, `util`, `console`, `display` and `color`. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 43628137c6..1bd0be0828 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -15,6 +15,7 @@ fn fields() -> Vec { ] } +/// The document these tests' scripts run in. fn doc() -> DocInfo { DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false } } @@ -380,6 +381,7 @@ mod xfa_model { } } + /// Run JavaScript `script` as `target`'s `activity` script on [`form`]. 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, ..Default::default() }; @@ -594,6 +596,7 @@ mod formcalc { use crate::formcalc::run_formcalc; use crate::xfa::*; + /// Run FormCalc `script` as `target`'s `activity` script on [`form`]. 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, ..Default::default() }; @@ -1110,6 +1113,8 @@ mod session { assert_eq!(s.dead(), None); } + /// A JavaScript script past its time is abandoned within the deadline, and the session runs + /// nothing after it; a FormCalc one stops itself or is abandoned too. #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); @@ -1137,6 +1142,8 @@ mod runtime_highlight { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; + /// `app.runtimeHighlight` in AcroForm scripts reads the viewer's setting, and setting it asks + /// for the change once and reads back the new value. #[test] fn app_runtime_highlight_reads_the_viewers_setting_and_asks_to_change_it() { let ev = Event::field("Calculate", "total", ""); @@ -1149,6 +1156,8 @@ mod runtime_highlight { assert_eq!(o.requests, vec![Request::Highlight(true)]); } + /// The same in XFA scripts, where the change is an effect; nothing is asked for when the + /// setting is already what the script wants. #[test] fn xfa_app_runtime_highlight_reads_the_setting_and_records_a_change() { let ev = XfaEvent { activity: "layoutReady".into(), target: "form1[0]".into(), ..Default::default() }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 53fd7582f0..f91a33c969 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1118,6 +1118,8 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { resolver(ctx, current, many) } +/// The XFA object model as globals for one script: `xfa` (form, host, layout, event), `event` +/// (the same object as `xfa.event`), `this` as node `current`, `app`, `console` and `util`. fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3c8dfabd0..630841ff5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1150,6 +1150,8 @@ impl PdfCraftApp { self.try_open(name, path, std::sync::Arc::new(bytes), None, activate) } + /// Hand `request` to the background open worker (started on first use); the result arrives + /// later as a pending open, under the returned token. #[cfg(not(target_arch = "wasm32"))] fn schedule_open(&mut self, request: OpenRequest) -> Result { if self.open_worker.is_none() { @@ -1275,6 +1277,8 @@ impl PdfCraftApp { } } + /// Open `bytes` on this thread: ask for a password if the document needs one, else add it + /// as a tab (in front when `activate`). fn try_open( &mut self, name: &str, @@ -1312,6 +1316,8 @@ impl PdfCraftApp { self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) } + /// Add a prepared document as a tab: its view, the recent-files entry, and what its scripts + /// said while it opened (at once in front, or when a background tab is first selected). fn finish_open( &mut self, name: &str, @@ -3123,6 +3129,7 @@ impl eframe::App for PdfCraftApp { self.process_picked(); } + /// One frame of the whole window. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.language)); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d8695a17c1..c51438db16 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2068,6 +2068,8 @@ fn som_indices_count_earlier_same_named_siblings() { assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); } +/// `ready` is two events: `ref="$layout"` is layout:ready (`layoutReady`), anything else stays +/// form:ready (`ready`), in the template and in the live form; page numbering is recognized. #[test] fn a_layout_ready_event_is_told_apart_from_form_ready() { let (tpl, _) = parse(&crate::fixtures::layout_ready_template()).unwrap(); From d9d0762b3fb20dce81cfdd2e617d3e7e84420e4b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:44:39 -0400 Subject: [PATCH 005/140] Forms: a later field script reads the highlight an earlier one set --- crates/engine/src/js.rs | 8 ++++++++ crates/engine/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a3ba953e3e..3824df47e3 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -141,6 +141,13 @@ impl Scripts for JsRunner { let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default()); self.output.absorb(&o); + // The edit's later scripts read the field highlight this one set. + if let Some(on) = o.requests.iter().rev().find_map(|r| match r { + Request::Highlight(on) => Some(*on), + _ => None, + }) { + self.doc.highlight = on; + } if o.error.is_some() { // As in Acrobat, a failing script leaves the value alone (the error goes to the console). return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; @@ -178,6 +185,7 @@ impl Session { self.js_off = !on; } + /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). pub fn javascript(&self) -> bool { !self.js_off } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8ea120b4b6..d11b43d4a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6127,3 +6127,40 @@ fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { let id = s.open("ready.pdf", None, bytes, None).expect("opens"); assert!(s.take_js_output(id).requests.is_empty()); } + +/// Field scripts of one edit read the field highlight an earlier one set: `on`'s calculation turns +/// it on, and `seen`'s, which runs after it, records what it reads. +#[test] +fn a_later_field_script_reads_the_highlight_an_earlier_one_set() { + let text = |name: &str, y: u32, aa: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /CO [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".to_string(), + text("input", 250, ""), + text("on", 220, "/AA << /C << /S /JavaScript /JS (app.runtimeHighlight = true; event.value = 'set';) >> >>"), + text("seen", 190, "/AA << /C << /S /JavaScript /JS (event.value = String\\(app.runtimeHighlight\\);) >> >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("seen.pdf", None, Arc::new(out), None).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "input".into(), value: FieldValue::Text("1".into()) }).unwrap(); + assert_eq!(value(&s, "on"), ["set"]); + assert_eq!(value(&s, "seen"), ["true"], "the later calculation reads the new setting"); + assert!(s.take_js_output(id).requests.contains(&js::Request::Highlight(true))); +} From bab75395e8cebfaefa67596eb3ff1b52d64ed66d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:46:19 -0400 Subject: [PATCH 006/140] Forms: scripted_template keeps its doc comment --- crates/xfa/src/fixtures.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 0f8cea53f6..88006e6a48 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -114,10 +114,6 @@ fn assemble(objs: Vec>) -> Vec { 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. /// A form with `layout:ready` scripts: the root subform turns field highlighting on (as forms /// do with `app.runtimeHighlight`), `stamp` takes a value, `seen` records the highlight its script /// reads after the root's, and `pageNo` numbers its page (left to the layout). `note` has a @@ -142,6 +138,10 @@ pub fn layout_ready_template() -> String { .to_string() } +/// 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##" From 18d0a40233745de106f8e3b8805eba18c7be3df8 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:56:54 -0400 Subject: [PATCH 007/140] Forms: a button script's highlight change reaches the calculations it sets off --- crates/engine/src/js.rs | 19 ++++++++-- crates/engine/src/tests.rs | 77 +++++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 3824df47e3..371dc84372 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -124,6 +124,14 @@ impl JsRunner { } } +/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). +fn last_highlight(requests: &[Request]) -> Option { + requests.iter().rev().find_map(|r| match r { + Request::Highlight(on) => Some(*on), + _ => None, + }) +} + /// The Info dictionary as `this.info` keys (lower-case). fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() }; @@ -136,16 +144,15 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + /// Run a field's `event` script on `target` with the fields as they are, keeping what it + /// printed or asked for; a failing script leaves the value as it was. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect(); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default()); self.output.absorb(&o); // The edit's later scripts read the field highlight this one set. - if let Some(on) = o.requests.iter().rev().find_map(|r| match r { - Request::Highlight(on) => Some(*on), - _ => None, - }) { + if let Some(on) = last_highlight(&o.requests) { self.doc.highlight = on; } if o.error.is_some() { @@ -261,6 +268,10 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // The calculations the script's changes set off read the field highlight it asked for. + if let Some(on) = last_highlight(&o.requests) { + self.field_highlight = on; + } let mut edits = Vec::new(); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d11b43d4a3..36be66dc03 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6132,35 +6132,58 @@ fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { /// it on, and `seen`'s, which runs after it, records what it reads. #[test] fn a_later_field_script_reads_the_highlight_an_earlier_one_set() { - let text = |name: &str, y: u32, aa: &str| { - format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) + // Fields `input` (no script), then those named in `calculated` with their calculate scripts, + // calculated in that order. + let form = |calculated: &[(&str, &str)]| { + let text = |name: &str, y: usize, aa: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) + }; + let n = calculated.len(); + let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut objs = vec![ + format!( + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R {}] /CO [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv {} 0 R >> >> >> >>", + refs(5), + refs(5), + 5 + n + ), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R {}] >>", refs(5)), + text("input", 250, ""), + ]; + for (i, (name, js)) in calculated.iter().enumerate() { + objs.push(text(name, 220 - 30 * i, &format!("/AA << /C << /S /JavaScript /JS ({js}) >> >>"))); + } + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Arc::new(out) }; - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /CO [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".to_string(), - text("input", 250, ""), - text("on", 220, "/AA << /C << /S /JavaScript /JS (app.runtimeHighlight = true; event.value = 'set';) >> >>"), - text("seen", 190, "/AA << /C << /S /JavaScript /JS (event.value = String\\(app.runtimeHighlight\\);) >> >>"), - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let seen = ("seen", "event.value = String\\(app.runtimeHighlight\\);"); + let value = |s: &Session, id: DocId, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + // `on`'s calculation turns it on, and `seen`'s, which runs after it, records what it reads. let mut s = Session::new(); - let id = s.open("seen.pdf", None, Arc::new(out), None).unwrap(); - let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + let id = s.open("seen.pdf", None, form(&[("on", "app.runtimeHighlight = true; event.value = 'set';"), seen]), None).unwrap(); s.apply(id, Edit::SetFieldValue { name: "input".into(), value: FieldValue::Text("1".into()) }).unwrap(); - assert_eq!(value(&s, "on"), ["set"]); - assert_eq!(value(&s, "seen"), ["true"], "the later calculation reads the new setting"); + assert_eq!(value(&s, id, "on"), ["set"]); + assert_eq!(value(&s, id, "seen"), ["true"], "the later calculation reads the new setting"); assert!(s.take_js_output(id).requests.contains(&js::Request::Highlight(true))); + // A button's script that turns it off and changes a field: the calculations it sets off read + // the new setting too. + let mut s = Session::new(); + s.set_field_highlight(true); + let id = s.open("seen.pdf", None, form(&[seen]), None).unwrap(); + let o = s.run_javascript(id, "app.runtimeHighlight = false; getField('input').value = '2';", Some("input")).unwrap(); + assert_eq!(o.requests, [js::Request::Highlight(false)]); + assert_eq!(value(&s, id, "seen"), ["false"], "the button's own change reaches the calculations"); } From 1260c91dd276af944b534f499560707be4a7bc22 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:09:00 -0400 Subject: [PATCH 008/140] Forms: every layout:ready script runs, and page-number fields keep the layout's numbers --- crates/engine/src/xfa.rs | 36 ++++++++++++++++++++++++++++++------ crates/xfa/src/fixtures.rs | 6 +++--- crates/xfa/src/parse.rs | 4 ++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index a1c4416408..264df766af 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -5,7 +5,7 @@ //! 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::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode}; @@ -154,6 +154,14 @@ fn to_js(n: &FormNode) -> XfaNode { } } +/// Whether the live form has a field at `som`. +fn is_field(n: &FormNode, som: &str, depth: usize) -> bool { + if depth > 64 { + return false; + } + (n.som == som && n.kind == Some(NodeKind::Field)) || n.children.iter().any(|c| is_field(c, som, depth + 1)) +} + fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> { if depth > 64 { return None; @@ -270,9 +278,14 @@ struct Runner<'a, 'b> { /// rebuilt when the structure changed). session: Option, changes: Changes, + /// Fields the layout numbers (a field whose own layout:ready script puts a page number in + /// it): while layout:ready scripts run, a value written to one is dropped, so each page keeps + /// the number the layout gave it. + numbered: HashSet, } impl<'a, 'b> Runner<'a, 'b> { + /// A runner for one event on `doc` and its template, its scripts sharing `budget_ms`. 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); @@ -298,6 +311,7 @@ impl<'a, 'b> Runner<'a, 'b> { cached: None, session: None, changes: Changes::default(), + numbered: HashSet::new(), } } @@ -587,7 +601,9 @@ impl<'a, 'b> Runner<'a, 'b> { } match e { XfaEffect::SetValue { som, value } => { - self.set_value(som, value); + if !self.numbered.contains(som) { + self.set_value(som, value); + } } XfaEffect::SetPresence { som, presence } => { if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) { @@ -777,19 +793,27 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, /// calculations. Returns what they changed. /// /// layout:ready runs once here, where forms set themselves up (turning field highlighting on, -/// say); a viewer runs it after every layout. Scripts that number pages are left out: the -/// layout gives each page its number itself. +/// say); a viewer runs it after every layout. Every layout:ready script runs, but a field that +/// numbers pages keeps the number the layout gave each page: the value a script writes to it is +/// dropped (one value would put the same number on every page). pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect(); for ev in &events { r.run(ev, "")?; } - let ready: Vec = - r.live().events.iter().filter(|e| e.activity == "layoutReady" && !pdfcraft_xfa::numbers_pages(&e.script)).cloned().collect(); + let (ready, numbered) = { + let live = r.live(); + let ready: Vec = live.events.iter().filter(|e| e.activity == "layoutReady").cloned().collect(); + let numbered: HashSet = + ready.iter().filter(|e| pdfcraft_xfa::numbers_pages(&e.script) && is_field(&live.root, &e.som, 0)).map(|e| e.som.clone()).collect(); + (ready, numbered) + }; + r.numbered = numbered; for ev in &ready { r.run(ev, "")?; } + r.numbered.clear(); r.calculates()?; r.finish() } diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 88006e6a48..4723ffa85a 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -114,8 +114,8 @@ fn assemble(objs: Vec>) -> Vec { out } -/// A form with `layout:ready` scripts: the root subform turns field highlighting on (as forms -/// do with `app.runtimeHighlight`), `stamp` takes a value, `seen` records the highlight its script +/// A form with `layout:ready` scripts: the root subform reads the page count and turns field +/// highlighting on (as forms do with `app.runtimeHighlight`), `stamp` takes a value, `seen` records the highlight its script /// reads after the root's, and `pageNo` numbers its page (left to the layout). `note` has a /// `form:ready` script, a different event. pub fn layout_ready_template() -> String { @@ -123,7 +123,7 @@ pub fn layout_ready_template() -> String { From 7e00c61c7fffda354f44f67832d50d57964d4dcb Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:20:30 -0400 Subject: [PATCH 019/140] M12: the editor follows each focus script in turn, so an exit then an enter that both change the field leave it on the last value --- crates/ui-egui/src/editing.rs | 8 +++++--- crates/ui-egui/tests/it/xfa.rs | 12 +++++++++++- crates/xfa/src/fixtures.rs | 4 +++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 34c9fac2f3..5266bebfbb 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -485,11 +485,13 @@ impl PdfCraftApp { } let left = std::mem::replace(&mut view.forms.focused, now.clone()); let id = view.id; - // The edited field's value before the scripts, to tell its untouched draft from typing. - let before: Option> = - now.as_ref().and_then(|(name, _)| self.session.get(id)?.form.iter().find(|f| &f.name == name).map(|f| f.value.clone())); + let edited = now.as_ref().map(|(name, _)| name.clone()); 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() { + // The edited field's value before this script, to tell its untouched draft from typing: + // the exit of the field left may have changed it already, and the draft with it. + let before: Option> = + edited.as_ref().and_then(|e| self.session.get(id)?.form.iter().find(|f| &f.name == e).map(|f| f.value.clone())); match self.session.field_focus(id, &name, widget, focus) { Ok(changed) => { if changed diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index e98d3df14e..120fe2cc2e 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -190,7 +190,8 @@ fn xfa_enter_and_exit_scripts_run_whenever_the_focus_moves() { /// An enter script that changes the field it enters (clearing a "Type here") shows in the editor, /// so leaving the field keeps the script's value rather than writing the old one back; typing -/// after it is kept as usual. +/// after it is kept as usual. When the exit of the field left changed it first (`relay` puts the +/// "Type here" back), the editor follows both scripts and ends on the enter script's value. #[test] fn an_enter_script_that_changes_its_field_shows_in_the_editor() { use egui::{Key, Modifiers}; @@ -213,6 +214,15 @@ fn an_enter_script_that_changes_its_field_shows_in_the_editor() { h.key_press_modifiers(Modifiers::NONE, Key::Tab); h.run_steps(4); assert_eq!(value(&h), "Ann"); + // From `relay` back to `hint`: relay's exit puts "Type here" back over "Ann", then hint's enter + // clears it. The editor follows both, so it holds the last value, and Tab keeps it. + click_field(&mut h, "relay"); + click_field(&mut h, "hint"); + assert_eq!(value(&h), "", "relay's exit, then hint's enter"); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "the editor holds the enter script's value"); + h.key_press_modifiers(Modifiers::NONE, Key::Tab); + h.run_steps(4); + assert_eq!(value(&h), ""); } #[test] diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 885ef9831c..4b116a9aa3 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -662,7 +662,8 @@ if (this.rawValue == "1") { /// 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), and whose text field `hint` clears its "Type here" when it gains focus. +/// with scripts of its own), whose text field `hint` clears its "Type here" when it gains focus, and +/// whose text field `relay` puts that "Type here" back in `hint` when it loses focus. pub fn focus_template() -> String { let js = "application/x-javascript"; let count = |name: &str| format!(r#""#); @@ -682,6 +683,7 @@ pub fn focus_template() -> String { {}{}{}{} Type here + From 2e8720c3323993b8b85316d6a07785be9cadbb26 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:36:15 -0400 Subject: [PATCH 020/140] M12: an exit skipped after a value change is skipped only until the next edit, undo or redo --- crates/engine/src/lib.rs | 7 +++++++ crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 915a6a2c2c..d1bea65d92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4012,6 +4012,9 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + // Only this edit's exits are skipped on the next focus loss: one an earlier change ran is + // no reason to skip a later one. + doc.xfa_exited.clear(); 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(); @@ -4059,6 +4062,8 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + // The change whose exit ran is undone (or redone without its scripts): skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; Ok(label) } @@ -4088,6 +4093,8 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + // The change whose exit ran is undone (or redone without its scripts): skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; Ok(label) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d928e429ce..346c7dfb08 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4719,6 +4719,28 @@ fn an_exit_the_fields_change_ran_does_not_run_twice() { assert_eq!(field_text(&s, id, "exits"), "2", "the next visit's exit runs"); } +/// The skip lasts only until the next edit, undo or redo: a change that is undone, or followed by +/// another edit, doesn't make a later focus loss skip the field's exit. +#[test] +fn an_exit_skip_does_not_outlive_its_change() { + 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(); + s.undo(id).unwrap(); + let before = field_text(&s, id, "exits"); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after an undo, the exit runs"); + // A change, then an edit of another field: the change's skip is gone. + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("y".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }).unwrap(); + let before = field_text(&s, id, "exits"); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after another edit, the 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] From 3257bac8e0ca09002ea2297812d15cb1b46d02bf Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:52:53 -0400 Subject: [PATCH 021/140] M12: a batched focus event that changes nothing keeps the batch's edits, and an exit is noted as run only when it ran --- crates/engine/src/lib.rs | 7 ++++-- crates/engine/src/tests.rs | 44 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/xfa.rs | 15 ++++++++----- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d1bea65d92..df7ba3e90d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3866,8 +3866,9 @@ impl Session { 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 { + // nothing unsaved); what they showed or asked for still goes out. Only an event on its own: + // a batch keeps the other edits it holds. + if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) { doc.js_output.append(cx.xfa_out); if cx.xfa_ran_away { doc.xfa_template = None; @@ -4037,6 +4038,7 @@ impl Session { Ok(()) } + /// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label. pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; @@ -4068,6 +4070,7 @@ impl Session { Ok(label) } + /// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label. pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 346c7dfb08..6a9574ed5b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4741,6 +4741,50 @@ fn an_exit_skip_does_not_outlive_its_change() { assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after another edit, the exit runs"); } +/// A focus event that changes nothing, batched with a form edit, doesn't cost the batch its edit. +#[test] +fn a_batch_keeps_its_edits_when_its_focus_event_changes_nothing() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let som = { + let doc = s.get(id).unwrap(); + let quiet = doc.form.iter().find(|f| f.name == "quiet").unwrap(); + crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, quiet, 0).unwrap() + }; + let batch = Edit::Batch { + label: "Fill and leave".into(), + edits: vec![ + Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }, + Edit::XfaEvent { som, activity: "exit".into() }, + ], + }; + s.apply(id, batch).unwrap(); + assert_eq!(field_text(&s, id, "quiet"), "z", "the value is kept"); + assert!(s.get(id).unwrap().dirty); +} + +/// A field's exit is noted as run (for the focus loss to skip) only when an exit script ran: +/// with every script skipped, as when the event's budget is spent or a script ran away, nothing +/// is noted and the next focus loss runs the exit. +#[test] +fn on_changes_notes_only_the_exits_that_ran() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let tpl = doc.xfa_template.clone().unwrap(); + let names = vec!["probe".to_string()]; + let mut exited = Vec::new(); + let mut out = crate::js::JsOutput::default(); + let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut stopped, &mut exited).unwrap(); + assert!(exited.is_empty(), "no exit ran: {exited:?}"); + let mut out = crate::js::JsOutput::default(); + let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut running, &mut exited).unwrap(); + assert_eq!(exited.len(), 1, "the exit ran: {exited:?}"); +} + /// 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] diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 8743ac94ed..b2e1abc25c 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -839,13 +839,18 @@ pub(crate) fn on_changes( 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(); - 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) { + for ev in r.events_of(&som, "change") { r.run(&ev, &value)?; } + // Noted only when an exit script ran: one the event's budget skipped, or one an error + // before it stopped, is still to run when the field loses focus. + let mut ran_exit = false; + for ev in r.events_of(&som, "exit") { + ran_exit |= r.run(&ev, &value)?.is_some(); + } + if ran_exit { + exited.push(som.clone()); + } 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() { From e3a4ef896e64d983cc6f28d61de6c7a687256076 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:56:17 -0400 Subject: [PATCH 022/140] M12: docstrings for the functions next to the focus-script change --- crates/automation/src/a11y.rs | 4 ++++ crates/automation/tests/it/automation.rs | 4 ++++ crates/engine/src/tests.rs | 4 ++++ crates/ui-egui/src/forms_ui.rs | 1 + crates/ui-egui/tests/it/xfa.rs | 4 ++++ crates/xfa/src/script.rs | 2 ++ 6 files changed, 19 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a0598262ad..276010c612 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -677,6 +677,8 @@ 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 { 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; @@ -724,6 +726,8 @@ impl Automation { 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 { let mut files = Vec::new(); for p in a.strs("paths")? { diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a2c0c216c5..ef8ff612a7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6777,6 +6777,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"); @@ -6846,6 +6848,8 @@ fn xfa_focus_scripts_run_through_form_focus() { } } +/// 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"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a9574ed5b..5aff0fe936 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4640,6 +4640,8 @@ fn guard_turns_a_panic_into_an_error() { assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string())); } +/// Measurement edits are single undoable steps that leave the file unchanged when they fail, and +/// respect the document's annotation permissions. #[test] fn measurement_edits_are_transactional_and_honour_annotation_permissions() { let mut session = Session::default(); @@ -4823,6 +4825,8 @@ fn a_radio_buttons_own_focus_scripts_run() { assert_eq!(field_text(&s, id, "drops"), "1"); } +/// A dynamic XFA form is laid out from its template on opening (an appended revision, not an +/// edit), takes values from the datasets, and saves its filled values incrementally. #[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/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 1e171431cc..68fb8b7876 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -93,6 +93,7 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// The editor's draft for widget `widget` of field `f`, opened on the field's value. fn open_focus(f: &FormField, widget: usize) -> Focus { Focus { name: f.name.clone(), diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index 120fe2cc2e..98dce60cd8 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -130,6 +130,8 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { h.run_steps(4); } +/// Clicking an XFA button in the app runs its click script: one adds a row, another shows its +/// message as a notice. #[test] fn xfa_buttons_run_their_scripts_in_the_app() { let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -225,6 +227,8 @@ fn an_enter_script_that_changes_its_field_shows_in_the_editor() { assert_eq!(value(&h), ""); } +/// A message an initialize script shows on opening appears at once, and a print request from it +/// is refused (only a click may print). #[test] fn messages_from_scripts_run_on_open_show_right_away() { let tpl = pdfcraft_xfa::fixtures::scripted_template() diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 3ae38e4d6e..74ec8652fd 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -440,9 +440,11 @@ pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> Liv /// 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 { + /// Is any of `s` a non-blank script? fn any(s: &[Script]) -> bool { s.iter().any(|s| !s.text.trim().is_empty()) } + /// Has field `f` an event, calculate or validate script? fn field(f: &Field) -> bool { any(&f.scripts) || [&f.calculate, &f.validate].into_iter().flatten().any(|s| !s.text.trim().is_empty()) } From 262013a9e0af52fda0585ea159bbe478a5d2d749 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:10:12 -0400 Subject: [PATCH 023/140] M12: a form script event can't be part of a batch of edits --- crates/engine/src/lib.rs | 5 +++++ crates/engine/src/tests.rs | 31 ++++++++++++++++++------------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df7ba3e90d..84f0868a10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2893,6 +2893,11 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> doc.remove_encryption(); } Edit::Batch { edits, .. } => { + // A form-script event runs on its own: in a batch, its scripts and those the batch's + // value edits run afterwards (change, exit) would interleave, an exit running twice. + if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) { + return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into())); + } for e in edits { run_edit(doc, e, cx)?; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5aff0fe936..a8a7364d8a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4743,26 +4743,31 @@ fn an_exit_skip_does_not_outlive_its_change() { assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after another edit, the exit runs"); } -/// A focus event that changes nothing, batched with a form edit, doesn't cost the batch its edit. +/// A form-script event can't be batched with other edits: its scripts and those the batch's value +/// edits run would interleave (the field's exit running twice). The batch is refused whole, and +/// the document is left as it was. #[test] -fn a_batch_keeps_its_edits_when_its_focus_event_changes_nothing() { +fn a_form_script_event_in_a_batch_is_refused() { let mut s = Session::new(); let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); - let som = { + let som_of = |s: &Session, name: &str| { let doc = s.get(id).unwrap(); - let quiet = doc.form.iter().find(|f| f.name == "quiet").unwrap(); - crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, quiet, 0).unwrap() + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, f, 0).unwrap() }; - let batch = Edit::Batch { + let batch = |name: &str, som: String| Edit::Batch { label: "Fill and leave".into(), - edits: vec![ - Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }, - Edit::XfaEvent { som, activity: "exit".into() }, - ], + edits: vec![Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("z".into()) }, Edit::XfaEvent { som, activity: "exit".into() }], }; - s.apply(id, batch).unwrap(); - assert_eq!(field_text(&s, id, "quiet"), "z", "the value is kept"); - assert!(s.get(id).unwrap().dirty); + // An exit that counts: it would run once for the event and once for the change. + let probe = som_of(&s, "probe"); + assert!(matches!(s.apply(id, batch("probe", probe)), Err(EditError::Invalid(_)))); + assert_eq!((field_text(&s, id, "probe"), field_text(&s, id, "exits")), (String::new(), "0".into())); + // An exit that changes nothing: the batch's value isn't kept either, nor dropped silently. + let quiet = som_of(&s, "quiet"); + assert!(s.apply(id, batch("quiet", quiet)).is_err()); + assert_eq!(field_text(&s, id, "quiet"), ""); + assert!(!s.get(id).unwrap().dirty, "nothing was applied"); } /// A field's exit is noted as run (for the focus loss to skip) only when an exit script ran: From a7d51786dab2d2f2020f550d7a74249411ac0cbc Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:59:54 -0400 Subject: [PATCH 024/140] M12: links in screen-only and print-only XFA text work only where the text shows, and their pieces still join --- crates/xfa/src/pdf.rs | 18 +++++++++++++++- crates/xfa/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 889b6f5653..ba33736942 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -748,12 +748,14 @@ impl Emitter<'_> { content.extend(format!("/OC /{resource} BDC\n").bytes()); } open = group; + // A link's pieces join only within one group: what is in another shows elsewhere. + last_link = None; } // A link's pieces join only across text and the underline drawn just before it: a fill, // picture, field or any other line (a rule) between them ends the link there. let keeps_link = match item { Item::Text(_) => true, - Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + Item::Line { from, to, .. } => matches!(page.items.get(i + 1).map(bare), Some(Item::Text(s)) if (from.1 - to.1).abs() < 0.01 && (from.0 - s.x).abs() < 0.01 && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), @@ -809,6 +811,12 @@ impl Emitter<'_> { content.extend(literal(&win_ansi(&s.text))); content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? { + // A link works only where its text shows: one in print-only text is + // NoView (32), so it can't be clicked on screen, and one in screen-only + // text doesn't print, as a field's widget there. + if let Some(g) = group { + self.doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(if g == 0 { 0 } else { 4 | 32 })))?; + } annots.push(Object::Ref(r)); } } @@ -921,6 +929,14 @@ impl Emitter<'_> { } } +/// `item`, or what it holds when it is shown only on screen or only in print ([`Item::Only`]). +fn bare(item: &Item) -> &Item { + match item { + Item::Only { item, .. } => item, + other => other, + } +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f0ba0e467a..e85146e1b0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -870,6 +870,48 @@ fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}"); } +/// A link in text shown only on screen or only in print works only where its text shows: in +/// print-only text it is NoView (`/F 36`), so it can't be clicked on screen over nothing, and in +/// screen-only text it doesn't print (`/F 0`). Its pieces still join into one link across a change +/// of style and the underline drawn with them. +#[test] +fn links_in_screen_only_and_print_only_text_work_where_their_text_shows() { + let draw = |name: &str, attrs: &str| { + format!( + r#"

forms@example.com

"# + ) + }; + let xdp = format!( + r##" +"##, + draw("everywhere", ""), + draw("screen", r#"relevant="-print""#), + draw("paper", r#"presence="invisible" relevant="+print""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + // Each link annotation's address and flags. + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone(); + let page = doc.resolve(kids.first().unwrap()); + let annots = doc.resolve(page.as_dict().unwrap().get(b"Annots").unwrap()).as_array().unwrap().clone(); + let links: Vec<(String, i64)> = annots + .iter() + .map(|a| doc.resolve(a).as_dict().unwrap().clone()) + .filter(|d| d.name(b"Subtype") == Some(&b"Link"[..])) + .map(|d| { + let action = doc.resolve(d.get(b"A").unwrap()).as_dict().unwrap().clone(); + (String::from_utf8_lossy(&action.get(b"URI").and_then(Object::as_string).unwrap().bytes).into_owned(), d.int(b"F").unwrap()) + }) + .collect(); + let expected = [("https://example.com/everywhere", 4), ("https://example.com/screen", 0), ("https://example.com/paper", 36)]; + assert_eq!(links, expected.map(|(u, f)| (u.to_string(), f)), "one link each, with flags where its text shows"); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 44ba9595f9..9765515e77 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5284,7 +5284,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] 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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::relevant_and_presence_say_where_fields_and_draws_show", "crates/xfa/src/tests.rs::screen_only_and_print_only_objects_are_written_with_flags_and_layers", "crates/xfa/src/tests.rs::a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere", "crates/engine/src/tests.rs::xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says"] +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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::relevant_and_presence_say_where_fields_and_draws_show", "crates/xfa/src/tests.rs::screen_only_and_print_only_objects_are_written_with_flags_and_layers", "crates/xfa/src/tests.rs::a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere", "crates/engine/src/tests.rs::xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says", "crates/xfa/src/tests.rs::links_in_screen_only_and_print_only_text_work_where_their_text_shows"] notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] From 0e3835d2d16f595571afbc199c97fd3c9ff8a855 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:00:49 -0400 Subject: [PATCH 025/140] M12: one summary for flow_subform's docstring after the merge --- crates/xfa/src/layout.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index c7251f50da..e5b15e900b 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1038,9 +1038,9 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── flow - /// Flow `node` (a subform; anything else is ignored) into the content areas. - /// Flow subform `node` (each instance the data asks for) into the content areas, shown where - /// it and what it is inside say ([`Common::views`]); hidden from both, it still takes its room. + /// Flow subform `node` (each instance the data asks for; anything else is ignored) into the + /// content areas, shown where it and what it is inside say ([`Common::views`]); hidden from + /// both, it still takes its room. fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { self.warn_other_views(node.common()); let views = self.views.and(node.common().views()); From a90f764a8e64af48cbcbbf5bbfb2a5fd85e5d219 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:21:03 -0400 Subject: [PATCH 026/140] M6: app.runtimeHighlight asks once, for the setting a script ends with, under the request cap --- crates/js/src/lib.rs | 5 ++++- crates/js/src/tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 829143681e..fa56d1b177 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -690,7 +690,10 @@ fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js let mut h = host(ctx)?.borrow_mut(); if h.doc.highlight != on { h.doc.highlight = on; - h.requests.push(Request::Highlight(on)); + // Only the setting the script ends with matters: one request, the latest, under the + // host's request cap like any other. + h.requests.retain(|r| !matches!(r, Request::Highlight(_))); + h.request(Request::Highlight(on)); } Ok(JsValue::undefined()) } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 00450256ad..d29899125c 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1560,6 +1560,10 @@ mod runtime_highlight { assert_eq!(o.error, None); assert_eq!(o.value, "false,true", "the script reads back what it set"); assert_eq!(o.requests, vec![Request::Highlight(true)]); + // A script that toggles it over and over asks once, for where it ended. + let o = go("for (var i = 0; i < 5001; i++) { app.runtimeHighlight = !app.runtimeHighlight; }", &ev); + assert_eq!(o.error, None); + assert_eq!(o.requests, vec![Request::Highlight(true)], "one request, the last setting"); } /// The same in XFA scripts, where the change is an effect; nothing is asked for when the From 99e1c67d49b186804e0f94b49e4f83142676a889 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:27:31 -0400 Subject: [PATCH 027/140] M12: run an XFA signature field's preSign scripts before it is signed and postSign after --- crates/automation/src/signing.rs | 40 +++++++++++++++- crates/automation/tests/it/automation.rs | 51 ++++++++++++++++++++ crates/engine/src/js.rs | 36 +++++++++++++++ crates/engine/src/tests.rs | 36 +++++++++++++++ crates/engine/src/xfa.rs | 9 +++- crates/ui-egui/src/sign_ui.rs | 32 ++++++++++++- crates/ui-egui/tests/it/signing.rs | 59 ++++++++++++++++++++++++ crates/xfa/README.md | 3 +- crates/xfa/src/fixtures.rs | 24 ++++++++++ parity/acrobat-features.toml | 4 +- 10 files changed, 286 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 743a36ff56..d2c11a3192 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -2,6 +2,7 @@ //! document, and manage trusted certificates. Rectangles are points from the top-left of the //! displayed page. +use pdfcraft_engine::js::SignEvent; use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus}; use serde_json::{Value, json}; @@ -196,6 +197,8 @@ impl Automation { }) } + /// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a + /// rectangle) or not, running a dynamic form's signing scripts around it. pub(crate) fn sign_document(&mut self, a: &Args) -> Result { let id = self.open_id(a)?; let doc = self.doc(a)?; @@ -245,6 +248,12 @@ impl Automation { Some(v) => labels_arg(v)?, }; let out = self.resolve(a.str("out")?, true)?; + // A dynamic form's preSign scripts run first, so what they change is signed with it. + let signing = field.clone(); + let before = match &signing { + Some(f) => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?, + None => false, + }; let opts = SignOptions { field, page, @@ -262,13 +271,40 @@ impl Automation { // A graphic the signer refuses is the caller's to fix. pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m), e => failed(e), - })?; + }); + let signed = match signed { + Ok(s) => s, + Err(e) => { + // Not signed: what the preSign scripts did is taken back. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } + }; write_atomic(&out, &signed)?; let path = out.to_string_lossy().into_owned(); self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; + // Then its postSign scripts; what they change is a change after signing, not saved yet. + let after = match &signing { + Some(f) => self.session.run_sign_event(doc_id, f, SignEvent::After).map_err(failed)?, + None => false, + }; + let scripts = self.session.take_js_output(doc_id); let doc = self.doc(a)?; let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned(); - Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? })) + let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }); + if before || after { + result["form_scripts"] = json!({ + "before_signing": before, + "after_signing": after, + "alerts": scripts.alerts, + "errors": scripts.errors, + "unsaved_changes": self.doc(a)?.dirty, + }); + } + Ok(result) } /// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9049299702..897b35dccc 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5696,6 +5696,8 @@ fn drawing_comments_through_tools() { assert!(matches!(png[0], Content::Png { .. })); } +/// A visible signature drawn from an image, typed text or strokes through sign_document; bad +/// graphics are refused before anything is written. #[test] fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { let dir = workdir("signing-graphic"); @@ -5765,6 +5767,55 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a dynamic form's signature box through sign_document runs its XFA preSign scripts first, +/// so what they change is signed, and its postSign scripts after, left as unsaved changes; the +/// result says so and carries their messages. A box without such scripts reports none. +#[test] +fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { + let dir = workdir("xfa-sign-events"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let name = |n: &str| { + fields["fields"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["name"].as_str()) + .find(|f| *f == n || f.ends_with(&format!(".{n}[0]"))) + .unwrap() + .to_string() + }; + let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf" })); + let scripts = &r["form_scripts"]; + assert_eq!((scripts["before_signing"].as_bool(), scripts["after_signing"].as_bool()), (Some(true), Some(true)), "{r}"); + assert_eq!(scripts["alerts"], json!(["Signing notice"]), "{r}"); + assert_eq!(scripts["unsaved_changes"], json!(true), "the postSign change isn't in the signed file: {r}"); + // The signed file holds what the preSign script did, not what the postSign one did. + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let values = ok(&mut a, "form_fields", json!({ "doc": signed })); + let value = |n: &str| { + values["fields"] + .as_array() + .unwrap() + .iter() + .find(|f| f["name"].as_str().is_some_and(|f| f == n || f.ends_with(&format!(".{n}[0]")))) + .map(|f| f["value"].clone()) + }; + assert_eq!(value("when"), Some(json!("signed")), "{values}"); + assert_eq!(value("after"), Some(json!(null)), "{values}"); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + assert!(list["signatures"].as_array().unwrap().iter().any(|s| s["signed"] == true && s["status"] == "unknown"), "intact: {list}"); + // A box without signing scripts: nothing reported. + let r = ok(&mut a, "sign_document", json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf" })); + assert!(r.get("form_scripts").is_none(), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Digital IDs are created and opened, documents signed with them and their signatures validated, +/// all through the tools. #[test] fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 7f94971cc1..0880565121 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -245,6 +245,25 @@ impl crate::Document { } } +/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignEvent { + /// Just before the field is signed (`preSign`): what it changes is signed too. + Before, + /// Once the field is signed (`postSign`). + After, +} + +impl SignEvent { + /// The template's name for the event. + pub fn activity(self) -> &'static str { + match self { + SignEvent::Before => "preSign", + SignEvent::After => "postSign", + } + } +} + impl Session { /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). pub fn set_javascript(&mut self, on: bool) { @@ -260,6 +279,23 @@ impl Session { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() } + /// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign` + /// before it is signed, `postSign` after, as one undoable step, as a form's signing events run + /// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when + /// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script. + /// What the scripts said or asked for is in [`Session::take_js_output`]. + pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result { + if self.js_off { + return Ok(false); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let (Some(tpl), Some(editor)) = (doc.xfa_template.clone(), doc.editor.as_ref()) else { return Ok(false) }; + let Some(f) = doc.form.iter().find(|f| f.name == field) else { return Ok(false) }; + let Some(som) = crate::xfa::scripted_som(&editor.cos, &tpl, f, event.activity()) else { return Ok(false) }; + self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?; + Ok(true) + } + /// 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/tests.rs b/crates/engine/src/tests.rs index dc76159d14..1561e5f07d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5204,6 +5204,8 @@ fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { } } +/// A dynamic XFA form is laid out from its template on opening, takes values and saves them as an +/// incremental revision. #[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))); @@ -5243,6 +5245,40 @@ fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); } +/// A signature box's XFA preSign script runs as one undoable step, showing its notice and +/// changing the form; its postSign script runs too; a box without them runs nothing and changes +/// nothing. +#[test] +fn xfa_signing_scripts_run_before_and_after_signing() { + use crate::js::SignEvent; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())); + let mut s = Session::new(); + let id = s.open("signing.pdf", None, bytes, None).expect("opens"); + let field = + |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap(); + let sign = field(&s, "sign").name; + let plain = field(&s, "plain").name; + assert!(s.run_sign_event(id, &sign, SignEvent::Before).unwrap(), "it has a preSign script"); + assert_eq!(field(&s, "when").value, ["signed"]); + assert!(field(&s, "when").read_only(), "and it locks the field"); + assert_eq!(s.take_js_output(id).alerts, ["Signing notice"]); + assert!(s.run_sign_event(id, &sign, SignEvent::After).unwrap()); + assert_eq!(field(&s, "after").value, ["done"]); + // Each one is an undo step of its own. + s.undo(id).unwrap(); + assert!(field(&s, "after").value.is_empty()); + assert_eq!(field(&s, "when").value, ["signed"]); + // No signing scripts: nothing runs, nothing to undo. + let label = s.get(id).unwrap().undo_label().cloned(); + assert!(!s.run_sign_event(id, &plain, SignEvent::Before).unwrap()); + assert!(!s.run_sign_event(id, "no such field", SignEvent::Before).unwrap()); + assert_eq!(s.get(id).unwrap().undo_label().cloned(), label); + // Nor with JavaScript off. + s.set_javascript(false); + assert!(!s.run_sign_event(id, &sign, SignEvent::Before).unwrap()); +} + +/// A signature box the file already holds a signature for is drawn with that signature's picture. #[test] fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 2f98efdb04..ee784df7a6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -900,12 +900,17 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s /// 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 { + scripted_som(doc, tpl, field, "click") +} + +/// The SOM path of a generated field whose template has an `activity` script. +pub(crate) fn scripted_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field, activity: &str) -> Option { // Page-master copies have distinct PDF field names but share a SOM path. Reversing - // fields_by_som would lose every copy except the last: use the clicked field itself. + // fields_by_som would lose every copy except the last: use the field itself. let object = doc.get(field.obj); let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text(); 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) + live.events.iter().any(|e| e.som == som && e.activity == activity).then_some(som) } /// The parsed template of a laid-out XFA form, kept for the document's life, when it has diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 25f92530b2..e9ba564012 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -459,12 +459,41 @@ impl PdfCraftApp { if !app.commit_typing_in(doc_id) { return Err(String::new()); } - let signed = app.session.sign(doc_id, &id, opts).map_err(|e| e.to_string())?; + // A dynamic form's preSign scripts run first, so what they change is signed with it + // (DA 31's date the signature and lock the date). What they say shows once signing is + // done, so the notice that it was saved doesn't take the place of the form's own. + let field = opts.field.clone(); + let before = match &field { + Some(f) => app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::Before).map_err(|e| e.to_string())?, + None => false, + }; + let mut said = app.session.take_js_output(doc_id); + let signed = match app.session.sign(doc_id, &id, opts) { + Ok(s) => s, + Err(e) => { + // Not signed: what the preSign scripts did is taken back. + if before { + let _ = app.session.undo(doc_id); + } + app.js_console.log.extend(said.console); + return Err(e.to_string()); + } + }; crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?; // Our own write is not a change on disk to reload (#431). #[cfg(not(target_arch = "wasm32"))] app.watch.known_now(doc_id, &path.to_string_lossy()); app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?; + // Then its postSign scripts; what they change is a change after signing, to save. + if let Some(f) = &field { + app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::After).map_err(|e| e.to_string())?; + let after = app.session.take_js_output(doc_id); + said.alerts.extend(after.alerts); + said.console.extend(after.console); + said.requests.extend(after.requests); + said.errors.extend(after.errors); + said.doc_errors.extend(after.doc_errors); + } #[cfg(target_os = "macos")] crate::macos_association::repair(&path); app.sign_graphic = choice; @@ -473,6 +502,7 @@ impl PdfCraftApp { } app.right = Some(crate::RightPanel::Signatures); app.notify_fmt("Signed and saved to {path}", &[("path", &path.display().to_string())]); + app.handle_js(doc_id, said); Ok(()) }; match self.save_override.clone() { diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..2795c0025d 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -368,6 +368,65 @@ fn clicking_an_empty_signature_field_signs_it() { assert!(sig.signed && sig.visible); } +/// Signing a dynamic form's signature box in the app runs its XFA preSign script first, so the +/// value it sets is in the signed file and its notice shows, then its postSign script, whose +/// change is left to save. +#[test] +fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() { + let dir = dir().join("xfa-events"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + let bytes = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template()); + h.state_mut().open_bytes("events.pdf", None, bytes).unwrap(); + h.state_mut().active = Some(1); + crate::support::settle(&mut h); + let named = |h: &Harness<'static, PdfCraftApp>, n: &str| { + let s = h.state(); + s.session.get(s.views[1].id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap() + }; + let p = { + let s = h.state(); + let doc = s.session.get(s.views[1].id).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[1], &doc.info, &named(&h, "sign"), 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(3); + assert_eq!(h.state().dialog, Some(Dialog::Sign)); + let entry = { + let id = pdfcraft_engine::sign::pkcs12::open( + &std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(), + "test", + ) + .unwrap(); + h.state_mut().add_digital_id(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"), &id.certificate) + }; + { + let d = h.state_mut().sign_draft.as_mut().unwrap(); + d.selected = Some(entry); + d.step = SignStep::SignAs; + d.password = "test".into(); + } + h.run_steps(2); + h.get_by_label("Sign").click(); + h.run_steps(6); + assert!(h.query_by_label_contains("Signing notice").is_some(), "the preSign script's notice shows"); + // The signed file holds the preSign script's value; the postSign one waits to be saved. + let signed = std::fs::read(dir.join("contract_signed.pdf")).unwrap(); + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("signed.pdf", None, std::sync::Arc::new(signed), None).unwrap(); + let value = + |n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).map(|f| f.value.clone()).unwrap(); + assert_eq!(value("when"), ["signed"]); + assert!(value("after").is_empty()); + assert_eq!(named(&h, "after").value, ["done"]); + let st = h.state(); + assert!(st.session.get(st.views[1].id).unwrap().dirty, "the postSign change is not saved yet"); +} + #[test] fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { use pdfcraft_ui_egui::SignGraphic; diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 23a6ef0d0e..bcb49439b9 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -67,7 +67,8 @@ be measured exponentially often in their nesting. A measurement budget backs thi 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. Every generated copy of a page-master button resolves + field changes, click for buttons, and preSign and postSign around signing a signature box + (preSign first, so what it changes is signed too). Every generated copy of a page-master button resolves its own PDF field to the shared XFA click script, so clicking an early copy works as well as the last, including through `js_run` and after save/reopen. While this XFA layout is active, the engine does not diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index b76b3efa1a..ede9fb1cc9 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -528,6 +528,30 @@ pub fn date_alternatives_template() -> String { .to_string() } +/// Two signature boxes: `sign`, whose preSign script shows a notice, fills `when` and locks it, and +/// whose postSign script fills `after`; and `plain`, with no signing scripts. +pub fn signing_events_template() -> String { + r##" + + + +"## + .to_string() +} + /// A form of `sections` sections of `rows` rows, each row with a calculated total (half in /// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections /// calculate scripts, every one reading values the earlier ones settled. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2c9cf17197..7edd0fa0a8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] 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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/engine/src/tests.rs::xfa_signing_scripts_run_before_and_after_signing", "crates/automation/tests/it/automation.rs::signing_an_xfa_box_runs_its_signing_scripts_through_tools"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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), preSign just before a signature box is signed, so what it changes (a date, a lock) is signed too, and postSign once it is (through sign_document 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; a postSign script that checks its signature through event.target.getField(...).signatureValidate() fails, as event.target isn't the document yet, and a preSign script's message shows once signing is done, as a notice, where another viewer asks before it signs; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 3eefcd5719e72444c90ffcaf43a8fae70625b281 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:38:35 -0400 Subject: [PATCH 028/140] M12: preSign runs as signing begins, so the signer sees what it changes; taken back if signing is cancelled; opt-in through sign_document --- crates/automation/src/signing.rs | 35 +++++++++-- crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 36 +++++++++-- crates/engine/src/js.rs | 26 ++++++-- crates/engine/src/lib.rs | 5 ++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/sign_ui.rs | 79 +++++++++++++++++------- crates/ui-egui/tests/it/signing.rs | 34 ++++++++-- parity/acrobat-features.toml | 2 +- 9 files changed, 182 insertions(+), 43 deletions(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index d2c11a3192..342ce85308 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -248,11 +248,33 @@ impl Automation { Some(v) => labels_arg(v)?, }; let out = self.resolve(a.str("out")?, true)?; - // A dynamic form's preSign scripts run first, so what they change is signed with it. + // A dynamic form's signing scripts run only when asked: what preSign changes is signed, + // and the caller hasn't seen it. What they changed is listed. + let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false); let signing = field.clone(); + let scripted = signing.as_deref().is_some_and(|f| { + self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After) + }); + // Each field's value and whether it is read-only, to tell what the preSign scripts changed. + let values = |me: &Automation| -> std::collections::BTreeMap, bool)> { + me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default() + }; + let was = values(self); let before = match &signing { - Some(f) => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?, - None => false, + Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?, + _ => false, + }; + let changed: Vec = if before { + values(self) + .into_iter() + .filter(|(name, now)| was.get(name) != Some(now)) + .map(|(name, (value, read_only))| { + let (old, old_ro) = was.get(&name).cloned().unwrap_or_default(); + json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only }) + }) + .collect() + } else { + Vec::new() }; let opts = SignOptions { field, @@ -288,8 +310,8 @@ impl Automation { self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; // Then its postSign scripts; what they change is a change after signing, not saved yet. let after = match &signing { - Some(f) => self.session.run_sign_event(doc_id, f, SignEvent::After).map_err(failed)?, - None => false, + Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::After).map_err(failed)?, + _ => false, }; let scripts = self.session.take_js_output(doc_id); let doc = self.doc(a)?; @@ -299,10 +321,13 @@ impl Automation { result["form_scripts"] = json!({ "before_signing": before, "after_signing": after, + "changed": changed, "alerts": scripts.alerts, "errors": scripts.errors, "unsaved_changes": self.doc(a)?.dirty, }); + } else if scripted { + result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" }); } Ok(result) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1bfcc77984..fa9e96c9f0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -774,7 +774,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.") .cmd("sign.digital") .with(schema( json!({ @@ -825,6 +825,7 @@ pub fn tools() -> Vec { "additionalProperties": false, }, "out": { "type": "string", "description": "Where to save the signed document." }, + "run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." }, }), &["doc", "id", "out"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 897b35dccc..1644d88bcd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5767,9 +5767,10 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } -/// Signing a dynamic form's signature box through sign_document runs its XFA preSign scripts first, -/// so what they change is signed, and its postSign scripts after, left as unsaved changes; the -/// result says so and carries their messages. A box without such scripts reports none. +/// Signing a dynamic form's signature box through sign_document runs its XFA signing scripts only +/// when asked (run_form_scripts): preSign first, so what it changes is signed, listed field by field, +/// and postSign after, left as unsaved changes; the result carries their messages. Not asked, they +/// don't run and the result says the box has them; a box without them reports nothing. #[test] fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { let dir = workdir("xfa-sign-events"); @@ -5788,8 +5789,29 @@ fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { .unwrap() .to_string() }; - let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf" })); + // Not asked: the scripts don't run, and the result says the box has them. + let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "unasked.pdf" })); + assert_eq!(r["form_scripts"]["skipped"], json!(true), "{r}"); + let unasked = ok(&mut a, "doc_open", json!({ "path": "unasked.pdf" }))["doc"].as_u64().unwrap(); + let unasked = ok(&mut a, "form_fields", json!({ "doc": unasked })); + assert!( + unasked["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{unasked}" + ); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let r = ok( + &mut a, + "sign_document", + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf", "run_form_scripts": true }), + ); let scripts = &r["form_scripts"]; + let changed = scripts["changed"].as_array().unwrap(); + assert_eq!(changed.len(), 1, "only what preSign changed: {r}"); + assert_eq!((&changed[0]["after"], &changed[0]["read_only"]), (&json!(["signed"]), &json!(true)), "{r}"); assert_eq!((scripts["before_signing"].as_bool(), scripts["after_signing"].as_bool()), (Some(true), Some(true)), "{r}"); assert_eq!(scripts["alerts"], json!(["Signing notice"]), "{r}"); assert_eq!(scripts["unsaved_changes"], json!(true), "the postSign change isn't in the signed file: {r}"); @@ -5809,7 +5831,11 @@ fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { let list = ok(&mut a, "sign_list", json!({ "doc": signed })); assert!(list["signatures"].as_array().unwrap().iter().any(|s| s["signed"] == true && s["status"] == "unknown"), "intact: {list}"); // A box without signing scripts: nothing reported. - let r = ok(&mut a, "sign_document", json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf" })); + let r = ok( + &mut a, + "sign_document", + json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf", "run_form_scripts": true }), + ); assert!(r.get("form_scripts").is_none(), "{r}"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 0880565121..c71f0c86be 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -285,17 +285,31 @@ impl Session { /// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script. /// What the scripts said or asked for is in [`Session::take_js_output`]. pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result { - if self.js_off { - return Ok(false); + if self.get(id).is_none() { + return Err(EditError::NoDocument); } - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let (Some(tpl), Some(editor)) = (doc.xfa_template.clone(), doc.editor.as_ref()) else { return Ok(false) }; - let Some(f) = doc.form.iter().find(|f| f.name == field) else { return Ok(false) }; - let Some(som) = crate::xfa::scripted_som(&editor.cos, &tpl, f, event.activity()) else { return Ok(false) }; + let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) }; self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?; Ok(true) } + /// Whether signature field `field` of document `id` has XFA signing scripts for `event` that + /// [`Session::run_sign_event`] would run, without running them. + pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool { + self.sign_event_som(id, field, event).is_some() + } + + /// The SOM path of signature field `field` when it has scripts for `event` to run. + fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option { + if self.js_off { + return None; + } + let doc = self.get(id)?; + let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?); + let f = doc.form.iter().find(|f| f.name == field)?; + crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity()) + } + /// 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 aa71d82fef..a9a893c5a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -695,6 +695,11 @@ impl Document { self.redo_label().map(Label::as_str) } + /// How many steps Undo can take back: a step still at this depth is still the last one. + pub fn undo_depth(&self) -> usize { + self.editor.as_ref().map_or(0, |e| e.undo.len()) + } + /// What Undo would undo, in parts, for an interface that says it in another language. pub fn undo_label(&self) -> Option<&Label> { self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c9..ebc2bb987f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -812,6 +812,9 @@ pub struct PdfCraftApp { pub summary_sort: pdfcraft_engine::SummarySort, /// The signing dialogs' state. pub sign_draft: Option, + /// A dynamic form's preSign step, run when signing a field began: the document and its undo + /// depth after the step. Taken back if signing ends without a signature. + pub(crate) presign: Option<(DocId, usize)>, /// Digital ID files the user has created or added. pub digital_ids: Vec, /// Signatures panel: expanded entries (field names). @@ -1185,6 +1188,7 @@ impl PdfCraftApp { rotate_draft: RotateDraft::default(), summary_sort: Default::default(), sign_draft: None, + presign: None, digital_ids: Vec::new(), sig_expanded: Vec::new(), a11y_options: a11y_ui::A11yOptions::default(), @@ -3590,6 +3594,7 @@ impl eframe::App for PdfCraftApp { self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] self.process_picked(); + self.settle_presign(); // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index e9ba564012..c18a48811b 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -272,10 +272,11 @@ impl PdfCraftApp { self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate"); } - /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. + /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID, after a + /// dynamic form's preSign scripts for the field. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { self.refresh_os_key_store_ids(); - let mut draft = SignDraft::new(page, rect, field, certify, &self.digital_ids); + let mut draft = SignDraft::new(page, rect, field.clone(), certify, &self.digital_ids); draft.graphic = match &self.sign_graphic { SignGraphic::FillSign if self.signature.is_none() => SignGraphic::Name, g => g.clone(), @@ -285,6 +286,55 @@ impl PdfCraftApp { } self.sign_draft = Some(draft); self.dialog = Some(crate::Dialog::Sign); + // A dynamic form's preSign scripts run now, as signing the field begins, so what they + // change and say shows before the signer confirms (DA 31's date the signature and lock the + // date). Signing ended without a signature takes them back ([`Self::settle_presign`]). + if let Some(f) = &field + && let Some((_, doc)) = self.active_ids() + { + self.run_presign(doc, f); + } + } + + /// Run signature field `field`'s preSign scripts in document `doc` and keep the step pending. + fn run_presign(&mut self, doc: pdfcraft_engine::DocId, field: &str) { + match self.session.run_sign_event(doc, field, pdfcraft_engine::js::SignEvent::Before) { + Ok(true) => { + self.presign = self.session.get(doc).map(|d| (doc, d.undo_depth())); + self.refresh_view_of(doc); + let out = self.session.take_js_output(doc); + self.handle_js(doc, out); + } + Ok(false) => {} + Err(e) => self.notify_error(e), + } + } + + /// Take back a pending preSign step once signing has ended without a signature: the Sign + /// dialog closed and no save picker is open for it. Only a step still the last one is taken + /// back. Called every frame. + pub(crate) fn settle_presign(&mut self) { + let Some((doc, depth)) = self.presign else { return }; + #[cfg(not(target_arch = "wasm32"))] + if self.pickers.pending() { + return; + } + if self.dialog == Some(crate::Dialog::Sign) { + return; + } + self.presign = None; + if self.session.get(doc).is_some_and(|d| d.undo_depth() == depth) && self.session.undo(doc).is_ok() { + self.refresh_view_of(doc); + } + } + + /// Show document `doc`'s changed form in its view. + fn refresh_view_of(&mut self, doc: pdfcraft_engine::DocId) { + if let Some(i) = self.views.iter().position(|v| v.id == doc) + && let Some(info) = self.session.get(doc).map(|d| d.info.clone()) + { + self.views[i].document_changed(&info); + } } /// List OS key store signing identities (after the file-based IDs). @@ -459,23 +509,14 @@ impl PdfCraftApp { if !app.commit_typing_in(doc_id) { return Err(String::new()); } - // A dynamic form's preSign scripts run first, so what they change is signed with it - // (DA 31's date the signature and lock the date). What they say shows once signing is - // done, so the notice that it was saved doesn't take the place of the form's own. + // The form's preSign step (run when signing began) is signed with it; if signing fails + // the step stays pending, and is taken back if signing ends without a signature. let field = opts.field.clone(); - let before = match &field { - Some(f) => app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::Before).map_err(|e| e.to_string())?, - None => false, - }; - let mut said = app.session.take_js_output(doc_id); + let pending = app.presign.take(); let signed = match app.session.sign(doc_id, &id, opts) { Ok(s) => s, Err(e) => { - // Not signed: what the preSign scripts did is taken back. - if before { - let _ = app.session.undo(doc_id); - } - app.js_console.log.extend(said.console); + app.presign = pending; return Err(e.to_string()); } }; @@ -485,15 +526,11 @@ impl PdfCraftApp { app.watch.known_now(doc_id, &path.to_string_lossy()); app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?; // Then its postSign scripts; what they change is a change after signing, to save. + // What they say shows once signing is done, after the notice that it was saved. if let Some(f) = &field { app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::After).map_err(|e| e.to_string())?; - let after = app.session.take_js_output(doc_id); - said.alerts.extend(after.alerts); - said.console.extend(after.console); - said.requests.extend(after.requests); - said.errors.extend(after.errors); - said.doc_errors.extend(after.doc_errors); } + let said = app.session.take_js_output(doc_id); #[cfg(target_os = "macos")] crate::macos_association::repair(&path); app.sign_graphic = choice; diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 2795c0025d..fda91427d6 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -368,9 +368,9 @@ fn clicking_an_empty_signature_field_signs_it() { assert!(sig.signed && sig.visible); } -/// Signing a dynamic form's signature box in the app runs its XFA preSign script first, so the -/// value it sets is in the signed file and its notice shows, then its postSign script, whose -/// change is left to save. +/// Signing a dynamic form's signature box in the app runs its XFA preSign script as signing begins, +/// so its notice and the value it sets show before the signer confirms, and that value is in the +/// signed file; its postSign script runs once signed, its change left to save. #[test] fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() { let dir = dir().join("xfa-events"); @@ -396,6 +396,8 @@ fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() { h.drop_at(p); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Sign)); + assert!(h.query_by_label_contains("Signing notice").is_some(), "the preSign script's notice shows before signing"); + assert_eq!(named(&h, "when").value, ["signed"], "and what it set, under the dialog"); let entry = { let id = pdfcraft_engine::sign::pkcs12::open( &std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(), @@ -413,7 +415,6 @@ fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() { h.run_steps(2); h.get_by_label("Sign").click(); h.run_steps(6); - assert!(h.query_by_label_contains("Signing notice").is_some(), "the preSign script's notice shows"); // The signed file holds the preSign script's value; the postSign one waits to be saved. let signed = std::fs::read(dir.join("contract_signed.pdf")).unwrap(); let mut s = pdfcraft_engine::Session::new(); @@ -427,6 +428,31 @@ fn signing_an_xfa_box_runs_its_signing_scripts_in_the_app() { assert!(st.session.get(st.views[1].id).unwrap().dirty, "the postSign change is not saved yet"); } +/// Signing a dynamic form's box and then cancelling takes back what its preSign script changed. +#[test] +fn cancelling_signing_takes_back_the_presign_scripts_changes() { + let dir = dir().join("xfa-events-cancel"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + let bytes = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template()); + h.state_mut().open_bytes("events.pdf", None, bytes).unwrap(); + h.state_mut().active = Some(1); + crate::support::settle(&mut h); + let named = |h: &Harness<'static, PdfCraftApp>, n: &str| { + let s = h.state(); + s.session.get(s.views[1].id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap() + }; + let sign = named(&h, "sign").name; + h.state_mut().start_signing(0, None, Some(sign), None); + h.run_steps(2); + assert_eq!(named(&h, "when").value, ["signed"]); + h.state_mut().dialog = None; + h.state_mut().sign_draft = None; + h.run_steps(3); + assert!(named(&h, "when").value.is_empty(), "taken back"); + assert!(!named(&h, "when").read_only(), "and unlocked again"); +} + #[test] fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { use pdfcraft_ui_egui::SignGraphic; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7edd0fa0a8..25d9610453 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5297,7 +5297,7 @@ status = "partial" commands = ["file.open"] 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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/engine/src/tests.rs::xfa_signing_scripts_run_before_and_after_signing", "crates/automation/tests/it/automation.rs::signing_an_xfa_box_runs_its_signing_scripts_through_tools"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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), preSign just before a signature box is signed, so what it changes (a date, a lock) is signed too, and postSign once it is (through sign_document 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; a postSign script that checks its signature through event.target.getField(...).signatureValidate() fails, as event.target isn't the document yet, and a preSign script's message shows once signing is done, as a notice, where another viewer asks before it signs; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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), preSign as signing a box begins, so what it changes (a date, a lock) and says shows before the signer confirms and is signed too, taken back if signing ends without a signature, and postSign once signed (in the app; through sign_document when run_form_scripts asks, with every field preSign changed listed), 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; a postSign script that checks its signature through event.target.getField(...).signatureValidate() fails, as event.target isn't the document yet, and a preSign script's message is a notice, where another viewer asks for an answer before it goes on; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 9b8ae111b524868c7baba1816f64a9a89f478492 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:40:55 -0400 Subject: [PATCH 029/140] M12: a postSign step that fails after signing is reported, and the signing stands --- crates/automation/src/signing.rs | 13 +++++++++---- crates/ui-egui/src/sign_ui.rs | 8 +++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 342ce85308..d10b38d7d8 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -309,11 +309,16 @@ impl Automation { let path = out.to_string_lossy().into_owned(); self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; // Then its postSign scripts; what they change is a change after signing, not saved yet. - let after = match &signing { - Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::After).map_err(failed)?, - _ => false, + // The file is signed and saved by now: a postSign step that fails is reported, not an error. + let (after, after_error) = match &signing { + Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) { + Ok(ran) => (ran, None), + Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))), + }, + _ => (false, None), }; - let scripts = self.session.take_js_output(doc_id); + let mut scripts = self.session.take_js_output(doc_id); + scripts.errors.extend(after_error); let doc = self.doc(a)?; let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned(); let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index c18a48811b..9e3eb395ec 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -527,9 +527,8 @@ impl PdfCraftApp { app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?; // Then its postSign scripts; what they change is a change after signing, to save. // What they say shows once signing is done, after the notice that it was saved. - if let Some(f) = &field { - app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::After).map_err(|e| e.to_string())?; - } + // The file is signed and saved by now: a postSign step that fails is said, not a failure. + let after = field.as_deref().and_then(|f| app.session.run_sign_event(doc_id, f, pdfcraft_engine::js::SignEvent::After).err()); let said = app.session.take_js_output(doc_id); #[cfg(target_os = "macos")] crate::macos_association::repair(&path); @@ -540,6 +539,9 @@ impl PdfCraftApp { app.right = Some(crate::RightPanel::Signatures); app.notify_fmt("Signed and saved to {path}", &[("path", &path.display().to_string())]); app.handle_js(doc_id, said); + if let Some(e) = after { + app.notify_fmt("Signed and saved, but the form's postSign script couldn't finish: {e}", &[("e", &e.to_string())]); + } Ok(()) }; match self.save_override.clone() { From 2c6496841125d13131ad98b17d7931a15a7b3d31 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:46:52 -0400 Subject: [PATCH 030/140] M12: doc comments on the functions the signing events touch --- crates/automation/src/signing.rs | 2 ++ crates/automation/tests/it/automation.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 2 ++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/tests/it/signing.rs | 2 ++ 6 files changed, 10 insertions(+) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index d10b38d7d8..e7f6b3efee 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -175,6 +175,8 @@ impl Automation { Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) })) } + /// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store + /// identity (`keychain:` on macOS, `windows:` on Windows). fn open_id(&self, a: &Args) -> Result { // A macOS Keychain identity: "keychain:" or "keychain:". if let Some(r) = a.str("id")?.strip_prefix("keychain:") { diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 1644d88bcd..9d03c93e8e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5652,6 +5652,7 @@ fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool { } } +/// Polygons, polylines and other drawing comments are added and listed through the tools. #[test] fn drawing_comments_through_tools() { let dir = workdir("drawing"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9a893c5a6..6f9a056418 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -691,6 +691,7 @@ impl Document { self.undo_label().map(Label::as_str) } + /// What Redo would redo, in English, when there is something to redo. pub fn can_redo(&self) -> Option<&str> { self.redo_label().map(Label::as_str) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1561e5f07d..dfff024200 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5178,6 +5178,8 @@ fn round_xfa_check_buttons_open_drawn_round() { } } +/// A dynamic XFA form with usage rights is saved without them once laid out, as the layout voids +/// them, with a note saying so. #[test] fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { let xdp = pdfcraft_xfa::fixtures::template(3); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ebc2bb987f..81d64237b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -606,6 +606,7 @@ pub struct UnlockPrompt { } impl UnlockPrompt { + /// Ask for document `doc`'s permissions password, then run command `then`, if any. pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self { Self { doc: doc.id, name: doc.name.clone(), input: String::new(), error: None, then } } @@ -1080,6 +1081,7 @@ pub enum OpenOutcome { } impl PdfCraftApp { + /// A new app with no documents open and every setting at its default. pub fn new() -> Self { Self { session: Session::new(), diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index fda91427d6..d5494d911d 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -326,6 +326,7 @@ const WITH_FIELD: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; +/// Clicking an empty signature field opens Sign for that field, and signing fills it. #[test] fn clicking_an_empty_signature_field_signs_it() { let dir = dir().join("field"); @@ -453,6 +454,7 @@ fn cancelling_signing_takes_back_the_presign_scripts_changes() { assert!(!named(&h, "when").read_only(), "and unlocked again"); } +/// A digital signature can show the Fill & Sign signature or an image where the name would be. #[test] fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { use pdfcraft_ui_egui::SignGraphic; From a756832dd5606ec092521c79f7ded1d028b73314 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:00:13 -0400 Subject: [PATCH 031/140] M12: a signed file that can't be written leaves the preSign step pending, or takes it back --- crates/automation/src/signing.rs | 9 ++++++++- crates/automation/tests/it/automation.rs | 17 +++++++++++++++++ crates/ui-egui/src/sign_ui.rs | 6 +++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index e7f6b3efee..1a6440fd54 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -307,7 +307,14 @@ impl Automation { return Err(e); } }; - write_atomic(&out, &signed)?; + if let Err(e) = write_atomic(&out, &signed) { + // Not saved, so not signed: what the preSign scripts did is taken back too. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } let path = out.to_string_lossy().into_owned(); self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; // Then its postSign scripts; what they change is a change after signing, not saved yet. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9d03c93e8e..c50d3d53c2 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5804,6 +5804,23 @@ fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { "{unasked}" ); let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // A signed file that can't be written (`out` is a folder): not signed, and what preSign did + // is taken back. + std::fs::create_dir_all(dir.join("taken")).unwrap(); + let failed = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "taken", "run_form_scripts": true }), + ); + assert!(failed.is_err(), "{failed:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); let r = ok( &mut a, "sign_document", diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 9e3eb395ec..3414f4ce9b 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -520,7 +520,11 @@ impl PdfCraftApp { return Err(e.to_string()); } }; - crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?; + if let Err(e) = crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()) { + // Not saved, so not signed: the preSign step stays pending as well. + app.presign = pending; + return Err(format!("Could not save: {e}")); + } // Our own write is not a change on disk to reload (#431). #[cfg(not(target_arch = "wasm32"))] app.watch.known_now(doc_id, &path.to_string_lossy()); From 9a81dd071672b49035fa8edeb6ae20e88fa92dac Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:04:42 -0400 Subject: [PATCH 032/140] M12: sign_document reads every argument before the preSign scripts run --- crates/automation/src/signing.rs | 20 +++++++------------- crates/automation/tests/it/automation.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 1a6440fd54..448a2d36d0 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -253,6 +253,11 @@ impl Automation { // A dynamic form's signing scripts run only when asked: what preSign changes is signed, // and the caller hasn't seen it. What they changed is listed. let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false); + // Every argument is read before the preSign scripts change anything, so a bad one is + // refused with the document as it was. + let reason = a.opt_str("reason")?.map(str::to_string); + let location = a.opt_str("location")?.map(str::to_string); + let contact = a.opt_str("contact")?.map(str::to_string); let signing = field.clone(); let scripted = signing.as_deref().is_some_and(|f| { self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After) @@ -278,19 +283,8 @@ impl Automation { } else { Vec::new() }; - let opts = SignOptions { - field, - page, - rect, - reason: a.opt_str("reason")?.map(str::to_string), - location: a.opt_str("location")?.map(str::to_string), - contact: a.opt_str("contact")?.map(str::to_string), - certify, - appearance, - appearance_labels, - graphic, - ..SignOptions::default() - }; + let opts = + SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() }; let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e { // A graphic the signer refuses is the caller's to fix. pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c50d3d53c2..4e75f6b588 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5804,6 +5804,21 @@ fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { "{unasked}" ); let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // A bad argument is refused before the preSign scripts run. + let bad = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "bad.pdf", "run_form_scripts": true, "reason": 5 }), + ); + assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{bad:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); // A signed file that can't be written (`out` is a folder): not signed, and what preSign did // is taken back. std::fs::create_dir_all(dir.join("taken")).unwrap(); From e0eb1eff012b160a0d8e89a23fc321f82b85dc2a Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:50:22 -0400 Subject: [PATCH 033/140] M6: a script's field-highlight change reaches the next edit of a batch, and is kept only when its request is --- crates/engine/src/lib.rs | 6 ++++++ crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ crates/js/src/tests.rs | 10 ++++++++++ crates/js/src/xfa.rs | 7 +++++-- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c4e15e4854..67083e1233 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2687,6 +2687,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + cx.highlight = run.highlight; ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { @@ -3484,6 +3486,8 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + cx.highlight = run.highlight; if let Err(e) = r { cx.xfa_out.errors.push(e); } @@ -4071,6 +4075,8 @@ impl Session { .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; + // The next edit of a batch reads the highlight this one's scripts set. + cx.highlight = run.highlight; if let Err(e) = ran { cx.xfa_out.errors.push(e); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 16278390cc..8b739e3056 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8102,6 +8102,35 @@ fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { assert!(s.take_js_output(id).requests.is_empty()); } +/// In a batch of XFA events, a later event's scripts read the field highlight an earlier one's set: +/// `on`'s click turns it on, and `look`'s click, in the same batch, records what it reads. +#[test] +fn a_later_xfa_event_in_a_batch_reads_the_highlight_an_earlier_one_set() { + let script = |js: &str| format!(r#""#); + let xdp = format!( + r##" + + + +"##, + on = script("app.runtimeHighlight = true;"), + look = script("xfa.form.form.seen.rawValue = String(app.runtimeHighlight);"), + ); + let mut s = Session::new(); + let id = s.open("batch.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).expect("opens"); + let click = |som: &str| Edit::XfaEvent { som: som.into(), activity: "click".into() }; + s.apply(id, Edit::Batch { edits: vec![click("form[0].on[0]"), click("form[0].look[0]")], label: "Click".into() }).expect("clicks"); + let seen = s.get(id).unwrap().form.iter().find(|f| f.name == "seen").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(seen, vec!["true".to_string()]); +} + /// Field scripts of one edit read the field highlight an earlier one set: `on`'s calculation turns /// it on, and `seen`'s, which runs after it, records what it reads. #[test] diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index d29899125c..9ceb8dbcf2 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1579,6 +1579,16 @@ mod runtime_highlight { assert_eq!(o.effects, vec![XfaEffect::Highlight(true)]); let o = run_xfa(script, &ev, &XfaDoc { highlight: true, ..Default::default() }, &root, Limits::default()); assert_eq!((o.result.as_deref(), o.effects.len()), (Some("true,true"), 0), "already on: nothing to ask for"); + // Past the effect cap the request is dropped, and the script still reads the setting the + // viewer has. + let full = format!( + "for (var i = 0; i < {}; i++) {{ xfa.host.beep(); }} app.runtimeHighlight = true; String(app.runtimeHighlight)", + crate::xfa::MAX_EFFECTS + ); + let o = run_xfa(&full, &ev, &XfaDoc::default(), &root, Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("false")); + assert!(!o.effects.contains(&XfaEffect::Highlight(true))); } } diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 3ad410c0d6..766fbf566c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1159,9 +1159,12 @@ fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult< fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); let mut h = host(ctx)?.borrow_mut(); - if h.doc.highlight != on { + let e = XfaEffect::Highlight(on); + // Changed only when the request is kept: past the effect cap the script goes on reading the + // setting the viewer has. + if h.doc.highlight != on && h.reserve(&e, payload(&e)) { h.doc.highlight = on; - h.push(XfaEffect::Highlight(on)); + h.record(e); } Ok(JsValue::undefined()) } From 56c1a1e5043027ef84ac9b7c46889ef37beb65d7 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:02:32 -0400 Subject: [PATCH 034/140] M6: an edit's XFA and field scripts share one field highlight --- crates/engine/src/lib.rs | 35 +++++++++++++++++++++++++++-------- crates/engine/src/tests.rs | 15 +++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 67083e1233..ae369c700f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2481,6 +2481,21 @@ struct EditCtx { } impl EditCtx { + /// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field + /// scripts' runner holds it while there is one (its scripts change it there), else the edit. + fn highlight(&self) -> bool { + self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight) + } + + /// A script changed the field highlight: both script runtimes of the edit read the new one, + /// so a later edit of a batch sees it whichever kind of script it runs. + fn set_highlight(&mut self, on: bool) { + self.highlight = on; + if let Some(js) = self.js.as_mut() { + js.doc.highlight = on; + } + } + /// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the /// ids it makes. fn new(now: Option, salt: u64) -> Self { @@ -2682,13 +2697,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, highlight: cx.highlight }; + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight }; let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; // The next edit of a batch reads the highlight this one's scripts set. - cx.highlight = run.highlight; + let on = run.highlight; + cx.set_highlight(on); ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { @@ -3481,13 +3497,15 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// fields take the data's values. fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight: cx.highlight }; + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight }; let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; // The next edit of a batch reads the highlight this one's scripts set. - cx.highlight = run.highlight; + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = r { cx.xfa_out.errors.push(e); } @@ -4067,8 +4085,8 @@ impl Session { 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, highlight: cx.highlight }; + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, highlight }; // 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)) @@ -4076,7 +4094,8 @@ impl Session { cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; // The next edit of a batch reads the highlight this one's scripts set. - cx.highlight = run.highlight; + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = ran { cx.xfa_out.errors.push(e); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8b739e3056..9bef836122 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8102,6 +8102,21 @@ fn a_forms_layout_ready_scripts_run_once_it_is_laid_out() { assert!(s.take_js_output(id).requests.is_empty()); } +/// An edit's two script runtimes share one field highlight: a change an XFA script makes reaches +/// the field scripts' runner, and one the field scripts make is what the next XFA script reads. +#[test] +fn an_edits_xfa_and_field_scripts_share_the_field_highlight() { + let cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.js = Some(js::JsRunner::new(&cos, "synthetic.pdf", false)); + cx.set_highlight(true); + assert!(cx.js.as_ref().is_some_and(|js| js.doc.highlight), "an XFA script's change reaches the field scripts"); + if let Some(js) = cx.js.as_mut() { + js.doc.highlight = false; + } + assert!(!cx.highlight(), "the next XFA script reads the field scripts' change"); +} + /// In a batch of XFA events, a later event's scripts read the field highlight an earlier one's set: /// `on`'s click turns it on, and `look`'s click, in the same batch, records what it reads. #[test] From c86b33985d44dfd14ec4e6e8516a95390b9171de Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:21:11 -0400 Subject: [PATCH 035/140] M12: XFA fields and exclusion groups that must be filled in are Required Co-authored-by: EULA <90616501+EULArule@users.noreply.github.com> --- crates/xfa/README.md | 6 +++-- crates/xfa/src/layout.rs | 12 ++++++++++ crates/xfa/src/model.rs | 4 ++++ crates/xfa/src/parse.rs | 8 +++++++ crates/xfa/src/pdf.rs | 8 ++++++- crates/xfa/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 7 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 23a6ef0d0e..0180d0f473 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -25,7 +25,7 @@ that warning and keeps the existing layout and accepted widget values. margins, borders (edge order rules), captions with reserves, check-button items, pictures, rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields), text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`), - inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow + required fields and exclusion groups (``), inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow leaders, `columnWidths` and `colSpan`. - **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a container without a width is as wide as its content; flow across content areas and pages with @@ -38,7 +38,9 @@ that warning and keeps the existing layout and accepted widget values. XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate` format and keystroke actions from the picture clause), check boxes and radio groups (from `exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their - scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps + scripts. A field or exclusion group that must be filled in gets the `Required` flag (a radio + group on its group field), which viewers outline when they highlight fields. Appearance + streams are left empty for the forms layer to generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 670d43791a..a5831bcc31 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -168,6 +168,8 @@ pub struct Widget { pub items: Vec, /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). pub no_scroll: bool, + /// The field must be filled in (`Required`); a radio button, when its group must be. + pub required: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -1799,6 +1801,15 @@ impl Layouter<'_> { let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); let chosen = selected.as_deref() == Some(on.as_str()); + // A group that must be answered makes each of its buttons required, as the group + // field they make is. + let required; + let f = if g.required && !f.required { + required = Field { required: true, ..f.clone() }; + &required + } else { + f + }; self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; } } @@ -2087,6 +2098,7 @@ impl Layouter<'_> { action, items, no_scroll: f.no_scroll, + required: f.required, }))) } } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 0fac8db18e..98cca9ddcf 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -449,6 +449,8 @@ pub struct ExclGroup { pub fields: Vec, pub tooltip: Option, pub scripts: Vec + + +
+"##; + let mut s = Session::new(); + let id = s.open("reads.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let value = s.get(id).unwrap().form.iter().find(|f| f.name == "reads").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value, vec!["pages: 1".to_string()]); +} + /// layout:ready scripts run once on opening: the root's turns field highlighting on (only when it /// is off), a field's sets a value and a later script reads the new highlight, while page /// numbering is left to the layout and form:ready is another event. diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 796c9827c8..35c756bd4f 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -682,13 +682,60 @@ fn parse_node(c: XmlNode, warnings: &mut Vec, depth: usize) -> Option bool { + own_page_role(script, formcalc).is_some() +} + +/// The page-number role a `layout:ready` script gives its own field ([`numbers_pages`]): the page +/// count when what it assigns calls `pageCount()`, else the page number. +fn own_page_role(script: &str, formcalc: bool) -> Option { let code = code_of(script, formcalc); - code.contains("pageCount") || code.contains("layout.page(") || code.contains("layout.absPage(") + let own = own_assignments(&code, formcalc); + if own.iter().any(|rhs| calls_layout(rhs, "pageCount")) { + Some(PageRole::Count) + } else if own.iter().any(|rhs| calls_layout(rhs, "page") || calls_layout(rhs, "absPage")) { + Some(PageRole::Number) + } else { + None + } +} + +/// What the statements of `code` assign to the script's own field: the text after `=` of each +/// `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`). Statements end at a line +/// break, and in JavaScript at `;` too. +fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> { + let targets: &[&str] = if formcalc { &["this.rawValue", "$.rawValue", "$"] } else { &["this.rawValue"] }; + code.split(|c| c == '\n' || (!formcalc && c == ';')) + .filter_map(|statement| { + let statement = statement.trim(); + targets.iter().find_map(|t| { + let rhs = statement.strip_prefix(t)?.trim_start().strip_prefix('=')?; + (!rhs.starts_with('=')).then_some(rhs) + }) + }) + .collect() +} + +/// Whether `code` calls `layout.(…)`: the method's whole name after `layout.` (spaces +/// allowed around the dot), then `(` after any spaces. +fn calls_layout(code: &str, method: &str) -> bool { + let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; + code.match_indices(method).any(|(i, _)| { + let before = code.get(..i).unwrap_or_default().trim_end(); + let after = code.get(i + method.len()..).unwrap_or_default(); + !after.chars().next().is_some_and(ident) + && after.trim_start().starts_with('(') + && before + .strip_suffix('.') + .map(str::trim_end) + .and_then(|b| b.strip_suffix("layout")) + .is_some_and(|rest| !rest.chars().next_back().is_some_and(ident)) + }) } /// `script` with its comments and string literals blanked out: `//` and `/* */` comments and @@ -739,14 +786,8 @@ fn code_of(script: &str, formcalc: bool) -> String { /// Which page-number role a field with a layout:ready script plays, if any. fn page_role(f: &Field) -> Option { - let script = f.scripts.iter().find(|s| s.activity == "ready" || s.activity == "layoutReady")?; - if !numbers_pages(&script.text, script.formcalc) { - None - } else if code_of(&script.text, script.formcalc).contains("pageCount") { - Some(PageRole::Count) - } else { - Some(PageRole::Number) - } + // Any of its layout:ready scripts, past a form:ready one. + f.scripts.iter().filter(|s| s.activity == "layoutReady").find_map(|s| own_page_role(&s.text, s.formcalc)) } fn collect_page_roles(nodes: &[Node], out: &mut Vec<(String, PageRole)>, depth: usize) { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d79b5a3e37..5ddd409df8 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3537,6 +3537,27 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { /// `ready` is two events: `ref="$layout"` is layout:ready (`layoutReady`), anything else stays /// form:ready (`ready`), in the template and in the live form; page numbering is recognized. +/// A field's page-number role comes from its layout:ready scripts, also when a form:ready script +/// comes first; a field whose layout:ready script only reads the count gets none. +#[test] +fn page_roles_come_from_layout_ready_scripts() { + let xdp = r##" +"##; + let (tpl, _) = parse(xdp).unwrap(); + assert_eq!(tpl.page_roles, vec![("no".to_string(), PageRole::Number)]); +} + #[test] fn a_layout_ready_event_is_told_apart_from_form_ready() { let (tpl, _) = parse(&crate::fixtures::layout_ready_template()).unwrap(); @@ -3565,6 +3586,16 @@ fn a_layout_ready_event_is_told_apart_from_form_ready() { assert!(formcalc("$ = xfa.layout.pageCount()")); assert!(!formcalc("; xfa.layout.pageCount()\n$ = \"pageCount \"\" here\"")); assert!(formcalc("$ = \"a;b\" ; not a comment's end\n$ = xfa.layout.page($)"), "a ; inside a string isn't a comment"); + // Only a script that gives its own field the page call's result numbers that field. + assert!(!js("other.rawValue = xfa.layout.pageCount(); this.rawValue = 'x';"), "it numbers another field"); + assert!(!js("var n = xfa.layout.pageCount(); this.rawValue = 'pages: ' + n;"), "it only reads the count"); + assert!(!js("if (this.rawValue == xfa.layout.page(this)) { }"), "a comparison, not an assignment"); + assert!(formcalc("$.rawValue = xfa.layout.page($)")); + // The calls are matched as whole names, spaces allowed before the parenthesis. + assert!(!js("var pageCounter = 1; this.rawValue = pageCounter;")); + assert!(!js("this.rawValue = mylayout.page(this);")); + assert!(js("this.rawValue = xfa.layout.page (this);")); + assert!(js("this.rawValue = xfa . layout . absPage(this);")); } /// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first From ba33e3c432e1ed110bef2d0aa5a7cd4a2a24c6bd Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:08:36 -0400 Subject: [PATCH 040/140] M12: the required-fields test checks that radio buttons carry no Required flag of their own --- crates/xfa/src/tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f662cc527c..fd3eb294eb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -388,6 +388,19 @@ fn fields_that_must_be_filled_in_are_required() { assert_eq!(leaf("answer"), Some(true), "the radio group field"); assert_eq!(leaf("optional"), Some(false)); assert_eq!(leaf("second"), Some(true), "a later button that must be chosen makes its group required"); + // The buttons themselves carry no Required flag: theirs are the group's. + for group in ["answer", "second"] { + let field = fields + .iter() + .map(|f| doc.resolve(f).as_dict().unwrap().clone()) + .find(|d| d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).is_some_and(|t| t.contains(group))); + let kids = field.and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k).as_array().cloned().unwrap_or_default())).unwrap_or_default(); + assert!(!kids.is_empty(), "{group} has its buttons as kids"); + for kid in &kids { + let ff = doc.resolve(kid).as_dict().and_then(|d| d.int(b"Ff")).unwrap_or(0); + assert_eq!(ff & 2, 0, "{group}: a button is not Required itself"); + } + } } /// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported From 9b5598b07125270ae78445a0fee0f0b3fcf8fc48 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:10:18 -0400 Subject: [PATCH 041/140] M12: a layout:ready assignment written over several lines is read whole --- crates/xfa/src/parse.rs | 68 ++++++++++++++++++++++++++++++----------- crates/xfa/src/tests.rs | 8 +++++ 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 35c756bd4f..373cf5e3ad 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -705,20 +705,53 @@ fn own_page_role(script: &str, formcalc: bool) -> Option { } } -/// What the statements of `code` assign to the script's own field: the text after `=` of each -/// `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`). Statements end at a line -/// break, and in JavaScript at `;` too. +/// What the statements of `code` assign to the script's own field: the expression after `=` of +/// each `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`) that starts a +/// statement. The expression runs to a `;` (JavaScript) or to the end of a line that completes it: +/// brackets closed and not ending in an operator, so `this.rawValue =` with its value on the next +/// line, or a sum broken over lines, is read whole. fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> { let targets: &[&str] = if formcalc { &["this.rawValue", "$.rawValue", "$"] } else { &["this.rawValue"] }; - code.split(|c| c == '\n' || (!formcalc && c == ';')) - .filter_map(|statement| { - let statement = statement.trim(); - targets.iter().find_map(|t| { - let rhs = statement.strip_prefix(t)?.trim_start().strip_prefix('=')?; - (!rhs.starts_with('=')).then_some(rhs) - }) - }) - .collect() + let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$' || c == '.'; + let mut out = Vec::new(); + for t in targets { + for (i, _) in code.match_indices(t) { + // A statement's start: nothing before it on its line but spaces, or a `;`, `{` or `}`. + let before = code.get(..i).unwrap_or_default().trim_end_matches([' ', '\t']); + if !(before.is_empty() || before.ends_with(['\n', '\r', ';', '{', '}'])) { + continue; + } + let Some(rest) = code.get(i + t.len()..) else { continue }; + // `$` is the whole name (not `$record`, not `$.rawValue`, a target of its own). + if rest.chars().next().is_some_and(ident) { + continue; + } + let Some(rhs) = rest.trim_start().strip_prefix('=').filter(|r| !r.starts_with('=')) else { continue }; + out.push(expression(rhs, formcalc)); + } + } + out +} + +/// The expression at the start of `rhs` (what follows an `=`): to a `;` outside brackets in +/// JavaScript, or to a line break once something is written, the brackets are closed and the +/// line doesn't end in an operator that continues it. +fn expression(rhs: &str, formcalc: bool) -> &str { + let mut depth = 0i32; + let mut last: Option = None; + for (j, c) in rhs.char_indices() { + match c { + '(' | '[' | '{' => depth = depth.saturating_add(1), + ')' | ']' | '}' => depth = depth.saturating_sub(1), + ';' if !formcalc && depth <= 0 => return rhs.get(..j).unwrap_or(rhs), + '\n' if depth <= 0 && last.is_some_and(|l| !"+-*/%=,(&|?:.<>!".contains(l)) => return rhs.get(..j).unwrap_or(rhs), + _ => {} + } + if !c.is_whitespace() { + last = Some(c); + } + } + rhs } /// Whether `code` calls `layout.(…)`: the method's whole name after `layout.` (spaces @@ -738,10 +771,10 @@ fn calls_layout(code: &str, method: &str) -> bool { }) } -/// `script` with its comments and string literals blanked out: `//` and `/* */` comments and -/// `'…'`, `"…"` and `` `…` `` strings (with `\` escapes) in JavaScript; `;` and `//` comments and -/// `"…"` strings (a doubled `""` inside) in FormCalc. Line breaks stay. An unclosed comment or -/// string runs to the end. +/// `script` with its comments blanked out and each string literal left as a bare `"`: `//` and +/// `/* */` comments and `'…'`, `"…"` and `` `…` `` strings (with `\` escapes) in JavaScript; `;` +/// and `//` comments and `"…"` strings (a doubled `""` inside) in FormCalc. Line breaks stay. An +/// unclosed comment or string runs to the end. fn code_of(script: &str, formcalc: bool) -> String { let mut out = String::with_capacity(script.len()); let mut chars = script.chars().peekable(); @@ -776,7 +809,8 @@ fn code_of(script: &str, formcalc: bool) -> String { } } } - out.push(' '); + // A mark where the string was: the expression it ends is still seen to end there. + out.push('"'); } else { out.push(c); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 5ddd409df8..2324fda380 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3596,6 +3596,14 @@ fn a_layout_ready_event_is_told_apart_from_form_ready() { assert!(!js("this.rawValue = mylayout.page(this);")); assert!(js("this.rawValue = xfa.layout.page (this);")); assert!(js("this.rawValue = xfa . layout . absPage(this);")); + // An assignment over several lines is read whole; the next statement is not part of it. + assert!(js("this.rawValue =\n xfa.layout.page(this);")); + assert!(js("this.rawValue = 'Page ' +\n xfa.layout.page(this)")); + assert!(!js("this.rawValue = 'x'\nother.rawValue = xfa.layout.page(this)"), "the next line is another statement"); + assert!(js("if (true) { this.rawValue = xfa.layout.page(this) }")); + assert!(!js("that.this.rawValue = xfa.layout.page(this);"), "not the script's own field"); + assert!(formcalc("$ =\n xfa.layout.page($)")); + assert!(!formcalc("$record.x = xfa.layout.page($)"), "not the field"); } /// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first From 0b7839d1e3b27c6a7b886a31bc16aaa99024725b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:17:13 -0400 Subject: [PATCH 042/140] M12: the page-number assignment scan reads a bounded number of bounded expressions --- crates/xfa/src/parse.rs | 12 +++++++++++- crates/xfa/src/tests.rs | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 373cf5e3ad..61f6610212 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -705,6 +705,11 @@ fn own_page_role(script: &str, formcalc: bool) -> Option { } } +/// Most places a name is looked at as an assignment to the script's own field ([`own_assignments`]). +const MAX_ASSIGNMENTS: usize = 256; +/// Most bytes of an assignment's expression read ([`expression`]); a page-number expression is short. +const MAX_EXPRESSION: usize = 4096; + /// What the statements of `code` assign to the script's own field: the expression after `=` of /// each `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`) that starts a /// statement. The expression runs to a `;` (JavaScript) or to the end of a line that completes it: @@ -715,7 +720,9 @@ fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> { let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$' || c == '.'; let mut out = Vec::new(); for t in targets { - for (i, _) in code.match_indices(t) { + // At most MAX_ASSIGNMENTS places looked at per name, each expression read at most + // MAX_EXPRESSION bytes: a hostile script can't make this scan quadratic. + for (i, _) in code.match_indices(t).take(MAX_ASSIGNMENTS) { // A statement's start: nothing before it on its line but spaces, or a `;`, `{` or `}`. let before = code.get(..i).unwrap_or_default().trim_end_matches([' ', '\t']); if !(before.is_empty() || before.ends_with(['\n', '\r', ';', '{', '}'])) { @@ -737,6 +744,9 @@ fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> { /// JavaScript, or to a line break once something is written, the brackets are closed and the /// line doesn't end in an operator that continues it. fn expression(rhs: &str, formcalc: bool) -> &str { + // Read no further than MAX_EXPRESSION bytes (cut at a character boundary). + let cap = (0..=MAX_EXPRESSION.min(rhs.len())).rev().find(|&n| rhs.is_char_boundary(n)).unwrap_or(0); + let rhs = rhs.get(..cap).unwrap_or_default(); let mut depth = 0i32; let mut last: Option = None; for (j, c) in rhs.char_indices() { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 2324fda380..969e958aeb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3604,6 +3604,12 @@ fn a_layout_ready_event_is_told_apart_from_form_ready() { assert!(!js("that.this.rawValue = xfa.layout.page(this);"), "not the script's own field"); assert!(formcalc("$ =\n xfa.layout.page($)")); assert!(!formcalc("$record.x = xfa.layout.page($)"), "not the field"); + // A hostile script (a bracket left open, then a name repeated many times) is read in bounded + // time: each expression stops at its length cap, and only so many places are looked at. + let hostile = "$ = (\n".repeat(200_000); + let started = std::time::Instant::now(); + assert!(!formcalc(&hostile)); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); } /// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first From aad694b6adbd15b990e8fa3a715a75b527d080f0 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:49:52 -0400 Subject: [PATCH 043/140] M12: a widget index the field doesn't have runs no focus scripts widget_som fell back to the field's own SOM path for an index out of range, so for a radio field it ran the group's scripts. It now returns None unless the field has that widget. --- crates/engine/src/tests.rs | 13 +++++++++++++ crates/engine/src/xfa.rs | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0f0ac30cdf..2dba866fe0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5266,6 +5266,19 @@ fn focus_scripts_that_change_nothing_leave_the_document_clean() { assert!(!s.get(id).unwrap().dirty); } +/// A widget the field doesn't have gains or loses no focus: no script runs, rather than the +/// field's own. +#[test] +fn a_widget_the_field_doesnt_have_runs_no_focus_scripts() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + assert!(!s.field_focus(id, "probe", 3, Enter).unwrap(), "probe has one widget"); + assert!(!s.field_focus(id, "probe", usize::MAX, Exit).unwrap()); + assert_eq!((field_text(&s, id, "enters"), field_text(&s, id, "exits")), ("0".into(), "0".into())); + 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 diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index b593f18c4a..bb5ed16e1e 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -936,13 +936,16 @@ pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s } /// 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. +/// and a field's copies on several pages share one. `None` when the field has no widget +/// `widget`: the field's own path is only for a widget that records none, so a widget index +/// out of range never reaches the field (for a radio field, the group). 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)) + let w = field.widgets.get(widget)?; + som_of(w.obj).or_else(|| som_of(field.obj)) } /// Does the live form give SOM path `som` an `activity` script? From 6a1e089a496fc6e81bb072ae0194f18d0ec2c29b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:12:24 -0400 Subject: [PATCH 044/140] M12: committing the form runs the focus scripts of a move not yet run Save, Print, Export, Sign and the other commands commit the form first. A focus move made in the same frame (or the editor they close) ran its enter and exit scripts only afterwards, so the document they read lacked what those scripts change. commit_form_typing now runs them before it returns. Also words the comment on noted exits so a failed exit reads as run. --- crates/engine/src/xfa.rs | 7 ++++-- crates/ui-egui/src/editing.rs | 42 ++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index bb5ed16e1e..2d13376226 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -844,8 +844,11 @@ pub(crate) fn on_changes( for ev in r.events_of(&som, "change") { r.run(&ev, &value)?; } - // Noted only when an exit script ran: one the event's budget skipped, or one an error - // before it stopped, is still to run when the field loses focus. + // Noted only when an exit script ran, whether it succeeded or failed: it has run for this + // focus loss, and a failed script keeps what it changed before its error, so running it + // again would repeat that and the error. One the event's budget skipped, or one never + // reached because an earlier script stopped the run, is still to run when the field + // loses focus. let mut ran_exit = false; for ev in r.events_of(&som, "exit") { ran_exit |= r.run(&ev, &value)?.is_some(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index a4c8785a3e..f3211b9594 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -449,9 +449,11 @@ impl PdfCraftApp { /// Make the active document say what its form shows (#166): apply an edit still queued on /// its tab, then commit the text in a field's open editor, which otherwise commits only on - /// Enter, Tab or clicking away (the editor closes, as clicking away would). Run before - /// anything reads the document: Save, Print, Export, Optimize, Sign, Extract, Split, Copy - /// pages and button scripts. + /// Enter, Tab or clicking away (the editor closes, as clicking away would), then run the + /// XFA enter and exit scripts of a focus move whose scripts haven't run yet (closing the + /// editor is one; so is a move made in the same frame), so the document has what they + /// change too. Run before anything reads the document: Save, Print, Export, Optimize, Sign, + /// Extract, Split, Copy pages and button scripts. /// /// Returns false when the field rejects the value (a validation script or format; the user /// is told why). The editor stays open with the text, and the caller must stop: nothing is @@ -461,14 +463,21 @@ impl PdfCraftApp { if !self.apply_queued_edit(i) { return false; } - let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else { return true }; + let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else { + self.run_focus_scripts(i); + return true; + }; let Some(edit) = self.session.get(id).and_then(|d| crate::forms_ui::draft_edit(&draft, &d.form)) else { - return true; // nothing typed that the document doesn't already have + // Nothing typed that the document doesn't already have. + self.run_focus_scripts(i); + return true; }; if let Some(view) = self.views.get_mut(i) { view.forms.focus = None; } if self.apply_edit(edit) { + // The editor closed: the field's exit runs now, unless its change just ran it. + self.run_focus_scripts(i); return true; } if let Some(view) = self.views.get_mut(i) { @@ -1798,6 +1807,29 @@ mod revert_draft_tests { } } + /// A focus move whose scripts the frame hasn't run yet (Save pressed as the focus reaches a + /// field) runs them before anything reads the document: `hint`'s enter clears its "Type here". + #[test] + fn committing_the_form_runs_the_focus_scripts_of_a_move_not_yet_run() { + let mut app = PdfCraftApp::new(); + app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap(); + let hint = |app: &PdfCraftApp| { + app.session + .get(app.views[0].id) + .unwrap() + .form + .iter() + .find(|f| f.name == "hint") + .and_then(|f| f.value.first().cloned()) + .unwrap_or_default() + }; + assert_eq!(hint(&app), "Type here"); + // As the editor opens on it: its text and value. + app.views[0].forms.focus = Some(Focus { name: "hint".into(), text: "Type here".into(), picked: vec!["Type here".into()], ..focus("") }); + assert!(app.commit_form_typing()); + assert_eq!(hint(&app), "", "hint's enter ran before the document is read"); + assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "and its editor shows it"); + } #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { From af27c3a290980679e1477b093a02c0a917208d37 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:30:04 -0400 Subject: [PATCH 045/140] M12: Revert drops the focus without running the field's exit Revert closed the field editor but kept the record of which field had the focus, so the next frame ran that field's exit on the reverted document and made it unsaved again. The record is cleared with the editor, and the engine forgets the exits that changes ran, as undo does. --- crates/engine/src/lib.rs | 2 ++ crates/ui-egui/src/editing.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 217afa1461..9b6867d4c8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4627,6 +4627,8 @@ impl Session { editor.redo.clear(); Self::adopt_keys(doc); Self::sync_dirty(doc); + // The changes whose exits ran are gone with the rest: skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f3211b9594..e197173c30 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -929,6 +929,9 @@ impl PdfCraftApp { // Revert discards this source's uncommitted typing as well as model edits. // Otherwise a later Save could write a rejected draft back into the file. view.forms.focus = None; + // The focus goes with them, running no exit: a script changing the document + // just reverted would only make it unsaved again. + view.forms.focused = None; view.forms.committed = None; view.forms.unbaked.clear(); view.pending_edit = None; @@ -1807,6 +1810,26 @@ mod revert_draft_tests { } } + /// Revert drops the focus without running the exit of the field that had it: the document + /// is the saved one again, and nothing changes it after. + #[test] + fn revert_runs_no_exit_for_the_field_that_had_the_focus() { + let mut app = PdfCraftApp::new(); + app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap(); + let id = app.views[0].id; + let count = |app: &PdfCraftApp, name: &str| { + app.session.get(id).unwrap().form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned()).unwrap_or_default() + }; + app.views[0].forms.focus = Some(Focus { name: "probe".into(), ..focus("") }); + assert!(app.process_pending_edits()); + assert_eq!(count(&app, "enters"), "1", "reaching probe ran its enter"); + app.revert_active(); + assert_eq!(count(&app, "enters"), "0"); + assert!(app.process_pending_edits()); + assert_eq!(count(&app, "exits"), "0", "no exit ran on the reverted document"); + assert!(!app.session.get(id).unwrap().dirty); + } + /// A focus move whose scripts the frame hasn't run yet (Save pressed as the focus reaches a /// field) runs them before anything reads the document: `hint`'s enter clears its "Type here". #[test] From 4b66608303d4309a5e2bc44fb8f62a424292a37e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:23:07 -0400 Subject: [PATCH 046/140] M6: doc comments on the XFA functions the required flag touches draw_draw and parse_subform, which this branch changes, had none (CodeRabbit's docstring coverage). --- crates/xfa/src/layout.rs | 2 ++ crates/xfa/src/parse.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 863b02e509..5fa42cff19 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1732,6 +1732,8 @@ impl Layouter<'_> { self.push_block(block) } + /// Draw a `draw` element (static text, an image, a line, a rectangle or an arc) in `rect`, + /// inside its border and margin. fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { if let Some(b) = &d.common.border { self.draw_border(b, rect)?; diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 07154a8da1..b2b5c32d9c 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -625,6 +625,8 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS PageSet { areas } } +/// A `subform` element: its own attributes, its page set if it has one, and the nodes it holds, +/// `depth` levels down (for the nesting limit). fn parse_subform(n: XmlNode, warnings: &mut Vec, depth: usize) -> Subform { let common = parse_common(n); let mut children_out = Vec::new(); From a8b4a2b325b4f1dc7df8134b726faca6874b6b17 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:52:46 -0400 Subject: [PATCH 047/140] M12: a dropped runtimeHighlight request leaves the setting as it was, and the page-number scan gathers the fields once --- crates/engine/src/xfa.rs | 23 ++++++++++++++--------- crates/js/src/lib.rs | 16 ++++++++++------ crates/js/src/tests.rs | 10 ++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index f8ba2c4621..130b49e191 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -161,12 +161,17 @@ fn to_js(n: &FormNode) -> XfaNode { } } -/// Whether the live form has a field at `som`. -fn is_field(n: &FormNode, som: &str, depth: usize) -> bool { +/// The SOM paths of the live form's fields under `n`, at most 64 levels deep, into `out`. +fn field_soms(n: &FormNode, out: &mut HashSet, depth: usize) { if depth > 64 { - return false; + return; + } + if n.kind == Some(NodeKind::Field) { + out.insert(n.som.clone()); + } + for c in &n.children { + field_soms(c, out, depth + 1); } - (n.som == som && n.kind == Some(NodeKind::Field)) || n.children.iter().any(|c| is_field(c, som, depth + 1)) } /// The subform or other container at SOM path `som` in the live form under `n` (fields are @@ -863,11 +868,11 @@ pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mu let (ready, numbered) = { let live = r.live(); let ready: Vec = live.events.iter().filter(|e| e.activity == "layoutReady").cloned().collect(); - let numbered: HashSet = ready - .iter() - .filter(|e| pdfcraft_xfa::numbers_pages(&e.script, e.formcalc) && is_field(&live.root, &e.som, 0)) - .map(|e| e.som.clone()) - .collect(); + // The fields, gathered in one walk rather than one per script. + let mut fields = HashSet::new(); + field_soms(&live.root, &mut fields, 0); + let numbered: HashSet = + ready.iter().filter(|e| fields.contains(&e.som) && pdfcraft_xfa::numbers_pages(&e.script, e.formcalc)).map(|e| e.som.clone()).collect(); (ready, numbered) }; r.numbered = numbered; diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index fa56d1b177..8f7265ef82 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -281,10 +281,10 @@ impl Host { /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] /// requests and the run's byte budget. - fn request(&mut self, r: Request) { + fn request(&mut self, r: Request) -> bool { if self.requests.len() >= xfa::MAX_EFFECTS { self.dropped = self.dropped.saturating_add(1); - return; + return false; } let r = match r { Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), @@ -298,9 +298,11 @@ impl Host { Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), _ => 0, }; - if self.room(bytes) { + let kept = self.room(bytes); + if kept { self.requests.push(r); } + kept } } @@ -689,11 +691,13 @@ fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js let on = arg(args, 0).to_boolean(); let mut h = host(ctx)?.borrow_mut(); if h.doc.highlight != on { - h.doc.highlight = on; // Only the setting the script ends with matters: one request, the latest, under the - // host's request cap like any other. + // host's request cap like any other. One the cap drops changes nothing, so the script + // goes on reading the setting the viewer has. h.requests.retain(|r| !matches!(r, Request::Highlight(_))); - h.request(Request::Highlight(on)); + if h.request(Request::Highlight(on)) { + h.doc.highlight = on; + } } Ok(JsValue::undefined()) } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c0922a152c..cf17a8e4d8 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1572,6 +1572,16 @@ mod runtime_highlight { let o = go("for (var i = 0; i < 5001; i++) { app.runtimeHighlight = !app.runtimeHighlight; }", &ev); assert_eq!(o.error, None); assert_eq!(o.requests, vec![Request::Highlight(true)], "one request, the last setting"); + // Past the request cap the request is dropped, and the script goes on reading the + // setting the viewer has. + let full = format!( + "for (var i = 0; i < {}; i++) app.execMenuItem('Print'); app.runtimeHighlight = true; event.value = String(app.runtimeHighlight);", + crate::xfa::MAX_EFFECTS + ); + let o = go(&full, &ev); + assert_eq!(o.error, None); + assert_eq!(o.value, "false", "the dropped request changed nothing"); + assert!(!o.requests.iter().any(|r| matches!(r, Request::Highlight(_)))); } /// The same in XFA scripts, where the change is an effect; nothing is asked for when the From 4bd8a755040b82998f4c3149ecfd2143ff7082a9 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:40:21 -0400 Subject: [PATCH 048/140] M12: the calculations after a focus script get the session's time scale too --- crates/engine/src/xfa.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 71b40aa956..8a289ae211 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -938,7 +938,7 @@ pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s } if !r.changes.is_empty() { // The calculations that follow get a budget of their own. - r.clock = Clock::start(OPEN_BUDGET_MS); + r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale))); r.calculates()?; } r.finish() From a7d4d9a247f8113036dba1173da062f36fb0df4d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:03:16 -0400 Subject: [PATCH 049/140] M12: form_focus refuses a field with no widget instead of reporting a focus change that ran nothing --- crates/automation/src/a11y.rs | 11 ++++--- crates/automation/tests/it/automation.rs | 40 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 80e816977f..b5ccf17b55 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -769,12 +769,13 @@ impl Automation { 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)")))?; + // A field with no widget (one only scripts or calculations use) has nothing to focus. + let widgets = field.widgets.len(); 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 widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; 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); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index df28def241..2fcff5f74e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7387,6 +7387,46 @@ fn xfa_focus_scripts_run_through_form_focus() { } } +/// form_focus refuses a field with no widget, which nothing can focus, instead of reporting a +/// focus change that ran nothing. +#[test] +fn form_focus_refuses_a_field_with_no_widget() { + let dir = workdir("focus-no-widget"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [60 60 200 80] /P 3 0 R >>", + "<< /FT /Tx /T (total) /V (3) >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("total.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "total.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == "total"), "the field without a widget is listed: {fields}"); + for args in [json!({ "doc": doc, "field": "total", "event": "enter" }), json!({ "doc": doc, "field": "total", "event": "enter", "widget": 1 })] { + let e = err(&mut a, "form_focus", args.clone()).to_string(); + assert!(e.contains("\"total\" has no widget to focus"), "{args}: {e}"); + } + assert_eq!( + ok(&mut a, "form_focus", json!({ "doc": doc, "field": "name", "event": "enter" }))["changed"], + false, + "a field with a widget still takes it" + ); +} + /// 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] From 8745623fca1cbb2372352216e0e970ff9c4a53b1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:39:05 -0400 Subject: [PATCH 050/140] M12: opening a document leaves the field highlight the one in front reads An open set the session's field highlight to the remembered Highlight fields for the new document's scripts, and the frame only set it back to the active document's afterwards: an edit of that document handled in between read the new one's default as app.runtimeHighlight. The open now passes the remembered setting in its own context (OpenContext::with_highlight) and the session's value stays (CodeRabbit, on #4691). --- crates/engine/src/open.rs | 8 ++++++++ crates/ui-egui/src/app/files.rs | 14 +++++++------ crates/ui-egui/tests/it/xfa.rs | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index cfa50dadfa..e2736fbf4d 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -27,6 +27,14 @@ pub struct PreparedDocument { } impl OpenContext { + /// This context with Highlight fields `on`, which the document's scripts read as + /// `app.runtimeHighlight` while it opens. A new document opens with the remembered setting + /// while the session keeps the one of the document in front, which its scripts read. + pub fn with_highlight(mut self, on: bool) -> Self { + self.highlight = on; + self + } + /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( &self, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 5db8e515e2..39caf91032 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -106,9 +106,10 @@ impl PdfCraftApp { if let Some(ctx) = &self.ctx { worker.set_context(ctx); } - self.session.set_field_highlight(self.view_defaults.highlight_fields); - let receiver = - worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + // A new document opens with the remembered Highlight fields, which its scripts read; the + // session keeps the document in front's. + let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); + let receiver = worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?; self.next_open_token = token; self.pending_opens.push(PendingOpen { token, @@ -226,9 +227,10 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; - // A new document opens with the remembered Highlight fields, which its scripts read. - self.session.set_field_highlight(self.view_defaults.highlight_fields); - let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { + // A new document opens with the remembered Highlight fields, which its scripts read; the + // session keeps the document in front's. + let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); + let prepared = match context.prepare_open(name, path.clone(), bytes.clone(), password) { Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index 8012b4a928..de01dfb82a 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -231,6 +231,42 @@ fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() { h.get_by_label("Security settings"); } +/// Opening another document doesn't change the field highlight the document in front's scripts +/// read: the new one opens with the remembered Highlight fields in its own open context. Before, +/// the open set the session's value, so an edit of the first document handled before the next +/// frame read the new document's default as `app.runtimeHighlight`. +#[test] +fn opening_another_document_keeps_the_highlight_the_one_in_front_reads() { + let script = |js: &str| format!(r#""#); + let xdp = format!( + r##" + + + +"##, + look = script("xfa.form.form.seen.rawValue = String(app.runtimeHighlight);"), + ); + let mut h = open(pdfcraft_xfa::fixtures::shell(&xdp)); + let app = h.state_mut(); + // The document in front has Highlight fields on; the remembered setting is off. + app.view_defaults.highlight_fields = false; + app.views[0].highlight_fields = true; + h.run_steps(2); + let app = h.state_mut(); + let first = app.views[0].id; + app.open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap(); + // Before the next frame, the first document's script reads its own setting. + app.session.apply(first, pdfcraft_engine::Edit::XfaEvent { som: "form[0].look[0]".into(), activity: "click".into() }).unwrap(); + let seen = app.session.get(first).unwrap().form.iter().find(|f| f.name == "seen").map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(seen, vec!["true".to_string()], "the first document's script reads its own Highlight fields"); +} + /// A form that turns field highlighting on (`app.runtimeHighlight = true` in a `layout:ready` /// script) gets it, and so does the next document, as with the Highlight fields button. #[test] From 0e57602e259e28aaf62e812e99a9ae34dd620eb3 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:23:41 -0400 Subject: [PATCH 051/140] M12: a push button that must be filled in isn't marked Required --- crates/xfa/src/pdf.rs | 5 +++-- crates/xfa/src/tests.rs | 5 ++++- parity/acrobat-features.toml | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 0904c89304..67c4193f49 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -174,8 +174,9 @@ impl Emitter<'_> { ff |= 1; } // Required, which viewers outline when they highlight a form's fields. A radio button's - // flags are its group's: the group takes it below. - if w.required && !matches!(w.kind, WidgetKind::Radio { .. }) { + // flags are its group's: the group takes it below. A push button keeps no value (ISO + // 32000-2 §12.7.5.2.2), so there is nothing it could be required to hold. + if w.required && !matches!(w.kind, WidgetKind::Radio { .. } | WidgetKind::Button { .. }) { ff |= 1 << 1; } let mut top_level = true; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 33b1c0aede..9ad12f82e9 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -345,7 +345,8 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { /// Required field, which viewers outline when they highlight a form's fields: a radio group on the /// group field, its buttons' flags being the group's (a button of its own that must be chosen /// makes the group required too). `nullTest="warning"` only recommends a value, -/// so it, like no test, leaves the field optional. +/// so it, like no test, leaves the field optional. A push button keeps no value (ISO 32000-2 +/// §12.7.5.2.2), so it is never required. #[test] fn fields_that_must_be_filled_in_are_required() { let xdp = r##" @@ -355,6 +356,7 @@ fn fields_that_must_be_filled_in_are_required() { 10 +