From 99e1c67d49b186804e0f94b49e4f83142676a889 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:27:31 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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();