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/290] 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/290] 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/290] 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/290] 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/290] 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/290] 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 022/290] 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 023/290] 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 024/290] 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 025/290] 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 026/290] 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 3d24d826351d4402e8039fe31e392425cb77c7bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:22:04 -0700 Subject: [PATCH 027/290] M11: PDF Optimizer can discard links to other documents (external cross-references) PDF Optimizer > Discard User Data gets "Discard external cross-references", off by default. It takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with an /F, out of the actions of link annotations and bookmarks, and the actions chained after one through /Next take its place in order. Other annotations, such as form fields, keep their actions. A link left with no action and no /Dest is removed from its page; when the link is an indirect object, that object is rewritten without the action for anything else that refers to it. A bookmark keeps its title, place and children, including a bookmark stored directly inside another one. Links within the document and web links stay. An indirect action that changes is written as a new indirect object, except the first action left at the head of an /A, which is copied into the /A directly when other actions follow it. Objects that use the old action keep it. An /A with more than 100,000 values to check, or one that opens another document and needs more than 100,000 values to rewrite, stops the optimization with OptimizeError::ExternalRefLimit, and so does an outline of more than 100,000 bookmarks. doc_optimize takes discard_external_refs and reports external_links and external_bookmarks. --- crates/automation/src/lib.rs | 3 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 16 + crates/optimize/README.md | 7 + crates/optimize/src/external_refs.rs | 427 +++++++++++++++++++++++ crates/optimize/src/lib.rs | 20 +- crates/optimize/tests/optimize.rs | 402 +++++++++++++++++++++ crates/ui-egui/src/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +- 9 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 crates/optimize/src/external_refs.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d2114ac7bf..f0b24ba0fe 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1258,6 +1258,7 @@ impl Automation { ("flate_unencoded", &mut settings.flate_unencoded), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ("discard_external_refs", &mut settings.discard_external_refs), ] { if let Some(b) = a.opt_bool(key)? { *flag = b; @@ -1297,6 +1298,8 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "external_links": o.external_links, + "external_bookmarks": o.external_bookmarks, "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..aa9fc448a9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1375,7 +1375,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1392,6 +1392,7 @@ pub fn tools() -> Vec { "flate_unencoded": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, + "discard_external_refs": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, }), &["doc", "path"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..1bef7c6315 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7352,6 +7352,22 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { assert_eq!(reopened["pages"], 1); } +#[test] +fn optimizing_discards_links_to_other_documents_through_tools() { + let dir = workdir("optimize-external-refs"); + std::fs::write(dir.join("remote.pdf"), remote_link_pdf("other.pdf", None)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "remote.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["external_links"], 0, "{kept}"); + let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": kept }))["count"], 1); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_external_refs": true })); + assert_eq!((r["external_links"].as_u64(), r["external_bookmarks"].as_u64()), (Some(1), Some(0)), "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": small }))["count"], 0); +} + /// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second /// page is turned with /Rotate 90. fn table_pdf() -> Vec { diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3a..199c20e186 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -31,6 +31,13 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes). Streams with `/DecodeParms`, images and object streams are left alone. +- **Discard external cross-references** (off by default): `GoToR`, `GoToE`, `Launch` and + `ImportData` actions, and `Thread` actions with an `/F`, are taken out of link annotations and + bookmarks, and the actions chained after one through `/Next` take its place. Other annotations, + such as form fields, keep their actions. A link left with no action and no `/Dest` is removed + from its page, and its object, when something else refers to it, keeps the link without the + action; a bookmark keeps its title and children. Links within the document and web links + (`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs new file mode 100644 index 0000000000..2df86175e0 --- /dev/null +++ b/crates/optimize/src/external_refs.rs @@ -0,0 +1,427 @@ +//! Discard User Data ▸ Discard external cross-references: links and bookmarks that open another +//! document. +//! +//! An action opens another document when its `/S` is `GoToR` (another PDF file), `GoToE` (a +//! document embedded in this one, or the one this is embedded in), `Launch` (a file or an +//! application), `ImportData` (form data read from another file) or `Thread` with an `/F` (an +//! article in another file). Such an action is taken out of the `/A` of a link annotation +//! (`/Subtype /Link`) or a bookmark, and the actions chained after it through `/Next` take its +//! place in order. Other annotations, such as form fields, keep their actions. A link left with no +//! action and no `/Dest` is removed from its page; when the link is stored as its own object, that +//! object keeps the link without its action for anything else that refers to it (the structure +//! tree, for example). A bookmark stays, with its title and children. Links that jump within the +//! document (`/Dest`, `GoTo`) and web links (`URI`) are kept. An `/A` that takes more than +//! [`MAX_STEPS`] values to check, or whose actions open another document and take more than that +//! to rewrite, stops the optimization with an error, and so does an outline of more than +//! [`MAX_OUTLINE`] items or with items stored inside each other more than [`MAX_DEPTH`] levels +//! deep. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +/// Action types that open or read another document (`Thread` does when it has an `/F`). +const EXTERNAL: [&[u8]; 4] = [b"GoToR", b"GoToE", b"Launch", b"ImportData"]; + +/// How many levels (actions, `/Next` arrays and references) a chain is rewritten. Past it, the rest +/// of the chain is kept when it opens no other document, and removed when it does. +const MAX_DEPTH: usize = 512; + +/// How many values each pass over one `/A` looks at: the check for actions to other documents, +/// then the rewrite with its check past [`MAX_DEPTH`]. A chain that needs more in either pass +/// stops the optimization with [`OptimizeError::ExternalRefLimit`]. +const MAX_STEPS: usize = 100_000; + +/// How many bookmarks the outline walk looks at before it stops the optimization with +/// [`OptimizeError::ExternalRefLimit`]. +const MAX_OUTLINE: usize = 100_000; + +/// What is left of an action or a list of actions. +enum Kept { + /// Nothing changed: keep the object as it is stored. + Same, + /// These actions, in order, replace it. + Now(Vec), +} + +#[derive(Default)] +struct Walk { + /// References on the current chain, to end a chain that comes back to itself. + path: Vec, + /// Indirect objects from which an action to another document can be reached. + dirty: HashSet, + /// Actions to another document taken out. + removed: usize, + /// A chain that opens another document past [`MAX_DEPTH`] was cut there. + cut: bool, + /// Values looked at so far, against [`MAX_STEPS`]. + steps: usize, + /// The walk ran out of steps; its result is not used. + exhausted: bool, +} + +fn is_external(doc: &Document, d: &Dict) -> bool { + let Some(s) = d.get(b"S").map(|s| doc.resolve(s)) else { return false }; + match s.as_name() { + Some(b"Thread") => d.get(b"F").is_some_and(|f| !matches!(doc.resolve(f).as_ref(), Object::Null)), + Some(n) => EXTERNAL.contains(&n), + None => false, + } +} + +/// The actions an unchanged `/Next` value stands for, in order. +fn listed(doc: &Document, next: &Object) -> Vec { + match doc.resolve(next).as_ref() { + Object::Array(a) => a.clone(), + Object::Dict(_) => vec![next.clone()], + _ => Vec::new(), + } +} + +impl Walk { + /// Count one value; false once the walk has run out of steps. + fn step(&mut self) -> bool { + self.steps += 1; + if self.steps > MAX_STEPS { + self.exhausted = true; + } + !self.exhausted + } + + /// Whether the actions stored directly in `o` include one to another document; the references + /// they hold are added to `refs`. + fn scan(&mut self, doc: &Document, o: &Object, refs: &mut Vec) -> bool { + let mut stack = vec![o]; + let mut external = false; + while let Some(o) = stack.pop() { + if !self.step() { + return false; + } + match o { + Object::Ref(r) => refs.push(*r), + Object::Array(items) => stack.extend(items), + Object::Dict(d) => { + external |= is_external(doc, d); + if let Some(n) = d.get(b"Next") { + stack.push(n); + } + } + _ => {} + } + } + external + } + + /// Mark the indirect objects reachable from `a` that lead to an action to another document. + /// Each indirect object is looked at once. + fn reach(&mut self, doc: &Document, a: &Object) { + let mut todo = Vec::new(); + self.scan(doc, a, &mut todo); + let mut seen = HashSet::new(); + let mut parents: HashMap> = HashMap::new(); + let mut found = Vec::new(); + while let Some(r) = todo.pop() { + if self.exhausted { + return; + } + if !seen.insert(r) { + continue; + } + let target = doc.get(r); + let mut refs = Vec::new(); + if self.scan(doc, &target, &mut refs) { + found.push(r); + } + for c in refs { + parents.entry(c).or_default().push(r); + todo.push(c); + } + } + while let Some(r) = found.pop() { + if self.dirty.insert(r) + && let Some(p) = parents.get(&r) + { + found.extend(p); + } + } + } + + /// Whether `o` or an action chained after it opens another document. Needs [`Walk::reach`] + /// to have run on an `/A` that holds `o`. + fn holds_external(&mut self, doc: &Document, o: &Object) -> bool { + let mut refs = Vec::new(); + let external = self.scan(doc, o, &mut refs); + external || refs.iter().any(|r| self.dirty.contains(r)) + } +} + +impl Walk { + /// Indirect actions that change are written as new indirect objects (the old ones stay for + /// whatever else uses them), so a rewritten chain nests no deeper than the original. + fn strip(&mut self, doc: &mut Document, o: &Object, depth: usize) -> Kept { + if !self.step() { + return Kept::Same; + } + if depth > MAX_DEPTH { + if !self.holds_external(doc, o) { + return Kept::Same; + } + self.cut = true; + return Kept::Now(Vec::new()); + } + match o { + Object::Ref(r) => { + if !self.dirty.contains(r) { + return Kept::Same; + } + if self.path.contains(r) { + return Kept::Now(Vec::new()); + } + self.path.push(*r); + let target = doc.get(*r); + let kept = self.strip(doc, &target, depth + 1); + self.path.pop(); + match kept { + Kept::Now(mut v) if v.len() == 1 && matches!(v.first(), Some(Object::Dict(_))) => { + let d = v.remove(0); + Kept::Now(vec![Object::Ref(doc.add(d))]) + } + other => other, + } + } + Object::Array(items) => { + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.strip(doc, item, depth + 1) { + Kept::Same => out.push(item.clone()), + Kept::Now(v) => { + changed = true; + out.extend(v); + } + } + } + if changed { Kept::Now(out) } else { Kept::Same } + } + Object::Dict(d) => { + let next = d.get(b"Next"); + let rest = next.map(|n| self.strip(doc, n, depth + 1)); + if is_external(doc, d) { + self.removed += 1; + return Kept::Now(match (rest, next) { + (Some(Kept::Now(v)), _) => v, + (Some(Kept::Same), Some(n)) => listed(doc, n), + _ => Vec::new(), + }); + } + match rest { + Some(Kept::Now(v)) => { + let mut d = d.clone(); + set_next(&mut d, v); + Kept::Now(vec![Object::Dict(d)]) + } + _ => Kept::Same, + } + } + _ => Kept::Same, + } + } +} + +fn set_next(d: &mut Dict, mut v: Vec) { + match v.len() { + 0 => { + d.remove(b"Next"); + } + 1 => d.set(b"Next".to_vec(), v.remove(0)), + _ => d.set(b"Next".to_vec(), Object::Array(v)), + } +} + +/// `/A` of `owner` with its actions to other documents taken out: `None` when nothing changed, +/// `Some(None)` when no action is left, `Some(Some(action))` otherwise (one action, the rest after +/// it). +fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result>, OptimizeError> { + let mut walk = Walk::default(); + // A quick check first, which looks at each indirect object once: a chain with no action to + // another document is left alone however its actions are shared, unless it holds more than + // `MAX_STEPS` values. + walk.reach(doc, a); + let external = walk.holds_external(doc, a); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + if !external { + return Ok(None); + } + walk.steps = 0; + let kept = walk.strip(doc, a, 0); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let Kept::Now(mut v) = kept else { return Ok(None) }; + if walk.removed == 0 && !walk.cut { + return Ok(None); + } + if v.is_empty() { + return Ok(Some(None)); + } + let first = v.remove(0); + if v.is_empty() { + return Ok(Some(Some(first))); + } + let Some(mut d) = doc.resolve(&first).as_dict().cloned() else { return Ok(Some(None)) }; + let mut next = d.get(b"Next").map(|n| listed(doc, n)).unwrap_or_default(); + next.extend(v); + set_next(&mut d, next); + Ok(Some(Some(Object::Dict(d)))) +} + +/// The outline walk: bookmarks stored as their own objects wait on `stack`; those stored inside +/// another bookmark are edited where they are. +#[derive(Default)] +struct Outline { + stack: Vec, + items: usize, + changed: usize, +} + +impl Outline { + /// Take actions to other documents out of the bookmark `d` (or, when `item` is false, the + /// outline dictionary) and of the bookmarks stored inside it. `owner` is the object that holds + /// `d`. Returns whether `d` changed. + fn visit(&mut self, doc: &mut Document, d: &mut Dict, owner: ObjRef, depth: usize, item: bool) -> Result { + let mut changed = false; + if item { + self.items += 1; + if self.items > MAX_OUTLINE { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let stripped = match d.get(b"A") { + Some(a) => strip_action(doc, a, owner)?, + None => None, + }; + if let Some(action) = stripped { + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + self.changed += 1; + changed = true; + } + } + let keys: &[&[u8]] = if item { &[b"Next", b"First"] } else { &[b"First"] }; + for k in keys { + match d.get_mut(k) { + Some(Object::Ref(r)) => self.stack.push(*r), + Some(Object::Dict(c)) => { + if depth >= MAX_DEPTH { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + changed |= self.visit(doc, c, owner, depth + 1, true)?; + } + _ => {} + } + } + Ok(changed) + } +} + +/// Take actions to other documents out of bookmarks, depth-first. Returns the bookmarks changed. +fn discard_bookmarks(doc: &mut Document) -> Result { + let Some(root) = doc.root() else { return Ok(0) }; + let catalog = doc.get(root); + let mut walk = Outline::default(); + let mut seen = HashSet::new(); + match catalog.as_dict().and_then(|c| c.get(b"Outlines")) { + Some(Object::Ref(o)) => { + seen.insert(*o); + if let Some(mut d) = doc.get(*o).as_dict().cloned() + && walk.visit(doc, &mut d, *o, 0, false)? + { + doc.set(*o, Object::Dict(d)); + } + } + Some(Object::Dict(o)) => { + let mut d = o.clone(); + if walk.visit(doc, &mut d, root, 0, false)? { + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(d)))?; + } + } + _ => {} + } + while let Some(r) = walk.stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; + if walk.visit(doc, &mut d, r, 0, true)? { + doc.set(r, Object::Dict(d)); + } + } + Ok(walk.changed) +} + +/// Take actions to other documents out of links and bookmarks. Returns (links, bookmarks) +/// changed; a link counts whether it was removed or kept for its other actions. +pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), OptimizeError> { + let mut links = 0; + for p in pages { + let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; + let Some(annots) = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; + let mut keep = Vec::with_capacity(annots.len()); + let mut changed = false; + for x in &annots { + let Some(mut d) = doc.resolve(x).as_dict().cloned() else { + keep.push(x.clone()); + continue; + }; + let owner = match x { + Object::Ref(r) => *r, + _ => *p, + }; + let link = d.get(b"Subtype").map(|s| doc.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|n| n == b"Link")); + let stripped = match d.get(b"A") { + Some(a) if link => strip_action(doc, a, owner)?, + _ => None, + }; + let Some(action) = stripped else { + keep.push(x.clone()); + continue; + }; + links += 1; + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + let removed = d.get(b"A").is_none() && d.get(b"Dest").is_none(); + match x { + Object::Ref(r) => { + doc.set(*r, Object::Dict(d)); + if removed { + changed = true; + } else { + keep.push(x.clone()); + } + } + _ => { + changed = true; + if !removed { + keep.push(Object::Dict(d)); + } + } + } + } + if changed { + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(keep)))?; + } + } + let bookmarks = discard_bookmarks(doc)?; + Ok((links, bookmarks)) +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..70d9c83100 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -18,11 +18,14 @@ //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine -//! runs alongside. +//! runs alongside. Links that open another document, and bookmark actions that do, are discarded +//! here (Discard User Data ▸ Discard external cross-references, see +//! [`Settings::discard_external_refs`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod external_refs; pub mod fragments; mod images; mod links; @@ -40,6 +43,8 @@ pub enum OptimizeError { NoPages, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), + #[error("object {0} holds too many actions or bookmarks to take out its links to other documents")] + ExternalRefLimit(ObjRef), #[error("the optimization was cancelled")] Cancelled, } @@ -104,6 +109,9 @@ pub struct Settings { pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, + /// Discard User Data: take `GoToR`, `GoToE` and `Launch` actions out of links and bookmarks; + /// a link left with nothing to do is removed. Links within the document and web links stay. + pub discard_external_refs: bool, } impl Default for Settings { @@ -120,6 +128,7 @@ impl Default for Settings { flate_unencoded: true, remove_invalid_links: true, remove_unreferenced_dests: true, + discard_external_refs: false, } } } @@ -182,6 +191,10 @@ pub struct Report { pub invalid_links: usize, pub invalid_bookmarks: usize, pub unreferenced_dests: usize, + /// Links removed (or kept for their other actions) and bookmarks whose action was changed or + /// cleared, because they opened another document (Discard external cross-references). + pub external_links: usize, + pub external_bookmarks: usize, /// Images and forms the pages listed but never drew, dropped (Clean Up). pub unused_xobjects: usize, } @@ -276,6 +289,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: report.invalid_links = l; report.invalid_bookmarks = b; } + if settings.discard_external_refs { + let (l, b) = external_refs::discard(doc, &pages)?; + report.external_links = l; + report.external_bookmarks = b; + } if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6d9a9bb44e..3f59e081b4 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -393,6 +393,408 @@ fn invalid_links_and_unreferenced_destinations_go() { assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0); } +/// An action dictionary of type `s`, followed by `next` when given. +fn action(s: &str, next: Option) -> Object { + let mut d = Dict::new(); + d.set(b"S".to_vec(), Object::name(s)); + match s { + "GoToR" | "Launch" => d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))), + "URI" => d.set(b"URI".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"https://example.com".to_vec()))), + _ => {} + } + if let Some(n) = next { + d.set(b"Next".to_vec(), n); + } + Object::Dict(d) +} + +fn link_with(a: Option, dest: Option) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + if let Some(a) = a { + d.set(b"A".to_vec(), a); + } + if let Some(dest) = dest { + d.set(b"Dest".to_vec(), dest); + } + Object::Dict(d) +} + +/// The action types an `/A` value runs, in order (depth-first through `/Next`), at most 1,000. +fn chain(doc: &Document, a: &Object) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![a.clone()]; + while let Some(o) = stack.pop() { + if out.len() >= 1_000 { + break; + } + match doc.resolve(&o).as_ref() { + Object::Array(items) => stack.extend(items.iter().rev().cloned()), + Object::Dict(d) => { + out.push( + d.get(b"S") + .map(|s| doc.resolve(s)) + .and_then(|s| s.as_name().map(|n| String::from_utf8_lossy(n).into_owned())) + .unwrap_or_default(), + ); + if let Some(n) = d.get(b"Next") { + stack.push(n.clone()); + } + } + _ => {} + } + } + out +} + +/// A one-page document whose page holds `annots`, with one bookmark per action in `bookmarks`. +fn linked_doc(annots: impl FnOnce(&mut Document, ObjRef) -> Vec, bookmarks: &[Object]) -> (Document, ObjRef, Vec) { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let annots = annots(&mut doc, p); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + let outlines = doc.add(Object::Null); + let items: Vec = bookmarks.iter().map(|_| doc.add(Object::Null)).collect(); + for (i, (r, a)) in items.iter().zip(bookmarks).enumerate() { + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(format!("Item {i}").into_bytes()))); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"A".to_vec(), a.clone()); + if let Some(n) = items.get(i + 1) { + item.set(b"Next".to_vec(), Object::Ref(*n)); + } + doc.set(*r, Object::Dict(item)); + } + let mut o = Dict::new(); + if let (Some(first), Some(last)) = (items.first(), items.last()) { + o.set(b"First".to_vec(), Object::Ref(*first)); + o.set(b"Last".to_vec(), Object::Ref(*last)); + } + doc.set(outlines, Object::Dict(o)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(outlines))).unwrap(); + (doc, p, items) +} + +fn annots_of(doc: &Document, p: ObjRef) -> Vec { + let annots = doc.get(p).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().map(|a| doc.resolve(a).as_dict().unwrap().clone()).collect() +} + +#[test] +fn links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay() { + let (doc, p, items) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here.clone()); + let remote = doc.add(link_with(Some(action("GoToR", None)), None)); + vec![ + Object::Ref(remote), + link_with(Some(action("Launch", None)), None), + link_with(Some(action("GoToE", None)), None), + link_with(Some(action("URI", None)), None), + link_with(Some(Object::Dict(goto)), None), + link_with(None, Some(here)), + link_with(Some(action("GoToR", Some(action("URI", None)))), None), + ] + }, + &[action("GoToR", None), action("Named", None)], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + // Off by default: nothing changes. + let mut off = doc.clone(); + let report = optimize(&mut off, &Settings::default()).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks), (0, 0)); + assert_eq!(annots_of(&off, p).len(), 7); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks, report.invalid_links), (4, 1, 0), "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| d.get(b"A").map(|a| chain(&doc, a)).unwrap_or_default()).collect(); + assert_eq!( + left, + [vec!["URI"], vec!["GoTo"], vec![], vec!["URI"]], + "the web link, the jump within and the /Dest link stay; the chained web link keeps its URI" + ); + assert!(annots_of(&doc, p)[2].get(b"Dest").is_some()); + let bookmark = |doc: &Document, r: ObjRef| doc.get(r).as_dict().unwrap().clone(); + assert!(bookmark(&doc, items[0]).get(b"A").is_none(), "the bookmark stays, without its action"); + assert_eq!(bookmark(&doc, items[0]).get(b"Next"), Some(&Object::Ref(items[1])), "and its place in the outline"); + assert_eq!(chain(&doc, bookmark(&doc, items[1]).get(b"A").unwrap()), ["Named"]); +} + +#[test] +fn an_action_to_another_document_hands_its_place_to_the_actions_after_it() { + let (doc, p, _) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + // A shared Launch, stored on its own with an indirect /S, followed by a web link. + let launch_name = doc.add(Object::name("Launch")); + let Object::Dict(mut launch) = action("Launch", Some(action("URI", None))) else { panic!("action() builds a dictionary") }; + launch.set(b"S".to_vec(), Object::Ref(launch_name)); + let launch = doc.add(Object::Dict(launch)); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here); + goto.set( + b"Next".to_vec(), + Object::Array(vec![action("GoToR", Some(action("JavaScript", None))), Object::Ref(launch), action("Named", None)]), + ); + // A remote go-to whose web link comes back to it. + let (a1, a2) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(a1, action("GoToR", Some(Object::Ref(a2)))); + doc.set(a2, action("URI", Some(Object::Ref(a1)))); + // A remote go-to that leads a list: the rest follow the first action left. + let list = action("GoToR", Some(Object::Array(vec![action("SubmitForm", Some(action("Named", None))), action("URI", None)]))); + vec![ + link_with(Some(Object::Dict(goto)), None), + link_with(Some(Object::Ref(a1)), None), + link_with(Some(list), None), + link_with(Some(Object::Ref(launch)), None), + ] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 4, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert_eq!( + left, + [vec!["GoTo", "JavaScript", "URI", "Named"], vec!["URI"], vec!["SubmitForm", "Named", "URI"], vec!["URI"]], + "the actions after a removed one run in their order, and a chain that comes back ends there" + ); +} + +#[test] +fn a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays() { + let deep = |n: usize, last: &str| (0..n).fold(action(last, None), |next, _| action("URI", Some(next))); + let (doc, p, _) = linked_doc(|_, _| vec![link_with(Some(deep(600, "GoToR")), None), link_with(Some(deep(600, "Named")), None)], &[]); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert!(left[0].len() < 600 && left[0].iter().all(|s| s == "URI"), "cut before the remote go-to: {} actions", left[0].len()); + assert_eq!(left[1].len(), 601, "a deep chain with nothing to remove is kept whole"); +} + +#[test] +fn a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving() { + // 130 indirect web links, then a remote go-to: the parser reads at most 128 nested levels. + let mut tail = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let mut r = doc.add(action("GoToR", None)); + for _ in 0..130 { + r = doc.add(action("URI", Some(Object::Ref(r)))); + } + tail = Some(r); + vec![link_with(Some(Object::Ref(r)), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let a = annots_of(&doc, p)[0].get(b"A").cloned().unwrap(); + assert!(matches!(a, Object::Ref(r) if Some(r) != tail), "a new indirect action: {a:?}"); + let old = Object::Ref(tail.unwrap()); + assert_eq!(chain(&doc, &old).last().map(String::as_str), Some("GoToR"), "the old chain is not edited"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let a = annots_of(&reopened, p)[0].get(b"A").cloned().unwrap(); + assert_eq!(chain(&reopened, &a), vec!["URI"; 130], "every web link reads back"); +} + +#[test] +fn shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document() { + use pdfcraft_optimize::OptimizeError; + // Forty levels of indirect actions, each listing the next level twice: 2^40 paths. + let shared = |doc: &mut Document, last: &str| { + let mut r = doc.add(action(last, None)); + for _ in 0..40 { + r = doc.add(action("URI", Some(Object::Array(vec![Object::Ref(r), Object::Ref(r)])))); + } + r + }; + let mut link = None; + let (doc, _, _) = linked_doc( + |doc, _| { + let a = shared(doc, "GoToR"); + let r = doc.add(link_with(Some(Object::Ref(a)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + let r = optimize(&mut doc.clone(), &on); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(o)) if Some(o) == link), "{r:?}"); + let (doc, p, _) = linked_doc( + |doc, _| { + let a = shared(doc, "Named"); + vec![link_with(Some(Object::Ref(a)), None)] + }, + &[], + ); + let before = annots_of(&doc, p); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!(report.external_links, 0, "{report:?}"); + assert_eq!(annots_of(&doc, p), before, "nothing to remove, however the actions are shared"); +} + +#[test] +fn a_shared_chain_without_remote_actions_is_looked_at_once() { + // 600 levels that each list a 2,000-action chain of web links sixty times, then a remote go-to. + let (doc, p, _) = linked_doc( + |doc, _| { + let mut g = doc.add(action("URI", None)); + for _ in 0..2_000 { + g = doc.add(action("URI", Some(Object::Ref(g)))); + } + let a = (0..600).fold(action("GoToR", None), |next, _| { + let mut list = vec![Object::Ref(g); 60]; + list.push(next); + action("URI", Some(Object::Array(list))) + }); + vec![link_with(Some(a), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert!(!chain(&doc, left[0].get(b"A").unwrap()).iter().any(|s| s == "GoToR")); +} + +#[test] +fn a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action() { + let (doc, p, _) = linked_doc( + |doc, _| { + let name = doc.add(Object::name("Link")); + let Object::Dict(mut indirect) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + indirect.set(b"Subtype".to_vec(), Object::Ref(name)); + let Object::Dict(mut widget) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + vec![Object::Dict(indirect), Object::Dict(widget)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1, "the link goes"); + assert_eq!(left[0].name(b"Subtype"), Some(&b"Widget"[..])); + assert_eq!(chain(&doc, left[0].get(b"A").unwrap()), ["GoToR"], "the form field keeps its action"); +} + +#[test] +fn thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay() { + let with_file = |s: &str| { + let Object::Dict(mut d) = action(s, None) else { panic!("action() builds a dictionary") }; + d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))); + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let here = { + let Object::Dict(mut d) = action("Thread", None) else { panic!("action() builds a dictionary") }; + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let (doc, p, _) = linked_doc( + |_, _| vec![link_with(Some(with_file("Thread")), None), link_with(Some(here.clone()), None), link_with(Some(with_file("ImportData")), None)], + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 2, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1); + assert_eq!(left[0].get(b"A"), Some(&here), "the thread in this document stays"); +} + +#[test] +fn an_outline_over_the_limit_stops_the_optimization() { + use pdfcraft_optimize::OptimizeError; + let bookmarks = vec![action("Named", None); 100_001]; + let (doc, _, _) = linked_doc(|_, _| Vec::new(), &bookmarks); + let r = optimize(&mut doc.clone(), &Settings { discard_external_refs: true, ..Settings::default() }); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(_))), "{r:?}"); +} + +#[test] +fn bookmarks_stored_inside_other_bookmarks_lose_their_actions_too() { + let (mut doc, _, _) = linked_doc(|_, _| Vec::new(), &[]); + let bookmark = |title: &str, a: Object| { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(title.as_bytes().to_vec()))); + d.set(b"A".to_vec(), a); + d + }; + let third = doc.add(Object::Dict(bookmark("C", action("GoToR", None)))); + let mut second = bookmark("B", action("Launch", None)); + second.set(b"First".to_vec(), Object::Ref(third)); + let mut first = bookmark("A", action("GoToR", Some(action("Named", None)))); + first.set(b"Next".to_vec(), Object::Dict(second)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Dict(first)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(outlines))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_bookmarks, 3, "{report:?}"); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + let first = catalog.get(b"Outlines").and_then(Object::as_dict).and_then(|o| o.get(b"First")).and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(chain(&doc, first.get(b"A").unwrap()), ["Named"]); + let second = first.get(b"Next").and_then(Object::as_dict).cloned().unwrap(); + assert!(second.get(b"A").is_none()); + assert!(doc.get(third).as_dict().unwrap().get(b"A").is_none()); +} + +#[test] +fn a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action() { + let mut link = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let r = doc.add(link_with(Some(action("GoToR", None)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let mut doc = doc; + let mut objr = Dict::new(); + objr.set(b"Type".to_vec(), Object::name("OBJR")); + objr.set(b"Obj".to_vec(), Object::Ref(link.unwrap())); + objr.set(b"Pg".to_vec(), Object::Ref(p)); + let mut element = Dict::new(); + element.set(b"S".to_vec(), Object::name("Link")); + element.set(b"K".to_vec(), Object::Dict(objr)); + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"K".to_vec(), Object::Dict(element)); + let tree = doc.add(Object::Dict(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert!(annots_of(&doc, p).is_empty(), "the link leaves its page"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let catalog = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let tree = reopened.dict(catalog.get(b"StructTreeRoot").unwrap()).unwrap(); + let element = reopened.dict(tree.get(b"K").unwrap()).unwrap(); + let objr = reopened.dict(element.get(b"K").unwrap()).unwrap(); + let kept = reopened.dict(objr.get(b"Obj").unwrap()).unwrap(); + assert_eq!(kept.name(b"Subtype"), Some(&b"Link"[..])); + assert!(kept.get(b"A").is_none(), "the link's object holds no action to another document: {kept:?}"); +} + #[test] fn progress_is_reported_per_image_and_a_refusal_cancels() { use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..28f2987c97 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -214,6 +214,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo discard_box(ui, &mut d.discard, Hidden::Comments, "Discard all comments, forms and multimedia"); discard_box(ui, &mut d.discard, Hidden::Metadata, "Discard document information and metadata"); discard_box(ui, &mut d.discard, Hidden::Attachments, "Discard all object data (file attachments)"); + ui.add(widgets::checkbox(&mut s.discard_external_refs, tl!("Discard external cross-references"))); discard_box(ui, &mut d.discard, Hidden::PrivateData, "Discard private data of other applications"); discard_box(ui, &mut d.discard, Hidden::HiddenText, "Discard hidden text"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..3c7fac6643 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7526,11 +7526,11 @@ title = "Discard external cross-references" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay", "crates/optimize/tests/optimize.rs::an_action_to_another_document_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays", "crates/optimize/tests/optimize.rs::a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving", "crates/optimize/tests/optimize.rs::shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document", "crates/optimize/tests/optimize.rs::a_shared_chain_without_remote_actions_is_looked_at_once", "crates/optimize/tests/optimize.rs::a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action", "crates/optimize/tests/optimize.rs::thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay", "crates/optimize/tests/optimize.rs::an_outline_over_the_limit_stops_the_optimization", "crates/optimize/tests/optimize.rs::bookmarks_stored_inside_other_bookmarks_lose_their_actions_too", "crates/optimize/tests/optimize.rs::a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action", "crates/automation/tests/it/automation.rs::optimizing_discards_links_to_other_documents_through_tools"] +notes = "PDF Optimizer ▸ Discard User Data, off by default. Takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with an /F, out of the /A of link annotations (/Subtype /Link, stored directly or as an indirect name) and bookmarks, and the actions chained after one through /Next take its place in order. A link left with no action and no /Dest is removed from /Annots, and when the link is an indirect object its object is rewritten without the action for its other users (the structure tree, for example); a bookmark keeps its title and children. Bookmarks stored directly inside another bookmark or the outline dictionary are changed in place; an outline of more than 100,000 items, or with direct items nested more than 512 levels deep, stops the optimization with an error. Links within the document (/Dest, GoTo) and web links (URI) stay. An indirect action that changes is written as a new indirect object, except the first action left at the head of an /A, which is copied into the /A directly when other actions follow it; the old one stays for its other users. Past 512 nested levels (actions, /Next arrays and references), the rest of a chain is kept when it opens no other document and removed when it does. An /A with more than 100,000 values to check, or one that opens another document and needs more than 100,000 values to rewrite (actions shared so that their paths multiply), stops the optimization with an error. Not changed: /AA triggers, widget and other annotation actions, the catalog /OpenAction, and JavaScript." [[feature]] id = "optimize.discard-hidden-layers" From 98907b3907bfd54efe57e60f7e19dede7f33f9fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:43:33 -0700 Subject: [PATCH 028/290] M11: PDF Optimizer can discard an embedded search index PDF Optimizer > Discard Objects gets "Discard embedded search index", off by default. It removes the entries of the catalog's /PieceInfo whose application name contains "index" in any letter case, such as IBM-ODIndexes, and removes /PieceInfo when nothing else is left in it. Other application data and the pages' own /PieceInfo stay. An indirect /PieceInfo is not edited: the catalog gets a direct copy without those entries, and other objects that refer to the original keep it. The full save leaves out the objects that only the removed entries refer to. doc_optimize takes discard_search_index and reports search_indexes. --- crates/automation/src/lib.rs | 2 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 30 ++++++++ crates/optimize/README.md | 7 ++ crates/optimize/src/lib.rs | 51 +++++++++++++ crates/optimize/tests/optimize.rs | 97 ++++++++++++++++++++++++ crates/ui-egui/src/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +-- 8 files changed, 195 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d2114ac7bf..ada8aee4bb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1254,6 +1254,7 @@ impl Automation { ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), + ("discard_search_index", &mut settings.discard_search_index), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), ("remove_invalid_links", &mut settings.remove_invalid_links), @@ -1292,6 +1293,7 @@ impl Automation { "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, + "search_indexes": o.search_indexes, "tags_removed": o.tags_removed, "streams_compressed": o.streams_compressed, "invalid_links": o.invalid_links, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..1661dfcc40 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1375,7 +1375,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1388,6 +1388,7 @@ pub fn tools() -> Vec { "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, + "discard_search_index": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..327e25d6cd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5685,6 +5685,36 @@ fn signing_with_translated_labels_through_tools() { assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); } +#[test] +fn optimizing_discards_an_embedded_search_index_through_tools() { + use pdfcraft_cos::{Dict, Object}; + let dir = workdir("optimize-search-index"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut info = Dict::new(); + for name in ["IBM-ODIndexes", "Illustrator"] { + let mut data = Dict::new(); + data.set(b"Private".to_vec(), Object::Int(1)); + info.set(name.as_bytes().to_vec(), Object::Dict(data)); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + std::fs::write(dir.join("indexed.pdf"), pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()).unwrap(); + let piece_info = |name: &str| { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let cat = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let info = d.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + }; + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "indexed.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["search_indexes"], 0, "{kept}"); + assert_eq!(piece_info("kept.pdf"), ["IBM-ODIndexes", "Illustrator"]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_search_index": true })); + assert_eq!(r["search_indexes"], 1, "{r}"); + assert_eq!(piece_info("small.pdf"), ["Illustrator"]); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3a..dde9d9f0f4 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -37,3 +37,10 @@ then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a progress bar). JPEG decoding and encoding and resampling come from the `image` crate. + +Discard embedded search index (`discard_search_index`, off by default) removes the entries of +the catalog's `/PieceInfo` whose application name contains "index" in any letter case, such as +`IBM-ODIndexes`, and removes `/PieceInfo` when no other entry is left. An indirect `/PieceInfo` +is copied into the catalog without those entries, and the original object is not changed. The +full save leaves out the objects that only the removed entries refer to. Pages keep their own +`/PieceInfo`. diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..b31a2af8fa 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -15,6 +15,8 @@ //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one. +//! - **Discard embedded search index** (off by default): the search indexes stored in the +//! catalog's `/PieceInfo` (see [`Settings::discard_search_index`]). //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -96,6 +98,11 @@ pub struct Settings { pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, + /// Remove the entries of the catalog's `/PieceInfo` (data an application keeps for the whole + /// document, ISO 32000-2 §14.5) whose application name contains "index" in any letter case, + /// and `/PieceInfo` itself when nothing else is left in it. Objects that only those entries + /// refer to are left out of the saved file. + pub discard_search_index: bool, pub discard_print_settings: bool, /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. @@ -116,6 +123,7 @@ impl Default for Settings { discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, + discard_search_index: false, discard_print_settings: false, flate_unencoded: true, remove_invalid_links: true, @@ -176,6 +184,8 @@ pub struct Report { pub thumbnails: usize, pub alternate_images: usize, pub tags_removed: bool, + /// Search index entries removed from the catalog's `/PieceInfo`. + pub search_indexes: usize, pub print_settings: usize, pub streams_compressed: usize, /// Links removed and bookmarks whose destination was cleared (Clean Up). @@ -247,6 +257,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } report.tags_removed = true; } + if settings.discard_search_index + && let Some(root) = root + { + report.search_indexes = discard_search_index(doc, root)?; + } if settings.discard_print_settings && let Some(root) = root { @@ -335,3 +350,39 @@ fn stored_len(s: &Stream) -> Option { serialize(&Object::Dict(dict), &mut out); out.len().checked_add(s.raw.len()) } + +/// Remove the search index entries of the catalog's `/PieceInfo` ([`Settings::discard_search_index`]) +/// and return how many there were. An indirect `/PieceInfo` is not edited: the catalog gets a +/// direct copy without those entries, so other objects that refer to it keep theirs. The objects +/// the removed entries held are dropped by the full save, which writes only what the trailer +/// reaches. +fn discard_search_index(doc: &mut Document, root: ObjRef) -> Result { + let Some(info) = doc.get(root).as_dict().and_then(|c| c.get(b"PieceInfo").cloned()) else { + return Ok(0); + }; + let (info, removed) = match &*doc.resolve(&info) { + Object::Dict(d) => { + let kept: pdfcraft_cos::Dict = d.iter().filter(|(k, _)| !is_search_index(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + let removed = d.len().saturating_sub(kept.len()); + (kept, removed) + } + _ => return Ok(0), + }; + if removed == 0 { + return Ok(0); + } + doc.update_dict(root, |c| { + if info.is_empty() { + c.remove(b"PieceInfo"); + } else { + c.set(b"PieceInfo".to_vec(), Object::Dict(info)); + } + })?; + Ok(removed) +} + +/// Whether a `/PieceInfo` application name names a search index: it contains "index" in any +/// letter case (such as `IBM-ODIndexes`). +fn is_search_index(name: &[u8]) -> bool { + name.windows(5).any(|w| w.eq_ignore_ascii_case(b"index")) +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6d9a9bb44e..bd3f3a3880 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1139,3 +1139,100 @@ fn named_dest_keys(doc: &Document) -> Vec> { let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() } + +/// A `/PieceInfo` data dictionary holding `private`. +fn app_data(private: Object) -> Object { + let mut d = Dict::new(); + d.set(b"LastModified".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"D:20260101000000Z".to_vec()))); + d.set(b"Private".to_vec(), private); + Object::Dict(d) +} + +fn marked(doc: &mut Document, marker: &str, extra: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Marker".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(marker.as_bytes().to_vec()))); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Dict(d)) +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() +} + +fn contains(bytes: &[u8], text: &str) -> bool { + bytes.windows(text.len()).any(|w| w == text.as_bytes()) +} + +#[test] +fn search_indexes_in_the_catalog_go_and_other_application_data_stays() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + // The first index holds two objects that refer to each other, and one the page also uses. + let shared = marked(&mut doc, "SHARED-DATA", &[]); + let a = marked(&mut doc, "INDEX-A", &[("Shared", Object::Ref(shared))]); + let b = marked(&mut doc, "INDEX-B", &[("Next", Object::Ref(a))]); + doc.update_dict(a, |d| d.set(b"Next".to_vec(), Object::Ref(b))).unwrap(); + let c = marked(&mut doc, "INDEX-C", &[]); + let keep = marked(&mut doc, "KEEP-APP", &[]); + let mut info = Dict::new(); + info.set(b"IBM-ODIndexes".to_vec(), app_data(Object::Ref(a))); + info.set(b"MySearchINDEX".to_vec(), app_data(Object::Ref(c))); + info.set(b"Illustrator".to_vec(), app_data(Object::Ref(keep))); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + let mut page_info = Dict::new(); + page_info.set(b"PageIndexer".to_vec(), app_data(Object::Ref(shared))); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Dict(page_info))).unwrap(); + + // Off by default: nothing changes. + let mut untouched = doc.clone(); + assert!(!Settings::default().discard_search_index); + assert_eq!(optimize(&mut untouched, &Settings::default()).unwrap().search_indexes, 0); + let bytes = saved(&untouched); + assert!(["INDEX-A", "INDEX-B", "INDEX-C", "KEEP-APP"].iter().all(|m| contains(&bytes, m))); + + let r = optimize(&mut doc, &Settings { discard_search_index: true, ..Settings::default() }).unwrap(); + assert_eq!(r.search_indexes, 2); + let cat = doc.get(root).as_dict().cloned().unwrap(); + let info = cat.get(b"PieceInfo").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(info.iter().map(|(k, _)| k.clone()).collect::>(), vec![b"Illustrator".to_vec()]); + // The saved file leaves out the objects only the indexes held, the cycle included; the + // object the page also uses, the other application's data and the page's own entry stay. + let bytes = saved(&doc); + assert!(!["INDEX-A", "INDEX-B", "INDEX-C"].iter().any(|m| contains(&bytes, m))); + assert!(["SHARED-DATA", "KEEP-APP", "PageIndexer"].iter().all(|m| contains(&bytes, m))); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let cat = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let info = reopened.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + assert!(info.contains(b"Illustrator") && info.len() == 1); +} + +#[test] +fn an_indirect_piece_info_is_left_for_its_other_users() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let idx = marked(&mut doc, "INDEX-DATA", &[]); + let mut info = Dict::new(); + info.set(b"Index".to_vec(), app_data(Object::Ref(idx))); + let info = doc.add(Object::Dict(info)); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + let settings = Settings { discard_search_index: true, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + // The catalog's only entry was an index, so /PieceInfo goes; the page keeps the shared one. + assert_eq!(r.search_indexes, 1); + assert!(!doc.get(root).as_dict().unwrap().contains(b"PieceInfo")); + assert!(doc.get(info).as_dict().unwrap().contains(b"Index")); + assert!(contains(&saved(&doc), "INDEX-DATA")); + + // A /PieceInfo that is not a dictionary is left as it is. + let mut doc = Document::new_empty(); + page(&mut doc, &[], "BT ET"); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Int(7))).unwrap(); + assert_eq!(optimize(&mut doc, &settings).unwrap().search_indexes, 0); + assert_eq!(doc.get(root).as_dict().unwrap().int(b"PieceInfo"), Some(7)); +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..9b485289b4 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -205,6 +205,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images"))); ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); + ui.add(widgets::checkbox(&mut s.discard_search_index, tl!("Discard embedded search index"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..bbcf68f66d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7418,11 +7418,11 @@ title = "Discard embedded search index" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::search_indexes_in_the_catalog_go_and_other_application_data_stays", "crates/optimize/tests/optimize.rs::an_indirect_piece_info_is_left_for_its_other_users", "crates/automation/tests/it/automation.rs::optimizing_discards_an_embedded_search_index_through_tools"] +notes = "PDF Optimizer ▸ Discard Objects, off by default. The public help page does not say where the index is stored; PdfCraft's rule: an entry of the catalog's /PieceInfo whose application name contains \"index\" in any letter case (such as IBM-ODIndexes) is a search index and is removed, with /PieceInfo itself when nothing else is left. An indirect /PieceInfo is copied into the catalog without those entries and the original object is left for its other users. The full save leaves out objects only the removed entries reach, cycles included; objects something else also uses stay. Page /PieceInfo is not changed. An index stored under an application name that does not contain \"index\" is kept." [[feature]] id = "optimize.discard-print-settings" 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 029/290] 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 030/290] 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 031/290] 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 bccad5c90d28a01897e7a0d30e15c717c828bbef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:31:50 +0000 Subject: [PATCH 032/290] M3: Previous and Next view let go of a find step's match, as other jumps do (#3530) Previous view while a wheel zoom was still easing kept find_hold, so the rest of the zoom anchored on the old match and the history jump waited until zooming stopped (CodeRabbit on #4139). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/ui-egui/src/canvas.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ebe5dfc6e7..137b2c49eb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1550,6 +1550,8 @@ impl DocView { self.goto = Some((page, 0.0)); self.current = page; self.reset_coarse_state(); + // A find step's match belongs to the place left, as for any other jump. + self.find_hold = None; true } @@ -6209,6 +6211,19 @@ trailer << /Root 1 0 R >> assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); } + #[test] + fn previous_view_after_a_find_step_lets_go_of_the_match() { + let mut v = history_search_view(); + v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))]; + v.find_step(true); + // Previous view while a wheel zoom is still easing: the zoom must not hold the old match + // and put the history jump off until it stops. + assert!(v.view_history(false)); + assert_eq!(v.goto, Some((0, 0.0))); + v.zoom_at(2.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); + } + #[test] fn a_match_with_unbounded_glyphs_falls_back_to_its_page() { let mut v = history_search_view(); From 8de27ff9add9bd4124c15e6ca706a72afa0191ab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:01:32 -0700 Subject: [PATCH 033/290] Optimize: a removed external action is not replaced by a null in its /Next When an action to another document was removed and its /Next list started with a null or a reference to something other than an action, that entry became the head of the chain, and the actions after it, such as a web link, were dropped with it. Leading entries that are not action dictionaries are now skipped. The Settings::discard_external_refs doc comment lists every action type the option removes. --- crates/optimize/src/external_refs.rs | 3 +++ crates/optimize/src/lib.rs | 5 +++-- crates/optimize/tests/optimize.rs | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs index 2df86175e0..a9a31bca5a 100644 --- a/crates/optimize/src/external_refs.rs +++ b/crates/optimize/src/external_refs.rs @@ -265,6 +265,9 @@ fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result Date: Sat, 24 Oct 2026 13:27:31 -0400 Subject: [PATCH 034/290] 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 035/290] 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 036/290] 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 037/290] 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 038/290] 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 039/290] 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 040/290] 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 041/290] 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 042/290] 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 047/290] 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 048/290] 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 049/290] 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 050/290] 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 051/290] 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 052/290] 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 053/290] 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 054/290] 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 055/290] 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 056/290] 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 e3f91e9c0b7c7f69f3a439f6e424689d92dfb86e Mon Sep 17 00:00:00 2001 From: Zach <90340773+zdblazer@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:42:08 -0400 Subject: [PATCH 057/290] fix(print): landscape sheets stay landscape on CUPS (#2713) On macOS and Linux the job sent no orientation, so CUPS left a landscape sheet unrotated and shrank it into the portrait paper named by `media` (Letter, A4, ...). Printing in Landscape therefore came out as a small landscape page on a portrait sheet. Add `pdfcraft_print::cups_orientation`, which gives `orientation-requested=4` when every sheet is landscape, and attach it to the job in two places: - the Print dialog (`print_now`); - the headless `doc_print` tool, which builds its own spooler job, so the automation tool and MCP server print the same way. An `orientation-requested` the caller already chose in `options` stands. Neither applies on Windows, whose print ticket sets the orientation. A job that mixes orientations gets nothing, since the option applies to the whole job. Record the new test as evidence on the `print.orientation` parity entry, saying what is still only checked by hand, and mention the option in the print crate README's CUPS paragraph. Checked against Apple's CUPS filters with a generic PostScript queue: a 792x612 sheet sent to Letter without the option stays unrotated at about 77% scale; with it the page is turned 90 degrees to fill the sheet. The new test fails with the fix stubbed out. --- crates/automation/src/printing.rs | 10 ++++++++-- crates/print/README.md | 4 +++- crates/print/src/lib.rs | 12 ++++++++++++ crates/print/src/tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/print_ui.rs | 10 +++++++++- parity/acrobat-features.toml | 4 ++-- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..fef5beaaab 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -202,7 +202,8 @@ impl Automation { }; let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; + let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); match (a.opt_str("path")?, a.opt_str("printer")?) { @@ -212,7 +213,12 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + // CUPS is told which way the sheets run, as the Print dialog does (#2713); Windows' + // print ticket already is. An orientation the caller chose in `options` stands. + if !cfg!(windows) && !job.options.iter().any(|(k, _)| k == "orientation-requested") { + job.options.extend(print::cups_orientation(&laid_out)); + } out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), diff --git a/crates/print/README.md b/crates/print/README.md index 1379be2b42..7f9f23b3ab 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -46,7 +46,9 @@ Headless `doc_print` defaults are unchanged. `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. -CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through +CUPS jobs stream through stdin with `fit-to-page=false`, and with `orientation-requested=4` when +every sheet is landscape (`cups_orientation`), since CUPS would otherwise shrink a landscape +sheet into portrait paper. Windows renders sheets through `Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex use the corresponding Windows binding edges. Random, exclusively created Windows job files diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..b32cb15541 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -361,6 +361,18 @@ pub fn media_option(index: usize) -> Option<(String, String)> { cups_media(index).map(|m| ("media".to_string(), m.to_string())) } +/// The CUPS job option that keeps landscape sheets landscape (`orientation-requested` 4). +/// +/// The print-ready PDF's sheets are already laid out on their paper, but the paper is named +/// portrait (`Letter`), and without this option CUPS leaves a landscape sheet unrotated and +/// shrinks it into the portrait paper instead of turning it to fill it (#2713). It is a property +/// of the whole job, so it is given only when *every* sheet is landscape; a job that mixes +/// orientations is left to the spooler, as portrait sheets would be turned wrongly. +pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { + let landscape = !sheets.is_empty() && sheets.iter().all(|s| s.size.0 > s.size.1); + landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..c37abdf8fc 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -509,6 +509,28 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +#[test] +fn landscape_sheets_ask_cups_to_turn_the_page() { + let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; + let ask = Some(("orientation-requested".to_string(), "4".to_string())); + // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. + let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&page), ask); + let booklet = + layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) + .unwrap(); + assert_eq!(cups_orientation(&booklet), ask); + // Portrait sheets, mixed jobs and nothing at all say nothing. + let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&portrait), None); + assert_eq!(cups_orientation(&[landscape((792.0, 612.0)), landscape((612.0, 792.0))]), None, "a mixed job is not turned as a whole"); + assert_eq!(cups_orientation(&[landscape((612.0, 612.0))]), None, "a square sheet has no orientation"); + assert_eq!(cups_orientation(&[]), None); + // `lp` is given it, among the job's other options. + let job = Job { options: ask.into_iter().collect(), ..Job::default() }; + assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 5bf8737fcc..2a68927f26 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -653,7 +653,15 @@ impl PdfCraftApp { } } } - Some(printer) => self.send_to_printer(printer, bytes, self.print_draft.job(&name)), + Some(printer) => { + let mut job = self.print_draft.job(&name); + // CUPS is told which way the sheets run; Windows' print ticket already is. + if !cfg!(windows) { + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + job.options.extend(print::layout(&sizes, &settings).ok().and_then(|sheets| print::cups_orientation(&sheets))); + } + self.send_to_printer(printer, bytes, job) + } None => self.save_print_pdf(&name, bytes), } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a042c2d642 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8144,8 +8144,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "auto portrait/landscape" +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it (#2713); a job that mixes orientations is left to the spooler, and Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters and one physical printer, not a test that runs a spooler." [[feature]] id = "print.comments-and-forms" From b8111effc6e6e5e9a9272e5d30f0fbfc84cc9bdc Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:34 -0400 Subject: [PATCH 058/290] M10: one guarded orientation for both print paths, with a test at each (#2713, #3739) Review follow-ups to the landscape fix rebased from #3068: - Both submission paths add the orientation through pdfcraft_print::add_cups_orientation, which never adds it over an orientation the job already names (a driver choice in the Print dialog, or a caller's doc_print options), so lp never gets it twice. - doc_print adds it inside spool_job, next to the paper (#3752), so its test checks the job it sends. - The Print dialog takes a stand-in for the spooler in tests (PdfCraftApp::spooler), like printer_source and options_source, and a test follows a real Print click to the job. - Doc comments for the tests beside the change. Checked on macOS with cupsfilter (cgpdftoraster, AirPrint PPD): a landscape sheet sent as before lands unrotated at 100% and cut off at the paper's right edge (#3739); with the option it is turned inside the margins. Co-authored-by: Zach <90340773+zdblazer@users.noreply.github.com> --- crates/automation/src/printing.rs | 43 +++++++++++++++------ crates/print/src/lib.rs | 11 ++++++ crates/print/src/tests.rs | 24 ++++++++++++ crates/ui-egui/src/lib.rs | 5 ++- crates/ui-egui/src/print_ui.rs | 28 +++++++++----- crates/ui-egui/tests/it/print.rs | 63 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 154 insertions(+), 24 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index fef5beaaab..9f0eedba95 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -60,8 +60,10 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { /// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default -/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells +/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the +/// caller chose in `options` stands. +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,7 +83,11 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); + print::add_cups_orientation(&mut options, sheets); + options + }, }) } @@ -213,12 +219,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; - // CUPS is told which way the sheets run, as the Print dialog does (#2713); Windows' - // print ticket already is. An orientation the caller chose in `options` stands. - if !cfg!(windows) && !job.options.iter().any(|(k, _)| k == "orientation-requested") { - job.options.extend(print::cups_orientation(&laid_out)); - } + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -257,13 +258,31 @@ mod tests { assert!(duplex("sideways", false).is_err()); } + /// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after + /// the paper, so a landscape sheet is turned onto the portrait paper rather than printed + /// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in + /// `options` stands and isn't sent twice. + #[test] + fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { + let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options; + let media = ("media".to_string(), "Letter".to_string()); + let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); + let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(job(json!({}), &landscape), turned); + assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets"); + assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them"); + let chosen = ("orientation-requested".to_string(), "5".to_string()); + assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once"); + } + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -279,7 +298,7 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err()); } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b32cb15541..88dfa84960 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -373,6 +373,17 @@ pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) } +/// Add [`cups_orientation`] to a CUPS job's `options`, for the Print dialog's jobs and +/// `doc_print`'s alike, unless they already name an orientation (a driver choice, or one a +/// caller passed): `lp` must not be given the option twice. Nothing on Windows, whose print +/// ticket sets each sheet's orientation itself. +pub fn add_cups_orientation(options: &mut Vec<(String, String)>, sheets: &[Sheet]) { + if cfg!(windows) || options.iter().any(|(k, _)| k == "orientation-requested") { + return; + } + options.extend(cups_orientation(sheets)); +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index c37abdf8fc..c15fc37254 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -489,6 +489,8 @@ fn printer_options_come_from_the_ppd() { assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped"); } +/// A driver option's current choice is read from `lpoptions`, and a job passes `lp` only the +/// driver options that are well-formed PPD keywords, never the ones PdfCraft sets itself. #[test] fn printer_option_defaults_and_job_arguments() { let current = @@ -509,6 +511,8 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +/// CUPS is asked to turn the sheets (`orientation-requested` 4) when every one is landscape, a +/// booklet's included; portrait, mixed, square and empty jobs say nothing (#2713). #[test] fn landscape_sheets_ask_cups_to_turn_the_page() { let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; @@ -531,6 +535,26 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); } +/// Both submission paths add the orientation through `add_cups_orientation`: after the job's +/// other options, never over an orientation the job already names (a driver choice), so `lp` is +/// never given it twice, and not on Windows, whose print ticket sets it. +#[test] +fn a_job_that_names_its_orientation_keeps_it() { + let landscape = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + let media = ("media".to_string(), "Letter".to_string()); + let mut options = vec![media.clone()]; + add_cups_orientation(&mut options, &landscape); + let turned = if cfg!(windows) { vec![media] } else { vec![media, ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(options, turned); + // Added again, it isn't doubled. + add_cups_orientation(&mut options, &landscape); + assert_eq!(options, turned); + // A choice already made stands. + let mut chosen = vec![("orientation-requested".to_string(), "5".to_string())]; + add_cups_orientation(&mut chosen, &landscape); + assert_eq!(chosen, [("orientation-requested".to_string(), "5".to_string())]); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..83386eed58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,7 +117,7 @@ mod signature_drag; pub mod units; mod web_print; mod word_count_ui; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; +pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -706,6 +706,8 @@ pub struct PdfCraftApp { pub printer_source: Option, /// Where Properties… reads a printer's driver options: the spooler's unless a test stands in. pub options_source: Option, + /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). + pub spooler: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, @@ -1139,6 +1141,7 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + spooler: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 2a68927f26..382e70df4d 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -264,6 +264,10 @@ pub type PrinterSource = std::sync::Arc Vec + Send + /// UI language) unless a test stands in. Called off the GUI thread. pub type OptionsSource = std::sync::Arc Vec + Send + Sync>; +/// What takes a print job in place of the system spooler: a test's stand-in, which gets the +/// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread. +pub type Spooler = std::sync::Arc Result + Send + Sync>; + /// The system spooler's printers, as the desktop app's [`PrinterSource`]. pub fn system_printers() -> PrinterSource { std::sync::Arc::new(spool::printers) @@ -655,10 +659,11 @@ impl PdfCraftApp { } Some(printer) => { let mut job = self.print_draft.job(&name); - // CUPS is told which way the sheets run; Windows' print ticket already is. - if !cfg!(windows) { - let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); - job.options.extend(print::layout(&sizes, &settings).ok().and_then(|sheets| print::cups_orientation(&sheets))); + // CUPS is told which way the sheets run (#2713), unless a driver choice already + // says; Windows' print ticket already is. + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + if let Ok(sheets) = print::layout(&sizes, &settings) { + print::add_cups_orientation(&mut job.options, &sheets); } self.send_to_printer(printer, bytes, job) } @@ -674,6 +679,7 @@ impl PdfCraftApp { fn send_to_printer(&mut self, printer: String, bytes: Vec, job: spool::Job) -> bool { let (tx, rx) = std::sync::mpsc::channel(); let ctx = self.ctx.clone(); + let spooler = self.spooler.clone(); std::thread::spawn(move || { let repaint = || { if let Some(ctx) = &ctx { @@ -681,11 +687,15 @@ impl PdfCraftApp { } }; // The receiver may be gone (the app quit): nothing to report to then. - let result = spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { - let _ = tx.send(JobNews::Sent(sent, sheets)); - repaint(); - }); - let _ = tx.send(JobNews::Done(result.map_err(|e| e.to_string()))); + let result = match &spooler { + Some(submit) => submit(&bytes, &job), + None => spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { + let _ = tx.send(JobNews::Sent(sent, sheets)); + repaint(); + }) + .map_err(|e| e.to_string()), + }; + let _ = tx.send(JobNews::Done(result)); repaint(); }); self.print_tasks.jobs.push(PrintJob { printer, sheets: None, news: rx }); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 81025e756f..5d0707ede8 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -215,6 +215,8 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() { let _ = std::fs::remove_file(out); } +/// Save as PDF to a folder that doesn't exist keeps the Print dialog open, with its settings, +/// and says why. #[test] fn a_failed_save_as_pdf_keeps_the_dialog_open() { let mut h = harness(); @@ -232,6 +234,67 @@ fn a_failed_save_as_pdf_keeps_the_dialog_open() { assert!(toast.contains("Could not save"), "the user is told why: {toast:?}"); } +/// #2713, #3739: Print to a CUPS queue tells it when every sheet runs landscape, so a +/// landscape sheet (an A4 landscape page, or a 6 × 4 label, laid out on Letter) is turned onto +/// the portrait paper instead of printed unrotated at 100% and cut off at the paper's edge. A +/// driver choice for the orientation stands and isn't sent twice. +#[test] +fn printing_landscape_sheets_tells_cups_to_turn_them() { + use pdfcraft_engine::print::spool; + use std::sync::{Arc, Mutex}; + let s = pdfcraft_engine::Session::new(); + let bytes = s.create_blank(842.0, 595.0, 2).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("landscape.pdf", None, bytes.to_vec()).unwrap(); + app + }); + h.run_steps(4); + let jobs: Arc>> = Arc::default(); + let sent = Arc::clone(&jobs); + h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &spool::Job| { + sent.lock().unwrap().push(job.clone()); + Ok(String::new()) + })); + // Print to the queue; `choose` sets the driver's choices once the dialog has its printer. + let print = |h: &mut Harness<'static, PdfCraftApp>, choose: &dyn Fn(&mut pdfcraft_ui_egui::PrintDraft)| -> spool::Job { + let before = jobs.lock().unwrap().len(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.printer = Some("Test_Queue".into()); + h.run_steps(2); + choose(&mut h.state_mut().print_draft); + h.run_steps(1); + h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); + for _ in 0..400 { + h.run_steps(1); + if jobs.lock().unwrap().len() > before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + jobs.lock().unwrap().last().cloned().expect("the job reached the spooler") + }; + let orientation = |job: &spool::Job| job.options.iter().filter(|(k, _)| k == "orientation-requested").map(|(_, v)| v.clone()).collect::>(); + // Fit on Letter with Auto orientation lays both pages out on landscape sheets. + let job = print(&mut h, &|_| {}); + assert_eq!(orientation(&job), if cfg!(windows) { vec![] } else { vec!["4".to_string()] }, "{:?}", job.options); + // A driver choice for the orientation goes instead, once. + let job = print(&mut h, &|d| { + let choice = spool::PrinterOption { + key: "orientation-requested".into(), + label: "Orientation".into(), + group: "General".into(), + choices: vec![("4".into(), "Landscape".into()), ("5".into(), "Reverse landscape".into())], + default: "4".into(), + }; + d.driver_options = Some(("Test_Queue".into(), vec![choice])); + d.driver_choices.insert("orientation-requested".into(), "5".into()); + }); + assert_eq!(orientation(&job), ["5"], "{:?}", job.options); +} + /// Properties… shows the printer driver's own options (CUPS) and sends only those changed. #[test] fn print_dialog_shows_the_printer_driver_options() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a042c2d642..e3319a5fb6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8144,8 +8144,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page"] -notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it (#2713); a job that mixes orientations is left to the spooler, and Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters and one physical printer, not a test that runs a spooler." +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page", "crates/print/src/tests.rs::a_job_that_names_its_orientation_keeps_it", "crates/automation/src/printing.rs::printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them", "crates/ui-egui/tests/it/print.rs::printing_landscape_sheets_tells_cups_to_turn_them"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters (cupsfilter on an AirPrint PPD) and one physical printer; the tests check the job both submission paths send, not a spooler's output." [[feature]] id = "print.comments-and-forms" From a4c8621fec11806628ddbffebe28a5f8d8bff407 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:35 -0400 Subject: [PATCH 059/290] M10: the orientation note says who checked the physical printer, and that no paper was printed for the filter checks --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e3319a5fb6..4ae029fbd1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8145,7 +8145,7 @@ status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page", "crates/print/src/tests.rs::a_job_that_names_its_orientation_keeps_it", "crates/automation/src/printing.rs::printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them", "crates/ui-egui/tests/it/print.rs::printing_landscape_sheets_tells_cups_to_turn_them"] -notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters (cupsfilter on an AirPrint PPD) and one physical printer; the tests check the job both submission paths send, not a spooler's output." +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option was checked against Apple's filters (cupsfilter on an AirPrint PPD, no paper printed), and #3068's author printed one landscape page with it on a physical printer on macOS; the tests check the job both submission paths send, not a spooler's output, and physical output is otherwise unvalidated." [[feature]] id = "print.comments-and-forms" From 97790abca1753f329de688d2a968bad66767c3d5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:14:39 -0400 Subject: [PATCH 060/290] M10: leave the Save as PDF test as main has it (out of this PR's scope) --- crates/ui-egui/tests/it/print.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 5d0707ede8..8e04cf2d82 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -215,8 +215,6 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() { let _ = std::fs::remove_file(out); } -/// Save as PDF to a folder that doesn't exist keeps the Print dialog open, with its settings, -/// and says why. #[test] fn a_failed_save_as_pdf_keeps_the_dialog_open() { let mut h = harness(); 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 061/290] 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 af250960cf4c8e19c8bcc61c0e0f85e73dd2f7fc Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:31:32 +0100 Subject: [PATCH 062/290] fix(ui): clear stale search point on page jump --- crates/ui-egui/src/canvas.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index abea0ca018..fe719051e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1395,6 +1395,7 @@ impl DocView { // Partial wheel motion belongs to the old place, and so does a find step's match. self.reset_coarse_state(); self.find_hold = None; + self.goto_point = None; self.goto = Some((page, 0.0)); self.current = page; } 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 063/290] 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 +