Merge #4517: M12: run an XFA signature field's preSign scripts before it is signed and postSign after

# Conflicts:
#	crates/engine/src/js.rs
#	crates/engine/src/tests.rs
#	crates/xfa/src/fixtures.rs
#	parity/acrobat-features.toml
This commit is contained in:
bflatastic
2026-11-09 18:21:41 +07:00
17 changed files with 495 additions and 27 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
mod documents;
mod form_tools;
pub(crate) mod form_tools;
mod ocr;
fn status_id(s: Status) -> &'static str {
+1 -1
View File
@@ -20,7 +20,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
/// icon and buttons.
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
}
+84 -15
View File
@@ -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};
@@ -175,6 +176,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<DigitalId> {
// A macOS Keychain identity: "keychain:<fingerprint>" or "keychain:<common name>".
if let Some(r) = a.str("id")?.strip_prefix("keychain:") {
@@ -197,6 +200,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<Value> {
let id = self.open_id(a)?;
let doc = self.doc(a)?;
@@ -246,30 +251,94 @@ impl Automation {
Some(v) => labels_arg(v)?,
};
let out = self.resolve(a.str("out")?, true)?;
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()
// 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)
});
// 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) 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, 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),
e => failed(e),
})?;
write_atomic(&out, &signed)?;
});
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);
}
};
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.
// 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 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();
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,
"changed": changed,
"alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::<Vec<_>>(),
"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)
}
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
+2 -1
View File
@@ -972,7 +972,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!({
@@ -1023,6 +1023,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"],
)),
+110
View File
@@ -7240,6 +7240,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");
@@ -7337,6 +7338,8 @@ fn lines_and_corners_are_held_level_or_plumb_through_the_tool() {
assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}");
}
/// 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");
@@ -7406,6 +7409,111 @@ 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 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");
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()
};
// 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();
// 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();
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",
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!([{ "message": "Signing notice", "title": null, "icon": "error", "buttons": "ok" }]), "{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", "run_form_scripts": true }),
);
assert!(r.get("form_scripts").is_none(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
/// signed file reopens with the signature intact and on that field.
@@ -7428,6 +7536,8 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
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 named_layer_verdicts_are_reported_and_rendered_through_tools() {
use pdfcraft_cos::{Dict, Document, Lexer, ObjRef, Object, SaveOptions, Stream};
+6
View File
@@ -423,10 +423,16 @@ 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)
}
/// 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)
+50
View File
@@ -383,6 +383,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) {
@@ -451,6 +470,37 @@ impl Session {
Ok(self.get(id).is_some_and(|d| d.generation != before))
}
/// 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<bool, EditError> {
if self.get(id).is_none() {
return Err(EditError::NoDocument);
}
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.
+36
View File
@@ -7349,6 +7349,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);
@@ -7416,6 +7418,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, [crate::js::Message::plain("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);
+7 -2
View File
@@ -1106,12 +1106,17 @@ pub(crate) fn has_event(doc: &pdfcraft_cos::Document, tpl: &Template, som: &str,
/// 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<String> {
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<String> {
// 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
+2
View File
@@ -205,6 +205,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(),
@@ -709,6 +710,7 @@ impl 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);
self.sync_native_menu(ctx);
+3
View File
@@ -544,6 +544,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).
+1
View File
@@ -372,6 +372,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 }
}
+77 -4
View File
@@ -289,10 +289,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(),
@@ -302,6 +303,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).
@@ -476,12 +526,31 @@ 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())?;
crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?;
// 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 pending = app.presign.take();
let signed = match app.session.sign(doc_id, &id, opts) {
Ok(s) => s,
Err(e) => {
app.presign = pending;
return Err(e.to_string());
}
};
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());
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.
// 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);
app.sign_graphic = choice;
@@ -490,6 +559,10 @@ 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() {
+87
View File
@@ -417,6 +417,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");
@@ -501,6 +502,92 @@ fn a_signature_badge_says_its_verdict_with_any_tool() {
assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair");
}
/// 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");
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));
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(),
"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);
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
// 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");
}
/// 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");
}
/// 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;
+2 -1
View File
@@ -79,7 +79,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
+24
View File
@@ -608,6 +608,30 @@ pub fn date_pictures_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##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page1" layout="tb" w="7.5in">
<field name="when" w="2in" h="0.3in"><ui><textEdit/></ui></field>
<field name="after" w="2in" h="0.3in"><ui><textEdit/></ui></field>
<field name="sign" w="3in" h="1in"><ui><signature/></ui>
<event activity="preSign"><script contentType="application/x-javascript">xfa.host.messageBox("Signing notice"); when.rawValue = "signed"; when.access = "readOnly";</script></event>
<event activity="postSign"><script contentType="application/x-javascript">after.rawValue = "done";</script></event>
</field>
<field name="plain" w="3in" h="1in"><ui><signature/></ui></field>
</subform>
</subform>
</template>
</xdp:xdp>
"##
.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.
File diff suppressed because one or more lines are too long