* M9: visible digital signatures can show a drawn, typed or image graphic (#193) A visible certificate signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG. Sign as previews it and remembers the choice; sign_document takes `graphic` (image, text or strokes). pdfcraft-sign gains `Graphic` and `SignOptions::graphic`. Graphics are checked and size-capped before drawing; images keep alpha as a soft mask and are encrypted with the rest of the update in encrypted files. Refs #193 * Refresh catalog hashes --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
626724bbdd
commit
56c034b0b3
|
|
@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "eb5bd161938e18015a635d1079fd2b6e4abb77b2f50de2a89ee402cd7ed4020c"
|
||||
sha256 = "20abff97752d6840dbccd0c0ab084a9a720a7098b9cb0dc85cb4dd5e3aed035a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
|
|
@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e1a04a188a6b1a9bff4301916bd202bcd46718e22206f86598e923b791364c79"
|
||||
sha256 = "c8e6cf64f6445a1e728160526ad03f6c334d4c17e28a363fc3117e678f05a1ac"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
|
|
@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "15c7e812bcc5e466f3ddeb957e85a2045db97bcc29e3b4715f0193e9d84dbfa9"
|
||||
sha256 = "c2988904eab14668a0c3b3ab60f3ec633cefd9aefa51537025289d5a56be90a3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
|
|
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ed63bb07b8cf1eea833572066a0dcb0cfc5ae2a54eac206b09661fb8743bedfe"
|
||||
sha256 = "4c458a6cf8bf835ddacf2aae9fcc3268a17889572dd84533db3387df3ed8a9ae"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
|
|
@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "7ae1cb58826e2eb7c2fd381173d232d7f4e5017f6ed557ddd65d4bacf2d69624"
|
||||
sha256 = "f1428a3e57fbe8c431a2893032c45333d8e603a84990020e7246c2ae89cdda63"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
|
|
@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d8b5578203cb43a175cc24bbfe1ae931717f01499a05c381823891f8b88a21e7"
|
||||
sha256 = "cf51169173fcbb05d8b543c99c757105235194745e3672da2b94c0344040d27f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
|
|
@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "926c6c6ca1c376b159779588d77c5d033e2ddab105ad97d097417c1b7c598690"
|
||||
sha256 = "a122ae46d112bd16238dc295e9514c59017874d76651195cd1f3d2849832084f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
|
|
@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3831aaeda30436a69ac35cc279726d4f1ba006013cf38e4d94304a7b492d1245"
|
||||
sha256 = "417725b7518473ae301f747fd8c5d25b17bd2ffb506e9a3b0b885d8332dbb7c9"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
|
|
@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "fefa6bc66dd019254fdd9aa9ad85a861d58f1e4c4ab55e01bf7dbacd7fd054e8"
|
||||
sha256 = "961c713b921e0b38ff41cca714cea252e88ffa4d9b3eb1e8610f67d532ab9d7d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
|
|
@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d82b74ebc99a82963d9f056643aa08b640115bbab0daf76569704b2984e28800"
|
||||
sha256 = "13e8633ef2b0482dde1dcad37207c24507df9bf4e2a15c065e0d58e25b2e9e23"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
|
|
@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "feb5ae147ab9647d5233a80d42c1e9a2e6c486813db9989969da75ea093b2c1b"
|
||||
sha256 = "aadfcc7a71ace8bd0de86d86846d88395ccbf6971e4ebf4e3e99b1d3d46e3913"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
|
|
@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "706c0a4dfbbc6eda15ddde838ccf2ebac3f9af00d8a402c4a59450c660172e10"
|
||||
sha256 = "527924f5558599bd7aeb1ad69a94fe58eeba6716e03e8ec1c6c061f3c53f2cdd"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
|
|
@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "27aa500c5b9daef63a0fdf3cbff60cc31242195f28100b5b20fd96c88b838765"
|
||||
sha256 = "91de7a1a8515a1f698567f6e0c9393a0eec111551f0de4ec03a8766b120e3222"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
|
|
@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2ded225e2bc1cb210c90a1e76ca4ec4a5613b0f4ac1ed3a3c1cbe1fafeccb34d"
|
||||
sha256 = "1989ca69412d7092e643b170a41f9ee01920513ef4c71a4b2abf5d9a24a24e6b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
|
|
@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "65ba0e77aec38b0717ff146a6aff1a6b40f14b4de1f3b216ba0babceb9075376"
|
||||
sha256 = "c149965b2886ad5cb1992aad3d50eda3a15c96aa36070a114a7240f62ce54684"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
|||
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f
|
|||
/// point parallel to the chord between its neighbours (an end uses itself as the missing
|
||||
/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
|
||||
/// stays within its points and these control points.
|
||||
pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
|
||||
pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
|
||||
let Some(&last) = pts.last() else { return Vec::new() };
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or(last);
|
||||
(0..pts.len().saturating_sub(1))
|
||||
|
|
|
|||
|
|
@ -171,6 +171,11 @@ impl Automation {
|
|||
}),
|
||||
Some(v) => Some(v.as_u64().filter(|n| (1..=3).contains(n)).ok_or_else(|| bad("certify must be 1, 2 or 3"))? as u8),
|
||||
};
|
||||
let graphic = match a.get("graphic") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
|
||||
Some(g) => Some(self.signature_graphic(g)?),
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
|
|
@ -180,9 +185,14 @@ impl Automation {
|
|||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
};
|
||||
let signed = self.session.sign(doc_id, &id, opts).map_err(failed)?;
|
||||
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 path = out.to_string_lossy().into_owned();
|
||||
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
|
||||
|
|
@ -191,6 +201,33 @@ impl Automation {
|
|||
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
|
||||
}
|
||||
|
||||
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
|
||||
/// `{"strokes": [[[x, y], …], …]}` (y up; the drawing pad is 1 wide).
|
||||
fn signature_graphic(&self, g: &Value) -> Result<sign::Graphic> {
|
||||
let kinds = "graphic must be an object with exactly one of image (a PNG or JPEG path), text, or strokes";
|
||||
let Some(o) = g.as_object().filter(|o| o.len() == 1) else { return Err(bad(kinds)) };
|
||||
if let Some(path) = o.get("image") {
|
||||
let path = self.resolve(path.as_str().ok_or_else(|| bad("graphic.image must be a path"))?, false)?;
|
||||
let file = std::fs::File::open(&path).map_err(|e| bad(format!("graphic.image {}: {e}", path.display())))?;
|
||||
return Ok(pdfcraft_engine::SignatureImage::read(file).map_err(|e| bad(e.to_string()))?.graphic());
|
||||
}
|
||||
if let Some(text) = o.get("text") {
|
||||
let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?;
|
||||
return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"graphic.text {text:?} has nothing the script font can draw (at most {} characters)",
|
||||
pdfcraft_engine::MAX_SIGNATURE_CHARS
|
||||
))
|
||||
});
|
||||
}
|
||||
if let Some(strokes) = o.get("strokes") {
|
||||
let strokes: Vec<Vec<[f64; 2]>> =
|
||||
serde_json::from_value(strokes.clone()).map_err(|e| bad(format!("graphic.strokes must be strokes of [x, y] points: {e}")))?;
|
||||
return Ok(sign::Graphic::Strokes(strokes));
|
||||
}
|
||||
Err(bad(kinds))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut certs: Vec<Certificate> =
|
||||
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };
|
||||
|
|
|
|||
|
|
@ -656,7 +656,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. 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. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
|
|
@ -670,6 +670,17 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"location": { "type": "string" },
|
||||
"contact": { "type": "string" },
|
||||
"certify": { "type": "string", "enum": ["no_changes", "form_fill", "comments"] },
|
||||
"graphic": {
|
||||
"type": "object",
|
||||
"description": "Exactly one of image, text or strokes.",
|
||||
"properties": {
|
||||
"image": { "type": "string", "description": "A PNG or JPEG file." },
|
||||
"text": { "type": "string", "description": "Typed in the script font." },
|
||||
"strokes": { "type": "array", "items": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } } },
|
||||
},
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
|
|
|
|||
|
|
@ -2966,6 +2966,74 @@ fn drawing_comments_through_tools() {
|
|||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
let dir = workdir("signing-graphic");
|
||||
// A solid red 120 x 40 picture.
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png, 120, 40);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let rgba: Vec<u8> = (0..120 * 40).flat_map(|_| [220, 0, 0, 255]).collect();
|
||||
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||||
std::fs::write(dir.join("broken.png"), b"broken").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| {
|
||||
a.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }),
|
||||
)
|
||||
};
|
||||
for (graphic, why) in [
|
||||
(json!({ "image": "logo.png", "text": "Ada" }), "two kinds"),
|
||||
(json!({}), "no kind"),
|
||||
(json!({ "picture": "logo.png" }), "unknown key"),
|
||||
(json!("logo.png"), "not an object"),
|
||||
(json!({ "image": "broken.png" }), "not an image"),
|
||||
(json!({ "text": " " }), "nothing to draw"),
|
||||
(json!({ "strokes": [[[0, 0], ["x", 1]]] }), "malformed strokes"),
|
||||
(json!({ "strokes": [] }), "empty strokes"),
|
||||
] {
|
||||
let r = sign(&mut a, [20.0, 200.0, 180.0, 250.0], graphic, "bad.pdf");
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{why}: {r:?}");
|
||||
}
|
||||
// A missing file fails as it does for every tool that reads one.
|
||||
assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_))));
|
||||
// An invisible signature shows nothing, so a graphic for one is a mistake.
|
||||
let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}");
|
||||
assert!(!dir.join("bad.pdf").exists());
|
||||
|
||||
// Image, typed and drawn graphics, each a signature of its own, all intact.
|
||||
sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "logo.png" }), "s1.pdf").unwrap();
|
||||
sign(&mut a, [20.0, 130.0, 180.0, 180.0], json!({ "text": "Ada Lovelace" }), "s2.pdf").unwrap();
|
||||
sign(&mut a, [20.0, 60.0, 180.0, 110.0], json!({ "strokes": [[[0, 0], [0.3, 0.2], [0.6, 0], [1, 0.2]]] }), "s3.pdf").unwrap();
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 3);
|
||||
for s in list["signatures"].as_array().unwrap() {
|
||||
assert_eq!((s["status"].as_str(), s["visible"].as_bool()), (Some("unknown"), Some(true)), "{s}");
|
||||
}
|
||||
// At 72 dpi a pixel is a point. Each graphic is in the left half of its box (x 23–97), the
|
||||
// details on the right.
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Content::Png { data, width, .. } = &png[0] else { panic!("expected an image") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data.as_slice())).read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut buf).unwrap();
|
||||
let channels = frame.line_size / *width as usize;
|
||||
let px = |x: usize, y: usize| buf[y * frame.line_size + x * channels..][..3].to_vec();
|
||||
assert_eq!(px(60, 225), [220, 0, 0], "the image fills the left half's middle");
|
||||
assert_ne!(px(150, 225), [220, 0, 0], "and not the details");
|
||||
let inked = |y0: usize, y1: usize| (y0..y1).any(|y| (23..97).any(|x| px(x, y).iter().all(|c| *c < 100)));
|
||||
assert!(inked(130, 180), "the typed name is drawn");
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6
|
|||
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types
|
||||
/// it. `None` for text with no outlines.
|
||||
pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
|
||||
let o = script_outline(text);
|
||||
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
|
||||
}
|
||||
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ impl SignatureImage {
|
|||
self.size
|
||||
}
|
||||
|
||||
/// This image as the graphic of a visible digital signature.
|
||||
pub fn graphic(&self) -> pdfcraft_sign::Graphic {
|
||||
// Sides are at most 4096 pixels; 0 would be refused by the signer, never drawn.
|
||||
let side = |v: usize| u32::try_from(v).unwrap_or(0);
|
||||
pdfcraft_sign::Graphic::Image { width: side(self.size[0]), height: side(self.size[1]), rgba: self.rgba.clone() }
|
||||
}
|
||||
|
||||
/// Make near-white paper transparent, keeping darker ink.
|
||||
pub fn remove_white_background(&self, cutoff: u8, feather: u8) -> Result<Self, SignatureImageError> {
|
||||
let [w, h] = self.size;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ for s in signatures(&doc, &bytes, &trust) { // li
|
|||
no fix, so `rsa` is only used to verify). ECDSA signs with deterministic nonces (RFC 6979).
|
||||
- **Signing:** PAdES B-B (`ETSI.CAdES.detached`, signing-certificate-v2, SHA-256/384, no SHA-1).
|
||||
An existing unsigned field or a new one (visible with Acrobat's name-and-details appearance,
|
||||
or invisible); certification with DocMDP P=1/2/3. The document is written incrementally
|
||||
or invisible); certification with DocMDP P=1/2/3. A visible signature can show a `Graphic`
|
||||
where the large name would be: drawn strokes, filled outlines (a typed signature) or RGBA
|
||||
pixels (an image XObject with a soft mask), checked and size-capped first. The document is written incrementally
|
||||
with a zero-filled `/Contents` and fixed-width `/ByteRange`, which are then patched in
|
||||
place. Encrypted documents are refused for now.
|
||||
- **Timestamps (PAdES B-T):** the `timestamp` module builds and parses RFC 3161 requests,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
//! A picture in a visible signature, where the signer's name would be large (Acrobat: Configure
|
||||
//! Signature Appearance ▸ Draw or Image, and typed signatures). Drawn as Fill & Sign draws its
|
||||
//! signatures: ink smoothed and stroked with round ends, typed outlines filled even-odd, images
|
||||
//! with their alpha as a soft mask.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_annot::appearance::smooth_segments;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::SignError;
|
||||
|
||||
/// Bounds the content stream a graphic from settings or an agent can produce. A typed name in
|
||||
/// the script font stays under 262,144 points.
|
||||
const MAX_POINTS: usize = 300_000;
|
||||
/// Image limits: a side and the whole picture (the UI keeps signature images under 4 MP).
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
const MAX_PIXELS: u64 = 1 << 24;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Graphic {
|
||||
/// Drawn strokes, y up, in units where the drawing pad is 1 wide (as Fill & Sign saves them).
|
||||
Strokes(Vec<Vec<[f64; 2]>>),
|
||||
/// Closed outlines, y up, filled even-odd: a typed signature in the script font.
|
||||
Outlines(Vec<Vec<[f64; 2]>>),
|
||||
/// Straight (not premultiplied) RGBA pixels, rows from the top.
|
||||
Image { width: u32, height: u32, rgba: Arc<Vec<u8>> },
|
||||
}
|
||||
|
||||
/// Sizes only: a graphic can hold megabytes of pixels or points.
|
||||
impl std::fmt::Debug for Graphic {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Graphic::Strokes(s) => write!(f, "Strokes({} strokes, {} points)", s.len(), s.iter().map(Vec::len).sum::<usize>()),
|
||||
Graphic::Outlines(c) => write!(f, "Outlines({} contours, {} points)", c.len(), c.iter().map(Vec::len).sum::<usize>()),
|
||||
Graphic::Image { width, height, rgba } => write!(f, "Image({width}x{height}, {} bytes)", rgba.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(why: &str) -> SignError {
|
||||
SignError::Pdf(format!("the signature graphic {why}"))
|
||||
}
|
||||
|
||||
/// The drawing's bounds `[x0, y0, x1, y1]` over `points`, refusing non-finite or unbounded ones.
|
||||
fn bounds<'a>(points: impl Iterator<Item = &'a [f64; 2]>) -> Result<[f64; 4], SignError> {
|
||||
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for p in points {
|
||||
if !p.iter().all(|v| v.is_finite()) {
|
||||
return Err(bad("has a point that isn't a number"));
|
||||
}
|
||||
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
|
||||
}
|
||||
if !b.iter().all(|v| v.is_finite()) {
|
||||
return Err(bad("is empty"));
|
||||
}
|
||||
if !((b[2] - b[0]).is_finite() && (b[3] - b[1]).is_finite()) {
|
||||
return Err(bad("is too large"));
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// `(scale, x, y)` placing `b` as large as fits in `area` (`[x, y, w, h]`), centred.
|
||||
fn fit(b: [f64; 4], area: [f64; 4]) -> (f64, f64, f64) {
|
||||
// A dot or a straight line has no extent on an axis; it is placed by the other one.
|
||||
let (bw, bh) = ((b[2] - b[0]).max(1e-9), (b[3] - b[1]).max(1e-9));
|
||||
let k = (area[2] / bw).min(area[3] / bh);
|
||||
(k, area[0] + (area[2] - (b[2] - b[0]) * k) / 2.0, area[1] + (area[3] - (b[3] - b[1]) * k) / 2.0)
|
||||
}
|
||||
|
||||
impl Graphic {
|
||||
fn check(&self) -> Result<(), SignError> {
|
||||
let points = match self {
|
||||
Graphic::Strokes(s) => s.iter().map(Vec::len).sum::<usize>(),
|
||||
Graphic::Outlines(c) => {
|
||||
if !c.iter().any(|c| c.len() > 2) {
|
||||
return Err(bad("has no outline that encloses anything"));
|
||||
}
|
||||
c.iter().map(Vec::len).sum()
|
||||
}
|
||||
Graphic::Image { width, height, rgba } => {
|
||||
if *width == 0 || *height == 0 || *width > MAX_SIDE || *height > MAX_SIDE || u64::from(*width) * u64::from(*height) > MAX_PIXELS {
|
||||
return Err(bad(&format!("image is {width} x {height} pixels; at most {MAX_SIDE} per side and 16 megapixels")));
|
||||
}
|
||||
let expected = (*width as usize).checked_mul(*height as usize).and_then(|n| n.checked_mul(4));
|
||||
if expected != Some(rgba.len()) {
|
||||
return Err(bad("image's pixels don't match its size"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if points > MAX_POINTS {
|
||||
return Err(bad(&format!("has {points} points; at most {MAX_POINTS}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check the graphic and draw it into `area` (`[x, y, w, h]` in the appearance's space):
|
||||
/// content operators, and the image XObject added to `doc` that they name `/Im1`.
|
||||
pub(crate) fn draw(&self, doc: &mut Document, area: [f64; 4]) -> Result<(String, Option<ObjRef>), SignError> {
|
||||
self.check()?;
|
||||
let n = crate::pdf::fmt;
|
||||
match self {
|
||||
Graphic::Strokes(strokes) => {
|
||||
let curves: Vec<Vec<[(f64, f64); 3]>> =
|
||||
strokes.iter().map(|s| smooth_segments(&s.iter().map(|p| (p[0], p[1])).collect::<Vec<_>>())).collect();
|
||||
// Curves stay within their points and control points.
|
||||
let controls: Vec<[f64; 2]> = curves.iter().flatten().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]).collect();
|
||||
let b = bounds(strokes.iter().flatten().chain(&controls))?;
|
||||
// Fill & Sign draws 1.5 pt ink on a 150 pt wide pad: 1% of the pad's width.
|
||||
let probe = fit(b, area).0;
|
||||
let width = (probe * 0.01).clamp(0.3, 4.0);
|
||||
let inset = [area[0] + width / 2.0, area[1] + width / 2.0, (area[2] - width).max(0.0), (area[3] - width).max(0.0)];
|
||||
let (k, x, y) = fit(b, inset);
|
||||
let at = |p: (f64, f64)| format!("{} {}", n(x + (p.0 - b[0]) * k), n(y + (p.1 - b[1]) * k));
|
||||
let mut c = format!("q 0 G {} w 1 J 1 j\n", n(width));
|
||||
for (s, segments) in strokes.iter().zip(&curves) {
|
||||
let Some(first) = s.first() else { continue };
|
||||
let start = at((first[0], first[1]));
|
||||
c.push_str(&format!("{start} m\n"));
|
||||
if segments.is_empty() {
|
||||
// A dot: a zero-length line, which round caps draw as a disc.
|
||||
c.push_str(&format!("{start} l\n"));
|
||||
}
|
||||
for [p, q, e] in segments {
|
||||
c.push_str(&format!("{} {} {} c\n", at(*p), at(*q), at(*e)));
|
||||
}
|
||||
c.push_str("S\n");
|
||||
}
|
||||
c.push_str("Q\n");
|
||||
Ok((c, None))
|
||||
}
|
||||
Graphic::Outlines(contours) => {
|
||||
let closed: Vec<&Vec<[f64; 2]>> = contours.iter().filter(|c| c.len() > 2).collect();
|
||||
let b = bounds(closed.iter().copied().flatten())?;
|
||||
let (k, x, y) = fit(b, area);
|
||||
let mut c = String::from("q 0 g\n");
|
||||
for contour in closed {
|
||||
for (i, p) in contour.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {}\n", n(x + (p[0] - b[0]) * k), n(y + (p[1] - b[1]) * k), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
c.push_str("h\n");
|
||||
}
|
||||
c.push_str("f*\nQ\n");
|
||||
Ok((c, None))
|
||||
}
|
||||
Graphic::Image { width, height, rgba } => {
|
||||
let (w, h) = (f64::from(*width), f64::from(*height));
|
||||
let (k, x, y) = fit([0.0, 0.0, w, h], area);
|
||||
let image = image_xobject(doc, *width, *height, rgba);
|
||||
Ok((format!("q {} 0 0 {} {} {} cm /Im1 Do Q\n", n(w * k), n(h * k), n(x), n(y)), Some(image)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An RGB image XObject, with a soft mask when any pixel isn't opaque. `rgba` is checked.
|
||||
fn image_xobject(doc: &mut Document, width: u32, height: u32, rgba: &[u8]) -> ObjRef {
|
||||
let dict = |space: &str| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(width)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(height)));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name(space));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d
|
||||
};
|
||||
let (pixels, _) = rgba.as_chunks::<4>();
|
||||
let rgb: Vec<u8> = pixels.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut d = dict("DeviceRGB");
|
||||
if pixels.iter().any(|p| p[3] != 255) {
|
||||
let alpha: Vec<u8> = pixels.iter().map(|p| p[3]).collect();
|
||||
let mask = doc.add(Object::Stream(Stream::flate(dict("DeviceGray"), &alpha)));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
}
|
||||
doc.add(Object::Stream(Stream::flate(d, &rgb)))
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod cms;
|
|||
pub mod der;
|
||||
pub mod dss;
|
||||
mod ec512;
|
||||
pub mod graphic;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod keychain;
|
||||
pub mod keys;
|
||||
|
|
@ -24,6 +25,7 @@ pub mod windows;
|
|||
pub mod x509;
|
||||
|
||||
pub use der::Time;
|
||||
pub use graphic::Graphic;
|
||||
pub use keys::{DigestAlg, PrivateKey, PublicKey};
|
||||
pub use pdf::{
|
||||
Appearance, DigestCache, Modification, SignOptions, SignatureInfo, Status, TrustStore, list as signatures, sign, sign_with_timestamp,
|
||||
|
|
|
|||
|
|
@ -1312,6 +1312,8 @@ pub struct SignOptions {
|
|||
/// Certify with these DocMDP permissions (1 no changes, 2 form fill and signing, 3 also comments).
|
||||
pub certify: Option<u8>,
|
||||
pub appearance: Appearance,
|
||||
/// A drawing, typed signature or image shown where the large name would be.
|
||||
pub graphic: Option<crate::graphic::Graphic>,
|
||||
}
|
||||
|
||||
/// What an encrypted document's permissions (ISO 32000-2 §7.6.4.2, Table 22) allow when it was
|
||||
|
|
@ -1505,7 +1507,7 @@ fn sign_inner(
|
|||
}
|
||||
}
|
||||
// The appearance.
|
||||
let ap = appearance(rect, &name, &id.certificate, opts);
|
||||
let ap = appearance(&mut doc, rect, &name, &id.certificate, opts)?;
|
||||
let ap_ref = doc.add(Object::Stream(ap));
|
||||
let mut apd = Dict::new();
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap_ref));
|
||||
|
|
@ -1625,9 +1627,10 @@ pub fn display_date(pdf: &str) -> String {
|
|||
format!("{}.{}.{} {}:{}:{}{tz}", g(0..4), g(4..6), g(6..8), g(8..10), g(10..12), g(12..14))
|
||||
}
|
||||
|
||||
/// The visible signature: the signer's name large on the left, the details on the right
|
||||
/// (Acrobat's standard layout), in Helvetica.
|
||||
fn appearance(rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Stream {
|
||||
/// The visible signature: the signer's name large on the left, or the graphic in its place, and
|
||||
/// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details
|
||||
/// beside it fills the box.
|
||||
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
|
||||
use pdfcraft_fonts::{helvetica_width, literal, win_ansi, wrap};
|
||||
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
|
||||
let a = &opts.appearance;
|
||||
|
|
@ -1655,12 +1658,22 @@ fn appearance(rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions
|
|||
}
|
||||
lines.retain(|l| !l.is_empty());
|
||||
let mut out = Vec::new();
|
||||
let mut image = None;
|
||||
if w > 1.0 && h > 1.0 {
|
||||
let pad = (h * 0.06).clamp(1.0, 6.0);
|
||||
let (left_w, right_x) = if a.name { (w * 0.5, w * 0.5 + pad) } else { (0.0, pad) };
|
||||
let (left_w, right_x) = match (&opts.graphic, a.name) {
|
||||
(Some(_), _) if lines.is_empty() => (w, pad),
|
||||
(Some(_), _) | (None, true) => (w * 0.5, w * 0.5 + pad),
|
||||
(None, false) => (0.0, pad),
|
||||
};
|
||||
let right_w = (w - right_x - pad).max(1.0);
|
||||
if let Some(g) = &opts.graphic {
|
||||
let (ops, im) = g.draw(doc, [pad, pad, (left_w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0)])?;
|
||||
out.extend(ops.bytes());
|
||||
image = im;
|
||||
}
|
||||
out.extend_from_slice(b"BT\n0 g\n");
|
||||
if a.name {
|
||||
if a.name && opts.graphic.is_none() {
|
||||
// The name fills the left half.
|
||||
let mut size = (h * 0.4).min(36.0);
|
||||
let fit = (left_w - 2.0 * pad).max(1.0);
|
||||
|
|
@ -1707,11 +1720,16 @@ fn appearance(rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions
|
|||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
if let Some(im) = image {
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"Im1".to_vec(), Object::Ref(im));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
Stream::flate(d, &out)
|
||||
Ok(Stream::flate(d, &out))
|
||||
}
|
||||
|
||||
fn fmt(v: f64) -> String {
|
||||
pub(crate) fn fmt(v: f64) -> String {
|
||||
let s = format!("{v:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,110 @@ fn invisible_signatures_and_refusals() {
|
|||
assert_eq!(pdfcraft_sign::pdf::display_date("D:20261002120000+01'00'"), "2026.10.02 12:00:00 +01'00'");
|
||||
}
|
||||
|
||||
/// The normal appearance of the signature field `name`: its decoded content and resources.
|
||||
fn appearance_of(bytes: &[u8], name: &str) -> (String, pdfcraft_cos::Dict) {
|
||||
appearance_in(&open(bytes), name)
|
||||
}
|
||||
|
||||
fn appearance_in(doc: &Document, name: &str) -> (String, pdfcraft_cos::Dict) {
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = doc.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
let fields = doc.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
let field = fields
|
||||
.iter()
|
||||
.filter_map(|f| doc.resolve(f).as_dict().cloned())
|
||||
.find(|f| matches!(f.get(b"T"), Some(Object::String(s)) if s.bytes == name.as_bytes()))
|
||||
.unwrap();
|
||||
let ap = doc.resolve(field.get(b"AP").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = &*doc.resolve(ap.get(b"N").unwrap()) else { panic!("no normal appearance") };
|
||||
let res = doc.resolve(s.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), res)
|
||||
}
|
||||
|
||||
/// A 40 x 10 image, opaque or with one translucent pixel.
|
||||
fn picture(translucent: bool) -> pdfcraft_sign::Graphic {
|
||||
let mut rgba: Vec<u8> = (0..400).flat_map(|_| [10, 20, 30, 255]).collect();
|
||||
if translucent {
|
||||
rgba[3] = 128;
|
||||
}
|
||||
pdfcraft_sign::Graphic::Image { width: 40, height: 10, rgba: Arc::new(rgba) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_signatures_show_a_drawn_typed_or_image_graphic() {
|
||||
use pdfcraft_sign::{Appearance, Graphic};
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let name = format!("({})", id.certificate.display_name());
|
||||
let plain = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
// Without a graphic the name is large on the left and in the details.
|
||||
assert_eq!(appearance_of(&plain, "Signature1").0.matches(&name).count(), 2);
|
||||
let strokes = Graphic::Strokes(vec![vec![[0.0, 0.0], [0.3, 0.2], [0.6, 0.0], [1.0, 0.2]], vec![[0.5, 0.1]]]);
|
||||
let typed = Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]], vec![[0.2, 0.1], [0.8, 0.1], [0.5, 0.4]]]);
|
||||
for (graphic, drawn) in [
|
||||
(strokes, " c\n"),
|
||||
(typed, "f*\n"),
|
||||
// 40 x 10 in the left half (94 x 44 inside the padding): 94 wide, centred vertically.
|
||||
(picture(true), "q 94 0 0 23.5 3 13.25 cm /Im1 Do Q"),
|
||||
] {
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(graphic.clone()), ..opts() }).unwrap();
|
||||
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!((s.status, s.modification), (Status::Unknown, Modification::None), "{graphic:?}: {:?}", s.details);
|
||||
let (content, res) = appearance_of(&signed, "Signature1");
|
||||
assert!(content.contains(drawn), "{graphic:?}: {content}");
|
||||
// The graphic takes the name's place on the left; the details still name the signer.
|
||||
assert_eq!(content.matches(&name).count(), 1, "{content}");
|
||||
assert_eq!(res.get(b"XObject").is_some(), matches!(graphic, Graphic::Image { .. }));
|
||||
}
|
||||
// Translucent images keep their alpha as a soft mask; opaque ones need none.
|
||||
for translucent in [true, false] {
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(picture(translucent)), ..opts() }).unwrap();
|
||||
let doc = open(&signed);
|
||||
let (_, res) = appearance_of(&signed, "Signature1");
|
||||
let xobjects = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(im) = &*doc.resolve(xobjects.get(b"Im1").unwrap()) else { panic!() };
|
||||
assert_eq!((im.dict.int(b"Width"), im.dict.int(b"Height")), (Some(40), Some(10)));
|
||||
assert_eq!(im.decoded().unwrap().len(), 40 * 10 * 3);
|
||||
assert_eq!(im.dict.get(b"SMask").is_some(), translucent);
|
||||
}
|
||||
// With no text at all, the graphic has the whole box (194 x 44): 176 x 44, centred.
|
||||
let bare = Appearance { name: false, date: false, reason: false, location: false, distinguished_name: false, labels: false };
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(picture(false)), appearance: bare, ..opts() }).unwrap();
|
||||
assert!(appearance_of(&signed, "Signature1").0.contains("q 176 0 0 44 12 3 cm /Im1 Do Q"));
|
||||
// An invisible signature has nothing to show: no image is embedded.
|
||||
let invisible = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { rect: None, graphic: Some(picture(false)), ..opts() }).unwrap();
|
||||
assert!(!invisible.windows(6).any(|w| w == b"/Image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_signature_graphics_are_refused_not_drawn() {
|
||||
use pdfcraft_sign::Graphic;
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let huge = vec![vec![[0.0, 0.0]; 300_001]];
|
||||
for graphic in [
|
||||
Graphic::Strokes(vec![]),
|
||||
Graphic::Strokes(vec![vec![]]),
|
||||
Graphic::Strokes(vec![vec![[0.0, 0.0], [f64::NAN, 1.0]]]),
|
||||
Graphic::Strokes(vec![vec![[-1e308, 0.0], [1e308, 1.0]]]),
|
||||
Graphic::Strokes(huge),
|
||||
// Outlines need three points to enclose anything.
|
||||
Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
|
||||
Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, f64::INFINITY], [1.0, 0.0]]]),
|
||||
Graphic::Image { width: 40, height: 10, rgba: Arc::new(vec![0; 399]) },
|
||||
Graphic::Image { width: 0, height: 10, rgba: Arc::new(vec![]) },
|
||||
Graphic::Image { width: u32::MAX, height: u32::MAX, rgba: Arc::new(vec![]) },
|
||||
] {
|
||||
let what = format!("{graphic:?}");
|
||||
assert!(what.len() < 200, "Debug never dumps pixels or points: {what}");
|
||||
let r = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(graphic), ..opts() });
|
||||
assert!(matches!(r, Err(SignError::Pdf(ref m)) if m.contains("signature graphic")), "{what}: {r:?}");
|
||||
}
|
||||
// A single dot is a (tiny) drawing, not an error.
|
||||
let dot = Graphic::Strokes(vec![vec![[0.5, 0.5]]]);
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(dot), ..opts() }).unwrap();
|
||||
let (content, _) = appearance_of(&signed, "Signature1");
|
||||
assert!(!content.contains("NaN") && !content.contains("inf"), "{content}");
|
||||
}
|
||||
|
||||
/// A deterministic TSA: signs an RFC 3161 response locally with a test digital ID at a fixed
|
||||
/// time. No sockets; the whole stamping path runs in-process.
|
||||
struct TestTsa {
|
||||
|
|
@ -1452,6 +1556,28 @@ fn encrypted_documents_are_signed_with_an_unencrypted_contents() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_documents_signature_graphic_is_encrypted_with_it() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
for alg in ALL_ALGORITHMS {
|
||||
let base = protected(alg, -1);
|
||||
let signed = pdfcraft_sign::sign(&open_pw(&base, None), &id, &SignOptions { graphic: Some(picture(true)), ..opts() }).unwrap();
|
||||
let doc = open_pw(&signed, None);
|
||||
let s = signatures(&doc, &signed, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!((s.status, s.modification), (Status::Valid, Modification::None), "{alg:?}");
|
||||
// Read back through the security handler, the pixels and their mask are exact; a stream
|
||||
// written in the clear would be "decrypted" into noise and fail to inflate.
|
||||
let (content, res) = appearance_in(&doc, "Signature1");
|
||||
assert!(content.contains("/Im1 Do"), "{alg:?}: {content}");
|
||||
let xobjects = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(im) = &*doc.resolve(xobjects.get(b"Im1").unwrap()) else { panic!() };
|
||||
assert_eq!(im.decoded().unwrap()[..3], [10, 20, 30], "{alg:?}");
|
||||
let Object::Stream(mask) = &*doc.resolve(im.dict.get(b"SMask").unwrap()) else { panic!() };
|
||||
assert_eq!(mask.decoded().unwrap()[..2], [128, 255], "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_are_signed_only_as_their_permissions_allow() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
|
|
|
|||
|
|
@ -34,7 +34,21 @@ pub enum SavedSig {
|
|||
Image(#[serde(with = "image_data")] SignatureImage),
|
||||
}
|
||||
|
||||
mod image_data {
|
||||
impl SavedSig {
|
||||
/// This signature as the graphic of a visible digital signature. `None` for typed text the
|
||||
/// script font has nothing to draw for.
|
||||
pub fn graphic(&self) -> Option<pdfcraft_engine::sign::Graphic> {
|
||||
match self {
|
||||
SavedSig::Drawn(strokes) => Some(pdfcraft_engine::sign::Graphic::Strokes(
|
||||
strokes.iter().map(|s| s.iter().map(|p| [f64::from(p[0]), f64::from(p[1])]).collect()).collect(),
|
||||
)),
|
||||
SavedSig::Typed(text) => pdfcraft_engine::typed_signature_graphic(text),
|
||||
SavedSig::Image(image) => Some(image.graphic()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod image_data {
|
||||
use base64::Engine as _;
|
||||
use pdfcraft_engine::{SignatureImage, signature_image::MAX_SIGNATURE_IMAGE_BYTES};
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
|
@ -308,48 +322,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
|||
Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let preview_rect = rect.shrink(8.0);
|
||||
match saved {
|
||||
SavedSig::Typed(text) => {
|
||||
let cache = &mut app.saved_signature_previews[i];
|
||||
if cache.as_ref().is_none_or(|(s, _)| s != saved) {
|
||||
*cache = Some((
|
||||
saved.clone(),
|
||||
ui.ctx().load_texture(format!("saved-{what}"), script_preview(text, 480, 104), egui::TextureOptions::LINEAR),
|
||||
));
|
||||
}
|
||||
if let Some((_, tex)) = cache {
|
||||
let size = vec2(480.0, 104.0) * (preview_rect.width() / 480.0).min(preview_rect.height() / 104.0);
|
||||
painter.image(
|
||||
tex.id(),
|
||||
egui::Rect::from_center_size(preview_rect.center(), size),
|
||||
egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
SavedSig::Image(image) => {
|
||||
image_preview(ui, preview_rect, image, &mut app.saved_signature_previews[i]);
|
||||
}
|
||||
SavedSig::Drawn(strokes) => {
|
||||
let bounds = strokes
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|p| p.iter().all(|v| v.is_finite()))
|
||||
.fold(egui::Rect::NOTHING, |r, p| r.union(egui::Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1]))));
|
||||
if bounds.is_finite() {
|
||||
let scale = (preview_rect.width() / bounds.width().max(0.01)).min(preview_rect.height() / bounds.height().max(0.01));
|
||||
for stroke in strokes {
|
||||
let pts = stroke
|
||||
.iter()
|
||||
.filter(|p| p.iter().all(|v| v.is_finite()))
|
||||
.map(|p| preview_rect.center() + vec2(p[0] - bounds.center().x, bounds.center().y - p[1]) * scale)
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::BLACK)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}"));
|
||||
if response.on_hover_text(label("Place saved {what}")).clicked() {
|
||||
command = Some(use_id);
|
||||
}
|
||||
|
|
@ -365,6 +338,47 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
|||
command
|
||||
}
|
||||
|
||||
/// Draw a saved signature as large as fits in `rect`, centred: typed text and images through
|
||||
/// `cache` (a texture named `key`), strokes as lines.
|
||||
pub(crate) fn paint_saved(ui: &egui::Ui, rect: egui::Rect, saved: &SavedSig, cache: &mut Option<(SavedSig, egui::TextureHandle)>, key: &str) {
|
||||
let painter = ui.painter_at(rect);
|
||||
match saved {
|
||||
SavedSig::Typed(text) => {
|
||||
if cache.as_ref().is_none_or(|(s, _)| s != saved) {
|
||||
*cache = Some((saved.clone(), ui.ctx().load_texture(key, script_preview(text, 480, 104), egui::TextureOptions::LINEAR)));
|
||||
}
|
||||
if let Some((_, tex)) = cache {
|
||||
let size = vec2(480.0, 104.0) * (rect.width() / 480.0).min(rect.height() / 104.0);
|
||||
painter.image(
|
||||
tex.id(),
|
||||
egui::Rect::from_center_size(rect.center(), size),
|
||||
egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
SavedSig::Image(image) => image_preview(ui, rect, image, cache),
|
||||
SavedSig::Drawn(strokes) => {
|
||||
let bounds = strokes
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|p| p.iter().all(|v| v.is_finite()))
|
||||
.fold(egui::Rect::NOTHING, |r, p| r.union(egui::Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1]))));
|
||||
if bounds.is_finite() {
|
||||
let scale = (rect.width() / bounds.width().max(0.01)).min(rect.height() / bounds.height().max(0.01));
|
||||
for stroke in strokes {
|
||||
let pts = stroke
|
||||
.iter()
|
||||
.filter(|p| p.iter().all(|v| v.is_finite()))
|
||||
.map(|p| rect.center() + vec2(p[0] - bounds.center().x, bounds.center().y - p[1]) * scale)
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::BLACK)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview and clicks with a Fill & Sign tool on one page.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn page_input(
|
||||
|
|
|
|||
|
|
@ -856,6 +856,10 @@
|
|||
The signature will be invisible. سيكون التوقيع غير مرئي.
|
||||
Distinguished name الاسم المميز
|
||||
Labels التسميات
|
||||
Graphic رسم
|
||||
Signer's name اسم الموقّع
|
||||
Fill & Sign signature توقيع «تعبئة وتوقيع»
|
||||
Choose image… اختيار صورة…
|
||||
Permitted actions after certifying الإجراءات المسموح بها بعد التصديق
|
||||
No changes allowed لا يُسمح بأي تغييرات
|
||||
Annotations, form fill-in, and digital signatures التعليقات وتعبئة النماذج والتوقيعات الرقمية
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -535,6 +535,10 @@
|
|||
The signature will be invisible. Подписът ще бъде невидим.
|
||||
Distinguished name Отличително име
|
||||
Labels Етикети
|
||||
Graphic Графика
|
||||
Signer's name Име на подписващия
|
||||
Fill & Sign signature Подпис от „Попълни и подпиши“
|
||||
Choose image… Избор на изображение…
|
||||
Permitted actions after certifying Разрешени действия след сертифициране
|
||||
No changes allowed Не се разрешават промени
|
||||
Annotations, form fill-in, and digital signatures Анотации, попълване на формуляри и цифрови подписи
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
|
|
@ -829,6 +829,10 @@
|
|||
The signature will be invisible. Die Signatur wird unsichtbar sein.
|
||||
Distinguished name Distinguished Name
|
||||
Labels Beschriftungen
|
||||
Graphic Grafik
|
||||
Signer's name Name des Unterzeichners
|
||||
Fill & Sign signature Unterschrift aus „Ausfüllen und unterschreiben“
|
||||
Choose image… Bild auswählen…
|
||||
Permitted actions after certifying Erlaubte Aktionen nach dem Zertifizieren
|
||||
No changes allowed Keine Änderungen erlaubt
|
||||
Annotations, form fill-in, and digital signatures Anmerkungen, Ausfüllen von Formularen und digitale Signaturen
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
|
|
@ -849,6 +849,10 @@
|
|||
The signature will be invisible. La firma será invisible.
|
||||
Distinguished name Nombre distintivo
|
||||
Labels Etiquetas
|
||||
Graphic Gráfico
|
||||
Signer's name Nombre del firmante
|
||||
Fill & Sign signature Firma de Rellenar y firmar
|
||||
Choose image… Elegir imagen…
|
||||
Permitted actions after certifying Acciones permitidas después de certificar
|
||||
No changes allowed No se permite ningún cambio
|
||||
Annotations, form fill-in, and digital signatures Anotaciones, rellenado de formularios y firmas digitales
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
|
|
@ -829,6 +829,10 @@
|
|||
The signature will be invisible. La signature sera invisible.
|
||||
Distinguished name Nom distinctif
|
||||
Labels Étiquettes
|
||||
Graphic Graphique
|
||||
Signer's name Nom du signataire
|
||||
Fill & Sign signature Signature de Remplir et signer
|
||||
Choose image… Choisir une image…
|
||||
Permitted actions after certifying Actions autorisées après certification
|
||||
No changes allowed Aucune modification autorisée
|
||||
Annotations, form fill-in, and digital signatures Annotations, remplissage de formulaire et signatures numériques
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
|
|
@ -826,6 +826,10 @@
|
|||
The signature will be invisible. Az aláírás láthatatlan lesz.
|
||||
Distinguished name Megkülönböztető név
|
||||
Labels Címkék
|
||||
Graphic Grafika
|
||||
Signer's name Aláíró neve
|
||||
Fill & Sign signature A Kitöltés és aláírás aláírása
|
||||
Choose image… Kép kiválasztása…
|
||||
Permitted actions after certifying Engedélyezett műveletek hitelesítés után
|
||||
No changes allowed Semmilyen módosítás nem engedélyezett
|
||||
Annotations, form fill-in, and digital signatures Széljegyzetek, űrlapkitöltés és digitális aláírások
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
|
|
@ -852,6 +852,10 @@
|
|||
The signature will be invisible. La firma sarà invisibile.
|
||||
Distinguished name Nome distinto
|
||||
Labels Etichette
|
||||
Graphic Grafica
|
||||
Signer's name Nome del firmatario
|
||||
Fill & Sign signature Firma di Compila e firma
|
||||
Choose image… Scegli immagine…
|
||||
Permitted actions after certifying Azioni consentite dopo la certificazione
|
||||
No changes allowed Nessuna modifica consentita
|
||||
Annotations, form fill-in, and digital signatures Annotazioni, compilazione moduli e firme digitali
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -856,6 +856,10 @@
|
|||
The signature will be invisible. 署名は非表示になります。
|
||||
Distinguished name 識別名
|
||||
Labels ラベル
|
||||
Graphic グラフィック
|
||||
Signer's name 署名者名
|
||||
Fill & Sign signature 入力と署名の署名
|
||||
Choose image… 画像を選択…
|
||||
Permitted actions after certifying 証明後に許可する操作
|
||||
No changes allowed 変更を許可しない
|
||||
Annotations, form fill-in, and digital signatures 注釈、フォーム入力、デジタル署名
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -850,6 +850,10 @@
|
|||
The signature will be invisible. Podpis będzie niewidoczny.
|
||||
Distinguished name Nazwa wyróżniająca
|
||||
Labels Etykiety
|
||||
Graphic Grafika
|
||||
Signer's name Nazwa podpisującego
|
||||
Fill & Sign signature Podpis z „Wypełnij i podpisz”
|
||||
Choose image… Wybierz obraz…
|
||||
Permitted actions after certifying Dozwolone działania po certyfikacji
|
||||
No changes allowed Zmiany niedozwolone
|
||||
Annotations, form fill-in, and digital signatures Adnotacje, wypełnianie formularzy i podpisy cyfrowe
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -487,6 +487,10 @@
|
|||
The signature will be invisible. A assinatura será invisível.
|
||||
Distinguished name Nome distinto
|
||||
Labels Rótulos
|
||||
Graphic Gráfico
|
||||
Signer's name Nome do signatário
|
||||
Fill & Sign signature Assinatura de Preencher e assinar
|
||||
Choose image… Escolher imagem…
|
||||
Permitted actions after certifying Ações permitidas após a certificação
|
||||
No changes allowed Nenhuma alteração permitida
|
||||
Annotations, form fill-in, and digital signatures Anotações, preenchimento de formulário e assinaturas digitais
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -850,6 +850,10 @@
|
|||
The signature will be invisible. Подпись будет невидимой.
|
||||
Distinguished name Уникальное имя
|
||||
Labels Метки
|
||||
Graphic Графика
|
||||
Signer's name Имя подписывающего
|
||||
Fill & Sign signature Подпись из «Заполнить и подписать»
|
||||
Choose image… Выбрать изображение…
|
||||
Permitted actions after certifying Разрешённые действия после сертификации
|
||||
No changes allowed Изменения не разрешены
|
||||
Annotations, form fill-in, and digital signatures Аннотации, заполнение форм и цифровые подписи
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -831,6 +831,10 @@ comment menu Edit సవరించు
|
|||
The signature will be invisible. సంతకం కనిపించదు.
|
||||
Distinguished name విశిష్ట పేరు
|
||||
Labels లేబుల్లు
|
||||
Graphic గ్రాఫిక్
|
||||
Signer's name సంతకదారు పేరు
|
||||
Fill & Sign signature నింపడం & సంతకం సంతకం
|
||||
Choose image… చిత్రాన్ని ఎంచుకోండి…
|
||||
Permitted actions after certifying సర్టిఫై చేసిన తర్వాత అనుమతించే చర్యలు
|
||||
No changes allowed మార్పులు ఏవీ అనుమతించబడవు
|
||||
Annotations, form fill-in, and digital signatures ఉల్లేఖనలు, ఫారం నింపడం మరియు డిజిటల్ సంతకాలు
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
|
|
@ -854,6 +854,10 @@
|
|||
The signature will be invisible. Підпис буде невидимим.
|
||||
Distinguished name Розпізнавальне ім’я
|
||||
Labels Позначки
|
||||
Graphic Графіка
|
||||
Signer's name Ім’я підписувача
|
||||
Fill & Sign signature Підпис із «Заповнення й підпис»
|
||||
Choose image… Вибрати зображення…
|
||||
Permitted actions after certifying Дозволені дії після засвідчення
|
||||
No changes allowed Зміни заборонено
|
||||
Annotations, form fill-in, and digital signatures Коментарі, заповнення форми й цифрові підписи
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -542,6 +542,10 @@
|
|||
The signature will be invisible. 签名将不可见。
|
||||
Distinguished name 可分辨名称
|
||||
Labels 标签
|
||||
Graphic 图形
|
||||
Signer's name 签名者姓名
|
||||
Fill & Sign signature 填写与签名中的签名
|
||||
Choose image… 选择图像…
|
||||
Permitted actions after certifying 认证后允许的操作
|
||||
No changes allowed 不允许任何更改
|
||||
Annotations, form fill-in, and digital signatures 注释、填写表单和数字签名
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -497,6 +497,10 @@
|
|||
The signature will be invisible. 簽名將不可見。
|
||||
Distinguished name 可分辨名稱
|
||||
Labels 標籤
|
||||
Graphic 圖形
|
||||
Signer's name 簽署者姓名
|
||||
Fill & Sign signature 填寫與簽署中的簽名
|
||||
Choose image… 選擇圖片…
|
||||
Permitted actions after certifying 認證後允許的操作
|
||||
No changes allowed 不允許任何更改
|
||||
Annotations, form fill-in, and digital signatures 註解、填寫表單和數位簽章
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -65,7 +65,7 @@ mod object_ui;
|
|||
pub use create_ui::Clip;
|
||||
pub use link_ui::LinkDraft;
|
||||
pub use optimize_ui::{OptimizeDraft, OptimizeTab};
|
||||
pub use sign_ui::{DigitalIdEntry, SignDraft, SignStep};
|
||||
pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep};
|
||||
mod autoscroll;
|
||||
mod bidi;
|
||||
pub mod bulk_fields;
|
||||
|
|
@ -640,6 +640,9 @@ pub struct PdfCraftApp {
|
|||
pub signature_draft: fill_sign::SigDraft,
|
||||
pub(crate) signature_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
|
||||
pub(crate) saved_signature_previews: [Option<(fill_sign::SavedSig, egui::TextureHandle)>; 2],
|
||||
/// What visible digital signatures show on their left, and its preview in Sign as.
|
||||
pub sign_graphic: sign_ui::SignGraphic,
|
||||
pub(crate) sign_graphic_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) signature_images: fill_sign::ImageInbox,
|
||||
/// The Comment Properties dialog's state.
|
||||
|
|
@ -870,6 +873,8 @@ impl PdfCraftApp {
|
|||
signature_draft: Default::default(),
|
||||
signature_preview: None,
|
||||
saved_signature_previews: [None, None],
|
||||
sign_graphic: sign_ui::SignGraphic::Name,
|
||||
sign_graphic_preview: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
signature_images: Default::default(),
|
||||
comment_props: None,
|
||||
|
|
@ -1529,6 +1534,7 @@ impl PdfCraftApp {
|
|||
"signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None },
|
||||
"signature_image": match &self.signature { Some(s @ fill_sign::SavedSig::Image(_)) => Some(s), _ => None },
|
||||
"initials": self.initials,
|
||||
"sign_graphic": self.sign_graphic,
|
||||
// macOS Keychain and Windows store identities are read from their OS key stores each time.
|
||||
"digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")).collect::<Vec<_>>(),
|
||||
"trusted": trusted,
|
||||
|
|
@ -1656,6 +1662,8 @@ impl PdfCraftApp {
|
|||
if let Ok(i) = serde_json::from_value::<fill_sign::SavedSig>(v["initials"].clone()) {
|
||||
self.initials = Some(i);
|
||||
}
|
||||
// An unreadable choice (a damaged image) falls back to the name.
|
||||
self.sign_graphic = serde_json::from_value(v["sign_graphic"].clone()).unwrap_or_default();
|
||||
if let Ok(ids) = serde_json::from_value::<Vec<DigitalIdEntry>>(v["digital_ids"].clone()) {
|
||||
self.digital_ids = ids;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ use std::path::PathBuf;
|
|||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
|
||||
use pdfcraft_engine::sign::{self, Appearance, Certificate, DigitalId, Modification, Name, PrivateKey};
|
||||
use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
|
||||
use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::fill_sign::SavedSig;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
|
||||
|
|
@ -76,6 +77,19 @@ impl Default for NewIdDraft {
|
|||
}
|
||||
}
|
||||
|
||||
/// What a visible signature shows on its left (Acrobat: Configure Signature Appearance ▸ Text,
|
||||
/// Draw or Image). The one last signed with is remembered.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SignGraphic {
|
||||
/// The signer's name in large type (when Name is on).
|
||||
#[default]
|
||||
Name,
|
||||
/// The saved Fill & Sign signature, as it is when signing.
|
||||
FillSign,
|
||||
/// An imported PNG or JPEG.
|
||||
Image(#[serde(with = "crate::fill_sign::image_data")] SignatureImage),
|
||||
}
|
||||
|
||||
/// The signing dialogs' state.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SignDraft {
|
||||
|
|
@ -92,6 +106,9 @@ pub struct SignDraft {
|
|||
pub reason: String,
|
||||
pub location: String,
|
||||
pub appearance: Appearance,
|
||||
pub graphic: SignGraphic,
|
||||
/// The image the dialog holds (remembered or chosen), so Image can be chosen again.
|
||||
pub image: Option<SignatureImage>,
|
||||
pub new_id: NewIdDraft,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
|
@ -111,6 +128,8 @@ impl SignDraft {
|
|||
reason: String::new(),
|
||||
location: String::new(),
|
||||
appearance: Appearance::default(),
|
||||
graphic: SignGraphic::Name,
|
||||
image: None,
|
||||
new_id: NewIdDraft::default(),
|
||||
error: None,
|
||||
}
|
||||
|
|
@ -247,7 +266,15 @@ impl PdfCraftApp {
|
|||
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID.
|
||||
pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) {
|
||||
self.refresh_os_key_store_ids();
|
||||
self.sign_draft = Some(SignDraft::new(page, rect, field, certify, &self.digital_ids));
|
||||
let mut draft = SignDraft::new(page, rect, field, certify, &self.digital_ids);
|
||||
draft.graphic = match &self.sign_graphic {
|
||||
SignGraphic::FillSign if self.signature.is_none() => SignGraphic::Name,
|
||||
g => g.clone(),
|
||||
};
|
||||
if let SignGraphic::Image(image) = &self.sign_graphic {
|
||||
draft.image = Some(image.clone());
|
||||
}
|
||||
self.sign_draft = Some(draft);
|
||||
self.dialog = Some(crate::Dialog::Sign);
|
||||
}
|
||||
|
||||
|
|
@ -368,6 +395,12 @@ impl PdfCraftApp {
|
|||
})?
|
||||
};
|
||||
let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_string());
|
||||
// Only a visible signature shows its graphic.
|
||||
let graphic = match self.graphic_sig(&d.graphic) {
|
||||
Some(saved) if d.rect.is_some() || d.field.is_some() => Some(saved.graphic().ok_or("The saved signature has nothing to draw.")?),
|
||||
_ => None,
|
||||
};
|
||||
let choice = d.graphic.clone();
|
||||
let opts = SignOptions {
|
||||
field: d.field.clone(),
|
||||
page: d.page,
|
||||
|
|
@ -376,6 +409,7 @@ impl PdfCraftApp {
|
|||
location: some(&d.location),
|
||||
certify: d.certify,
|
||||
appearance: d.appearance.clone(),
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
};
|
||||
// Signing saves, as in Acrobat: choose where (a cancelled save cancels signing). The
|
||||
|
|
@ -391,6 +425,7 @@ impl PdfCraftApp {
|
|||
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}"))?;
|
||||
app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?;
|
||||
app.sign_graphic = choice;
|
||||
if let Some(view) = app.views.iter_mut().find(|v| v.id == doc_id) {
|
||||
view.invalidate_content();
|
||||
}
|
||||
|
|
@ -424,6 +459,37 @@ impl PdfCraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// The saved signature or image `g` stands for (`None`: the name).
|
||||
fn graphic_sig(&self, g: &SignGraphic) -> Option<SavedSig> {
|
||||
graphic_of(self.signature.as_ref(), g)
|
||||
}
|
||||
|
||||
/// Sign as ▸ Choose image…: answered only into the signing dialog that asked.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn pick_sign_graphic_image(&mut self) {
|
||||
let epoch = self.dialog_epoch();
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]);
|
||||
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
|
||||
if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Sign) {
|
||||
return;
|
||||
}
|
||||
// Scanned paper becomes transparent, as for Fill & Sign image signatures.
|
||||
let image = std::fs::File::open(path)
|
||||
.map_err(pdfcraft_engine::signature_image::SignatureImageError::from)
|
||||
.and_then(SignatureImage::read)
|
||||
.and_then(|image| image.remove_white_background(245, 35));
|
||||
match (image, app.sign_draft.as_mut()) {
|
||||
(Ok(image), Some(d)) => {
|
||||
d.graphic = SignGraphic::Image(image.clone());
|
||||
d.image = Some(image);
|
||||
d.error = None;
|
||||
}
|
||||
(Ok(_), None) => {}
|
||||
(Err(e), _) => app.notify_fmt("Couldn't import the signature image: {e}", &[("e", &e.to_string())]),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Trust a certificate (Signatures panel ▸ Add to trusted certificates), revalidating.
|
||||
pub fn trust_certificate(&mut self, cert: Certificate) {
|
||||
let mut certs = self.session.trusted_certificates().to_vec();
|
||||
|
|
@ -687,17 +753,29 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|||
}
|
||||
|
||||
/// The appearance preview: Acrobat's standard layout (name left, details right).
|
||||
fn preview(ui: &mut egui::Ui, t: &Tokens, name: &str, d: &SignDraft) {
|
||||
/// The saved signature or image `g` stands for, given the saved Fill & Sign `signature`.
|
||||
fn graphic_of(signature: Option<&SavedSig>, g: &SignGraphic) -> Option<SavedSig> {
|
||||
match g {
|
||||
SignGraphic::Name => None,
|
||||
SignGraphic::FillSign => signature.cloned(),
|
||||
SignGraphic::Image(image) => Some(SavedSig::Image(image.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature as it will look: the graphic (or the name) on the left, the details beside it.
|
||||
fn preview(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
name: &str,
|
||||
d: &SignDraft,
|
||||
graphic: Option<&SavedSig>,
|
||||
cache: &mut Option<(SavedSig, egui::TextureHandle)>,
|
||||
) {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover());
|
||||
let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE };
|
||||
ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let inner = rect.shrink(10.0);
|
||||
let a = &d.appearance;
|
||||
if a.name {
|
||||
let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height()));
|
||||
let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width());
|
||||
ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK);
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
if a.name {
|
||||
lines.push(if a.labels { format!("Digitally signed by {name}") } else { name.to_string() });
|
||||
|
|
@ -711,7 +789,16 @@ fn preview(ui: &mut egui::Ui, t: &Tokens, name: &str, d: &SignDraft) {
|
|||
if a.date {
|
||||
lines.push(format!("{}{}", if a.labels { "Date: " } else { "" }, "(the signing time)"));
|
||||
}
|
||||
let x = if a.name { inner.center().x + 6.0 } else { inner.left() };
|
||||
// As in the signature itself: a graphic with no details beside it fills the box.
|
||||
let alone = graphic.is_some() && lines.is_empty();
|
||||
let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height()));
|
||||
if let Some(g) = graphic {
|
||||
crate::fill_sign::paint_saved(ui, if alone { inner } else { half }, g, cache, "sign-graphic");
|
||||
} else if a.name {
|
||||
let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width());
|
||||
ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK);
|
||||
}
|
||||
let x = if (a.name || graphic.is_some()) && !alone { inner.center().x + 6.0 } else { inner.left() };
|
||||
let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x);
|
||||
ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK);
|
||||
}
|
||||
|
|
@ -733,9 +820,12 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|||
let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:");
|
||||
let mut close = false;
|
||||
let mut go = false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut pick = false;
|
||||
if d.rect.is_some() || d.field.is_some() {
|
||||
ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5)));
|
||||
preview(ui, t, &entry.name, d);
|
||||
let graphic = graphic_of(app.signature.as_ref(), &d.graphic);
|
||||
preview(ui, t, &entry.name, d, graphic.as_ref(), &mut app.sign_graphic_preview);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let a = &mut d.appearance;
|
||||
ui.checkbox(&mut a.name, tl!("Name"));
|
||||
|
|
@ -745,6 +835,27 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|||
ui.checkbox(&mut a.distinguished_name, tl!("Distinguished name"));
|
||||
ui.checkbox(&mut a.labels, tl!("Labels"));
|
||||
});
|
||||
// Acrobat: Configure Signature Appearance, Text, Draw or Image.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(tl!("Graphic"));
|
||||
ui.radio_value(&mut d.graphic, SignGraphic::Name, tl!("Signer's name"));
|
||||
ui.add_enabled_ui(app.signature.is_some(), |ui| {
|
||||
if ui.radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature")).clicked() {
|
||||
d.graphic = SignGraphic::FillSign;
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(d.image.is_some(), |ui| {
|
||||
if ui.radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image")).clicked()
|
||||
&& let Some(image) = &d.image
|
||||
{
|
||||
d.graphic = SignGraphic::Image(image.clone());
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if widgets::pill_button(ui, tl!("Choose image…"), false).clicked() {
|
||||
pick = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
} else {
|
||||
ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted));
|
||||
|
|
@ -808,6 +919,10 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
|||
}
|
||||
});
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if pick {
|
||||
app.pick_sign_graphic_image();
|
||||
}
|
||||
if go {
|
||||
match app.finish_signing() {
|
||||
Ok(()) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use egui::{Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_engine::SignatureStatus;
|
||||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, RightPanel, SignStep};
|
||||
|
||||
|
|
@ -247,6 +247,71 @@ fn clicking_an_empty_signature_field_signs_it() {
|
|||
assert!(sig.signed && sig.visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
|
||||
use pdfcraft_ui_egui::SignGraphic;
|
||||
use pdfcraft_ui_egui::fill_sign::SavedSig;
|
||||
let dir = dir().join("graphic");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12");
|
||||
let cert = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap().certificate;
|
||||
let sign_as = |h: &mut Harness<'static, PdfCraftApp>, rect: [f64; 4]| {
|
||||
h.state_mut().start_signing(0, Some(rect), None, None);
|
||||
let entry = h.state_mut().add_digital_id(p12, &cert);
|
||||
let d = h.state_mut().sign_draft.as_mut().unwrap();
|
||||
(d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into());
|
||||
h.run_steps(2);
|
||||
};
|
||||
|
||||
// Without a saved Fill & Sign signature there is nothing to choose.
|
||||
sign_as(&mut h, [20.0, 110.0, 280.0, 140.0]);
|
||||
h.get_by_label("Graphic");
|
||||
assert!(h.get_by_label("Fill & Sign signature").accesskit_node().is_disabled(), "disabled without one");
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
|
||||
// The saved signature (typed here) takes the name's place.
|
||||
h.state_mut().signature = Some(SavedSig::Typed("Grace Hopper".into()));
|
||||
sign_as(&mut h, [20.0, 110.0, 280.0, 140.0]);
|
||||
h.get_by_label("Fill & Sign signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign);
|
||||
h.get_by_label("Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
|
||||
let sig = s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).cloned().unwrap();
|
||||
assert!(sig.visible && sig.status == SignatureStatus::Unknown, "{:?}", sig.details);
|
||||
// Remembered for the next signature, and across a restart.
|
||||
assert_eq!(s.sign_graphic, SignGraphic::FillSign);
|
||||
sign_as(&mut h, [20.0, 20.0, 280.0, 60.0]);
|
||||
assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign);
|
||||
|
||||
// An image (the picker's result is set directly: the file dialog is native).
|
||||
let image = {
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(60, 20, image::Rgba([200, 0, 0, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
pdfcraft_engine::SignatureImage::from_bytes(png.get_ref()).unwrap()
|
||||
};
|
||||
h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Choose image…");
|
||||
h.get_by_label("Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
|
||||
let signed = std::fs::read(dir.join("contract_signed.pdf")).unwrap();
|
||||
assert!(signed.windows(6).any(|w| w == b"/Image"), "the image is embedded");
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().signatures.iter().filter(|x| x.signed).count(), 2);
|
||||
let mut fresh = PdfCraftApp::new();
|
||||
fresh.restore(&s.persist());
|
||||
assert_eq!(fresh.sign_graphic, SignGraphic::Image(image));
|
||||
// A saved choice that can't be read back falls back to the name.
|
||||
fresh.restore(r#"{"sign_graphic": {"Image": "not base64"}}"#);
|
||||
assert_eq!(fresh.sign_graphic, SignGraphic::Name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_store_identities_are_not_persisted() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
|
|
|
|||
|
|
@ -5941,9 +5941,9 @@ tier = "P1"
|
|||
milestone = "M9"
|
||||
status = "partial"
|
||||
commands = ["sign.digital"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
|
||||
notes = "text items (name, date, reason, location, DN, labels); no graphics or saved appearances yet"
|
||||
tools = ["sign_document"]
|
||||
evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools"]
|
||||
notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. Missing: named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog"
|
||||
|
||||
[[feature]]
|
||||
id = "sign.reason-location"
|
||||
|
|
|
|||
Loading…
Reference in New Issue