M12: preSign runs as signing begins, so the signer sees what it changes; taken back if signing is cancelled; opt-in through sign_document

This commit is contained in:
Eric Kaden
2026-10-24 13:38:35 -04:00
parent 99e1c67d49
commit 3eefcd5719
9 changed files with 182 additions and 43 deletions
+30 -5
View File
@@ -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<String, (Vec<String>, 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<Value> = 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)
}
+2 -1
View File
@@ -774,7 +774,7 @@ pub fn tools() -> Vec<ToolDef> {
.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:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" 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:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" 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<ToolDef> {
"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"],
)),
+31 -5
View File
@@ -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);
}
+20 -6
View File
@@ -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<bool, EditError> {
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<String> {
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.
+5
View File
@@ -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)
+5
View File
@@ -812,6 +812,9 @@ pub struct PdfCraftApp {
pub summary_sort: pdfcraft_engine::SummarySort,
/// The signing dialogs' state.
pub sign_draft: Option<SignDraft>,
/// 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<DigitalIdEntry>,
/// 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);
}
+58 -21
View File
@@ -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<String>, certify: Option<u8>) {
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;
+30 -4
View File
@@ -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;
File diff suppressed because one or more lines are too long