mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 06:04:59 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user