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 01/21] 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 04/21] 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 05/21] 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 06/21] 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 07/21] 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 08/21] 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 {