From e17833a64eef4880731a056af1b021789b39cc36 Mon Sep 17 00:00:00 2001
From: Devon Llewellyn <139090831+remesis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:36:26 -0400
Subject: [PATCH] Add image signatures with placement preview and resize
controls (#335)
* M5.7: import images as Fill & Sign signatures and initials
* M5.7: preview image signatures and select after placement
---
ATTRIBUTION.toml | 14 +-
ROADMAP.md | 5 +-
crates/a11y/src/alt.rs | 6 +-
crates/annot/README.md | 2 +
crates/annot/src/lib.rs | 23 +-
crates/automation/src/comments.rs | 13 +-
crates/automation/src/tools.rs | 5 +-
crates/automation/tests/automation.rs | 66 ++++++
crates/engine/Cargo.toml | 2 +-
crates/engine/src/lib.rs | 5 +-
crates/engine/src/signature_image.rs | 186 +++++++++++++++++
crates/optimize/src/images.rs | 6 +-
crates/ui-egui/Cargo.toml | 3 +-
crates/ui-egui/src/a11y_ui.rs | 13 +-
crates/ui-egui/src/canvas.rs | 28 ++-
crates/ui-egui/src/comments.rs | 4 +-
crates/ui-egui/src/dialogs.rs | 15 +-
crates/ui-egui/src/editing.rs | 19 +-
crates/ui-egui/src/fill_sign.rs | 260 ++++++++++++++++++++---
crates/ui-egui/src/i18n/cs.tsv | 2 +
crates/ui-egui/src/i18n/es.tsv | 2 +
crates/ui-egui/src/i18n/fr.tsv | 2 +
crates/ui-egui/src/i18n/ja.tsv | 2 +
crates/ui-egui/src/i18n/pt-br.tsv | 2 +
crates/ui-egui/src/i18n/zh-hans.tsv | 2 +
crates/ui-egui/src/i18n/zh-hant.tsv | 2 +
crates/ui-egui/src/lib.rs | 14 +-
crates/ui-egui/tests/fill_sign.rs | 290 +++++++++++++++++++++++++-
parity/acrobat-features.toml | 8 +-
xtask/src/layers.rs | 7 +-
30 files changed, 926 insertions(+), 82 deletions(-)
create mode 100644 crates/engine/src/signature_image.rs
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 5549859e..bfe6ccec 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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 = "d55df478f60a8a8185cdbc428d7f1c276b7ab21a8a4024faf0e978387fd3189e"
+sha256 = "2d0c3c882f0d842230b32834150df3307111b3b91d3c1c403001c78b8f4202df"
[[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 = "97de2a56aa5cf32d11f22c19f0efeb76cbe16dff769247f49d8bdecbeac34d90"
+sha256 = "bff6a243f40151a20a5082e02a1e7b588e46fca5f7b616ca618e57576b5218e3"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "accf181cff1f0ea86bb33528f6158d1aec18c9c3ff54de62dc758536a24cb677"
+sha256 = "92cd3816ae6f0cf59244f4836cc95a3b12961c88d219dae593fd60dbc6eb8de0"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.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 = "0ee4d2f630623ef7e06413a7c34a4a0269a2600fba682f06988a1346be4d07a5"
+sha256 = "0cd7b7d0696323e281f7a23ef7511785b6a519c94ddb9d89c2468bcdc44bbc84"
[[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 = "51982e7f71c598aa1fd23f1883cc84ee69ca15b4cd31671015328fb18e187e0b"
+sha256 = "de653ffb92e6b99e8d390e6c21f5f13ecef198e21be3045e159b86073fbe7f4e"
[[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 = "41b46b0540aead4c11b4a30b72758f6fa90888a544c18108a8c55d3682e1da2a"
+sha256 = "415aa786c8afc8095be14ac9d74b864e968326699ce0130fede5616ccf9e70ad"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -85,7 +85,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 = "f2a9449db49de5226000d0b14b991ab2318a82e4ca63624b11ea4a753ae2ea74"
+sha256 = "d3a89359bdb7c8af830a49a1b574312c65f757e4f69cf221e06f27ea0c0e7373"
[[asset]]
path = "assets/icons/accessibility.svg"
diff --git a/ROADMAP.md b/ROADMAP.md
index a3c9f48b..b2b1f3c2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -94,7 +94,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
-| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page |
+| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews and selection for resizing. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
@@ -125,6 +125,9 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
+- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
+- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
+
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
Newest first. One line per session: the date, what moved, and the new overall percentage.
diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs
index 95e272d3..38f0c9f2 100644
--- a/crates/a11y/src/alt.rs
+++ b/crates/a11y/src/alt.rs
@@ -144,10 +144,8 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
- b"Q" => {
- if ctm.len() > 1 {
- ctm.pop();
- }
+ b"Q" if ctm.len() > 1 => {
+ ctm.pop();
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands)
diff --git a/crates/annot/README.md b/crates/annot/README.md
index b3224b7b..beb0e058 100644
--- a/crates/annot/README.md
+++ b/crates/annot/README.md
@@ -37,6 +37,8 @@ deterministic; the engine supplies them.
cloudy borders or callouts, instead of leaving a stale appearance.
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
never paint a second icon.
+- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
+ viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
## Not yet
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index a0f76306..77bdc292 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -724,7 +724,6 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
| Shape::TextBox { rect, .. }
| Shape::Typewriter { rect, .. }
| Shape::Stamp { rect, .. }
- | Shape::CustomStamp { rect, .. }
| Shape::TypedSignature { rect, .. }
| Shape::Mark { rect, .. } => {
let r = normalize(*rect);
@@ -733,6 +732,14 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
}
r
}
+ Shape::CustomStamp { rect, .. } => {
+ let r = normalize(*rect);
+ // Image signatures may be very thin; a positive PDF appearance box still works.
+ if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] {
+ return Err(bad("rectangle (empty)"));
+ }
+ r
+ }
Shape::Line { from, to, arrow } => {
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
return Err(bad("line (too short)"));
@@ -1353,12 +1360,24 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d
Ok(())
}
-/// Resize a rectangle, oval or text box to `rect`; its appearance is redrawn.
+/// Resize a rectangle, oval, text box or stamp to `rect`. Stamps keep their appearance,
+/// which PDF viewers scale from its bounding box into the new rectangle.
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
+ if subtype == "Stamp" {
+ let rect = normalize(rect);
+ if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] {
+ return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
+ }
+ doc.update_dict(r, |d| {
+ d.set(b"Rect".to_vec(), num_array(&rect));
+ touch(d, meta);
+ })?;
+ return Ok(());
+ }
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index 7b09e4d4..22485246 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -350,13 +350,24 @@ impl Automation {
let [x, y] = a.need::<2>("at", "Fill & Sign")?;
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
+ let kind = a.str("type")?;
+ if let Some(path) = a.opt_str("path")? {
+ if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
+ return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
+ }
+ let path = self.resolve(path, false)?;
+ let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
+ let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
+ let edit = image.edit(page, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
+ return self.apply(a, edit);
+ }
let size = 10.0;
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;
Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size }
};
- let (shape, contents) = match a.str("type")? {
+ let (shape, contents) = match kind {
"text" => {
let t = a.str("text")?.to_string();
(text_at(&t), t)
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index a6838150..deae3b4b 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -618,7 +618,7 @@ pub fn tools() -> Vec {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
- t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box) or position (`move` [dx, dy] in points). One undo step.").with(schema(
+ t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -893,7 +893,7 @@ pub fn tools() -> Vec {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
- "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date, or a typed signature or initials (`text` drawn in a script font as filled outlines; at is its left edge, centred vertically), at `at` [x, y] in points from the top-left of the page (the text's top-left; a mark's centre). Creates movable, undoable annotations.",
+ "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date, or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
)
.cmd("sign.fill.text")
.with(schema(
@@ -903,6 +903,7 @@ pub fn tools() -> Vec {
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
"at": point(),
"text": { "type": "string", "minLength": 1 },
+ "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"author": { "type": "string" },
}),
&["doc", "page", "type", "at"],
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index b3ac82e9..2f4449a9 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -1100,6 +1100,72 @@ fn fill_and_sign_through_tools() {
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
}
+#[test]
+fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
+ let dir = workdir("image-signatures");
+ 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 = (0..40)
+ .flat_map(|y| (0..120).flat_map(move |x| if (40..80).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [0, 0, 0, 0] }))
+ .collect();
+ encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
+ }
+ std::fs::write(dir.join("signature.png"), png).unwrap();
+ std::fs::write(dir.join("broken.png"), b"broken").unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap();
+ for (kind, y) in [("signature", 60), ("initials", 120)] {
+ ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "path": "signature.png" }));
+ }
+ let render = |a: &mut Automation, doc| {
+ let output = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
+ let Content::Png { data, .. } = &output[0] else { panic!() };
+ image::load_from_memory(data).unwrap().to_rgba8()
+ };
+ let before = render(&mut a, doc);
+ assert_eq!(before.get_pixel(25, 60).0, [255, 255, 255, 255], "transparent margin exposes the page");
+ assert_eq!(before.get_pixel(65, 60).0, [0, 0, 0, 255], "signature ink is embedded");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add initials");
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1);
+ ok(&mut a, "edit_redo", json!({ "doc": doc }));
+ // The same resize edit the selection handles use must keep the imported appearance.
+ for rect in [json!([20, 40, 20, 88]), json!([20, 40, 100000000, 88])] {
+ assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "rect": rect })).is_err());
+ }
+ ok(&mut a, "comment_lock", json!({ "doc": doc, "page": 1, "index": 1 }));
+ assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "rect": [20, 40, 164, 88] })).is_err());
+ ok(&mut a, "comment_lock", json!({ "doc": doc, "page": 1, "index": 1, "locked": false }));
+ assert_eq!(render(&mut a, doc), before, "invalid and locked resizes leave the image intact");
+ ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "rect": [20, 40, 164, 88] }));
+ let resized = render(&mut a, doc);
+ assert_eq!(resized.get_pixel(110, 64).0, [0, 0, 0, 255], "resizing scales the original ink");
+ assert_eq!(resized.get_pixel(25, 64).0, [255, 255, 255, 255], "resizing retains alpha");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Resize comment");
+ assert_eq!(render(&mut a, doc), before);
+ ok(&mut a, "edit_redo", json!({ "doc": doc }));
+ assert_eq!(render(&mut a, doc), resized);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "signed.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": reopened }))["count"], 2);
+ assert_eq!(render(&mut a, reopened), resized, "resized appearances retain alpha and geometry after save");
+ for args in [
+ json!({ "type": "signature", "path": "broken.png" }),
+ json!({ "type": "signature", "path": "signature.png", "text": "Ada" }),
+ json!({ "type": "check", "path": "signature.png" }),
+ json!({ "type": "signature", "path": "../outside.png" }),
+ ] {
+ let mut args = args;
+ args["doc"] = json!(doc);
+ args["page"] = json!(1);
+ args["at"] = json!([20, 80]);
+ assert!(a.call("fill_sign_add", &args).is_err(), "{args}");
+ }
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
+}
+
#[test]
fn creating_and_reducing_through_tools() {
let dir = workdir("create");
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 5fbc9a4b..bcf762fc 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
pdfcraft-measure = { workspace = true }
-image = { version = "0.25", default-features = false, features = ["jpeg", "tiff"] }
+image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-organize = { workspace = true }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 2e623506..ca97c80f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -20,8 +20,11 @@ pub mod export;
pub mod js;
pub mod links;
pub mod ocr;
+pub mod signature_image;
pub mod xfa;
+pub use signature_image::SignatureImage;
+
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
@@ -1434,7 +1437,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::AddCustomStamp { page, rect, name, file, author } => {
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
- let rect = if (rect[2] - rect[0]).abs() < 1.0 || (rect[3] - rect[1]).abs() < 1.0 {
+ let rect = if rect[2] == rect[0] && rect[3] == rect[1] {
let k = (200.0 / sw.max(sh)).min(1.0);
let (w, h) = (sw * k, sh * k);
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs
new file mode 100644
index 00000000..264c9c73
--- /dev/null
+++ b/crates/engine/src/signature_image.rs
@@ -0,0 +1,186 @@
+//! Local image signatures, shared by Fill & Sign and its automation tool.
+
+use std::io::{Cursor, Read};
+use std::sync::Arc;
+
+use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
+
+use crate::{Edit, MarkFile};
+
+/// Saved signatures live in settings, so both source and normalized PNG are bounded.
+pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
+const MAX_PIXELS: u64 = 4 << 20;
+
+#[derive(Debug, thiserror::Error)]
+pub enum SignatureImageError {
+ #[error("Choose a PNG or JPEG image.")]
+ Format,
+ #[error("Choose an image smaller than 4 MiB and 4 megapixels (at most 4096 pixels per side).")]
+ Size,
+ #[error("Couldn't read the signature image: {0}")]
+ Read(#[from] std::io::Error),
+ #[error("Couldn't decode the signature image: {0}")]
+ Decode(#[from] image::ImageError),
+}
+
+/// A validated image, normalized to PNG without source paths or metadata. Alpha is preserved.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SignatureImage {
+ bytes: Arc>,
+ rgba: Arc>,
+ size: [usize; 2],
+}
+
+impl SignatureImage {
+ /// Read at most the file limit plus one byte, even if the file grows while being read.
+ pub fn read(reader: impl Read) -> Result {
+ let mut bytes = Vec::new();
+ reader.take(MAX_SIGNATURE_IMAGE_BYTES as u64 + 1).read_to_end(&mut bytes)?;
+ Self::from_bytes(&bytes)
+ }
+
+ pub fn from_bytes(bytes: &[u8]) -> Result {
+ if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
+ return Err(SignatureImageError::Size);
+ }
+ let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
+ if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
+ return Err(SignatureImageError::Format);
+ }
+ let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
+ let mut limits = Limits::default();
+ limits.max_image_width = Some(4096);
+ limits.max_image_height = Some(4096);
+ limits.max_alloc = Some(32 << 20);
+ reader.limits(limits);
+ let mut decoder = reader.into_decoder()?;
+ let (w, h) = decoder.dimensions();
+ if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
+ return Err(SignatureImageError::Size);
+ }
+ let orientation = decoder.orientation()?;
+ let mut image = DynamicImage::from_decoder(decoder)?;
+ image.apply_orientation(orientation);
+ let rgba = image.into_rgba8();
+ let size = [rgba.width() as usize, rgba.height() as usize];
+ let mut png = Cursor::new(Vec::new());
+ DynamicImage::ImageRgba8(rgba.clone()).write_to(&mut png, ImageFormat::Png)?;
+ let bytes = png.into_inner();
+ if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
+ return Err(SignatureImageError::Size);
+ }
+ Ok(Self { bytes: Arc::new(bytes), rgba: Arc::new(rgba.into_raw()), size })
+ }
+
+ pub fn bytes(&self) -> &Arc> {
+ &self.bytes
+ }
+
+ pub fn rgba(&self) -> &[u8] {
+ &self.rgba
+ }
+
+ pub fn size(&self) -> [usize; 2] {
+ self.size
+ }
+
+ /// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names.
+ pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
+ if !at.iter().all(|v| v.is_finite()) {
+ return None;
+ }
+ let [w, h] = self.size;
+ let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
+ let (width, height) = (w as f64 * scale, h as f64 * scale);
+ Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0])
+ }
+
+ pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option {
+ let rect = self.rect(at, initials)?;
+ let label = if initials { "Add initials" } else { "Add signature" };
+ Some(Edit::Batch {
+ label: label.into(),
+ edits: vec![Edit::AddCustomStamp {
+ page,
+ rect,
+ name: if initials { "Initials" } else { "Signature" }.into(),
+ file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 },
+ author: author.into(),
+ }],
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn png(w: u32, h: u32) -> Vec {
+ let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128]));
+ let mut out = Cursor::new(Vec::new());
+ image.write_to(&mut out, ImageFormat::Png).unwrap();
+ out.into_inner()
+ }
+
+ #[test]
+ fn image_signatures_preserve_alpha_and_fit_the_signature_box() {
+ let image = SignatureImage::from_bytes(&png(400, 40)).unwrap();
+ assert_eq!(image.size(), [400, 40]);
+ assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]);
+ let Edit::Batch { edits, .. } = image.edit(0, [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
+ let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() };
+ assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]);
+ assert!(image.edit(0, [f64::NAN, 0.0], false, "").is_none());
+ assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image);
+ }
+
+ #[test]
+ fn invalid_and_oversized_signature_images_are_rejected() {
+ assert!(SignatureImage::from_bytes(b"not an image").is_err());
+ assert!(SignatureImage::from_bytes(b"\x89PNG\r\n\x1a\ntruncated").is_err());
+ assert!(SignatureImage::from_bytes(&vec![0; MAX_SIGNATURE_IMAGE_BYTES + 1]).is_err());
+ assert!(SignatureImage::from_bytes(&png(4097, 1)).is_err());
+ assert!(SignatureImage::from_bytes(&png(4096, 1025)).is_err());
+ assert!(SignatureImage::read(std::io::repeat(0)).is_err());
+ assert!(matches!(SignatureImage::from_bytes(b"GIF89a\x02\0\x02\0"), Err(SignatureImageError::Format)));
+ }
+
+ #[test]
+ fn jpeg_signatures_are_normalized_to_png() {
+ let mut jpeg = Cursor::new(Vec::new());
+ image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20])).write_to(&mut jpeg, ImageFormat::Jpeg).unwrap();
+ let image = SignatureImage::from_bytes(&jpeg.into_inner()).unwrap();
+ assert_eq!(image.size(), [60, 20]);
+ assert!(image.bytes().starts_with(b"\x89PNG\r\n\x1a\n"));
+ assert!(image.rgba().chunks_exact(4).all(|p| p[3] == 255));
+ }
+
+ #[test]
+ fn jpeg_signature_orientation_is_applied_before_preview_and_embedding() {
+ let mut jpeg = Cursor::new(Vec::new());
+ image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20])).write_to(&mut jpeg, ImageFormat::Jpeg).unwrap();
+ let jpeg = jpeg.into_inner();
+ // Synthetic EXIF IFD with Orientation=6 (90 degrees clockwise).
+ let exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x06\0\0\0\0\0\0\0";
+ let mut rotated = jpeg[..2].to_vec();
+ rotated.extend_from_slice(&[0xff, 0xe1]);
+ rotated.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
+ rotated.extend_from_slice(exif);
+ rotated.extend_from_slice(&jpeg[2..]);
+ let image = SignatureImage::from_bytes(&rotated).unwrap();
+ assert_eq!(image.size(), [20, 60]);
+ assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap().size(), [20, 60]);
+ assert!(!image.bytes().windows(4).any(|w| w == b"Exif"));
+ }
+
+ #[test]
+ fn very_wide_image_signatures_keep_the_requested_rectangle() {
+ let image = SignatureImage::from_bytes(&png(3000, 10)).unwrap();
+ let mut session = crate::Session::new();
+ let blank = session.create_blank(300.0, 400.0, 1).unwrap();
+ let id = session.open_new("form.pdf", blank).unwrap();
+ session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
+ let a = &session.get(id).unwrap().info.annotations[0];
+ assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]);
+ }
+}
diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs
index f023a854..69e16ba3 100644
--- a/crates/optimize/src/images.rs
+++ b/crates/optimize/src/images.rs
@@ -53,10 +53,8 @@ fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize
let top = stack.last().copied().unwrap_or(ctm);
match op.op.as_slice() {
b"q" => stack.push(top),
- b"Q" => {
- if stack.len() > 1 {
- stack.pop();
- }
+ b"Q" if stack.len() > 1 => {
+ stack.pop();
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands)
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index ab00b626..8f817457 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -40,6 +40,7 @@ skrifa = "0.44"
sys-locale = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
+rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
@@ -56,4 +57,4 @@ skrifa = "0.44"
[build-dependencies]
serde_json = { workspace = true }
-toml = "1"
\ No newline at end of file
+toml = "1"
diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs
index 6d157f3a..5a5a9c66 100644
--- a/crates/ui-egui/src/a11y_ui.rs
+++ b/crates/ui-egui/src/a11y_ui.rs
@@ -386,12 +386,13 @@ impl PdfCraftApp {
let edit = self.session.get(id).map(|d| d.accessibility_fix(rule, None));
match edit {
Some(Ok(e)) => {
- if self.apply_edit(e) {
- self.run_accessibility_check();
- // The title can then be edited.
- if rule == Rule::Title {
- self.dialog = Some(Dialog::Properties(PropsTab::Description));
- }
+ if !self.apply_edit(e) {
+ return;
+ }
+ self.run_accessibility_check();
+ // The title can then be edited.
+ if rule == Rule::Title {
+ self.dialog = Some(Dialog::Properties(PropsTab::Description));
}
}
Some(Err(e)) => self.notify_error(e),
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 8a1e5118..37ac3a34 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -275,6 +275,8 @@ pub struct DocView {
pub marquee_done: Option,
/// Fill & Sign text being typed.
pub fill_text: Option,
+ /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
+ pub(crate) fill_signature_page: Option,
/// A non-edit action requested by the organize toolbar, handled by the app.
pub pending_action: Option,
}
@@ -381,6 +383,7 @@ impl DocView {
marquee: None,
marquee_done: None,
fill_text: None,
+ fill_signature_page: None,
}
}
@@ -1490,8 +1493,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let QuickTool::Fill(ft) = tool
&& allowed
{
- match crate::fill_sign::page_input(ui, &resp, &xf, i, info, ft, view, signature.as_ref(), initials.as_ref(), &author, today) {
+ match crate::fill_sign::page_input(
+ ui,
+ &resp,
+ &xf,
+ i,
+ info,
+ ft,
+ view,
+ signature.as_ref(),
+ initials.as_ref(),
+ &mut app.signature_preview,
+ &author,
+ today,
+ ) {
Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e),
+ Some(crate::fill_sign::FillAction::Signature(e)) => {
+ view.pending_edit = Some(*e);
+ view.fill_signature_page = Some(i);
+ }
Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true,
Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true,
None => {}
@@ -1660,8 +1680,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
}
- // Link hover + click.
- if let Some(p) = pointer {
+ // Fill & Sign keeps its placement cursor clear of link/comment hover feedback.
+ if !matches!(tool, QuickTool::Fill(_))
+ && let Some(p) = pointer
+ {
for l in info.links.iter().filter(|l| l.page == i) {
let sr = xf.user_rect(info, i, l.rect);
if sr.contains(p) {
diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs
index a60819bd..6cec279a 100644
--- a/crates/ui-egui/src/comments.rs
+++ b/crates/ui-egui/src/comments.rs
@@ -483,10 +483,10 @@ fn is_markup(subtype: &str) -> bool {
matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly")
}
-/// Rectangles, ovals and text boxes. A callout's `/Rect` also holds its leader line, so it
+/// Rectangles, ovals, text boxes and stamps. A callout's `/Rect` also holds its leader line, so it
/// only moves.
fn resizable(a: &Annotation) -> bool {
- matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText") && a.intent.as_deref() != Some("FreeTextCallout")
+ matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp") && a.intent.as_deref() != Some("FreeTextCallout")
}
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index fc2a610d..dd9a7889 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -814,14 +814,17 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
return;
}
Dialog::Signature => {
- let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview);
+ let (apply, cancel, browse) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview);
+ if browse {
+ app.pick_signature_image();
+ }
if apply {
let d = std::mem::take(&mut app.signature_draft);
let tool = if d.initials {
- app.initials = Some(d.saved());
+ app.initials = d.saved();
crate::fill_sign::FillTool::Initials
} else {
- app.signature = Some(d.saved());
+ app.signature = d.saved();
crate::fill_sign::FillTool::Signature
};
app.quick_tool = crate::QuickTool::Fill(tool);
@@ -1190,10 +1193,8 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
}
}
}
- Some(Dialog::Sanitize) => {
- if app.apply_edit(Edit::Sanitize) {
- app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file.");
- }
+ Some(Dialog::Sanitize) if app.apply_edit(Edit::Sanitize) => {
+ app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file.");
}
Some(Dialog::RedactApply) => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 27b26277..e8a9b8ee 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -208,12 +208,29 @@ impl PdfCraftApp {
/// user has been told why, so the typing isn't lost and the caller can stop.
fn apply_queued_edit(&mut self, i: usize) -> bool {
let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) else { return true };
+ let signature_page = self.views.get_mut(i).and_then(|v| v.fill_signature_page.take());
let committed = self.views.get_mut(i).and_then(|v| v.forms.committed.take());
let typed = match (&edit, &committed) {
(Edit::SetFieldValue { name, .. }, Some(draft)) => *name == draft.name,
_ => false,
};
- if self.apply_edit(edit) || !typed {
+ if self.apply_edit(edit) {
+ if let Some(page) = signature_page
+ && let Some(view) = self.views.get_mut(i)
+ {
+ // Signature imports are a labeled batch; select their appended stamp just
+ // as AddAnnotation selects a typed or drawn signature.
+ let newest = self
+ .session
+ .get(view.id)
+ .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && a.in_reply_to.is_none()).map(|a| a.index).max());
+ view.comments.selected = newest.map(|index| (page, index));
+ view.comments.reveal = true;
+ self.quick_tool = crate::QuickTool::Select;
+ }
+ return true;
+ }
+ if !typed {
return true;
}
if let (Some(mut draft), Some(view)) = (committed, self.views.get_mut(i)) {
diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs
index dad01205..702cc9c4 100644
--- a/crates/ui-egui/src/fill_sign.rs
+++ b/crates/ui-egui/src/fill_sign.rs
@@ -4,7 +4,7 @@
//! any comment.
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
-use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, Style};
+use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
@@ -26,11 +26,31 @@ pub const FILL_TOOLS: [FillTool; 8] =
[FillTool::Text, FillTool::Cross, FillTool::Check, FillTool::Dot, FillTool::Line, FillTool::Date, FillTool::Signature, FillTool::Initials];
/// A saved signature or initials: drawn strokes (normalised to the pad width, y up) or typed
-/// text (drawn in the script font).
+/// text (drawn in the script font), or a locally imported image.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SavedSig {
Drawn(Vec>),
Typed(String),
+ Image(#[serde(with = "image_data")] SignatureImage),
+}
+
+mod image_data {
+ use base64::Engine as _;
+ use pdfcraft_engine::{SignatureImage, signature_image::MAX_SIGNATURE_IMAGE_BYTES};
+ use serde::{Deserialize, Deserializer, Serializer};
+
+ pub fn serialize(image: &SignatureImage, s: S) -> Result {
+ s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(image.bytes().as_slice()))
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result {
+ let encoded = String::deserialize(d)?;
+ if encoded.len() > MAX_SIGNATURE_IMAGE_BYTES.div_ceil(3) * 4 {
+ return Err(serde::de::Error::custom("signature image exceeds 4 MiB"));
+ }
+ let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).map_err(serde::de::Error::custom)?;
+ SignatureImage::from_bytes(&bytes).map_err(serde::de::Error::custom)
+ }
}
/// The Create signature / initials dialog.
@@ -40,6 +60,9 @@ pub struct SigDraft {
pub text: String,
/// The Draw tab (otherwise Type).
pub drawing: bool,
+ /// The Image tab. The file is held in the draft until Apply.
+ pub image_mode: bool,
+ pub image: Option,
/// Creating initials (otherwise the signature).
pub initials: bool,
/// Replacing an existing saved value.
@@ -49,7 +72,7 @@ pub struct SigDraft {
impl SigDraft {
pub fn new(initials: bool, name: &str) -> Self {
let text = if initials { name.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { name.trim().to_string() };
- Self { strokes: Vec::new(), text, drawing: false, initials, editing: false }
+ Self { text, initials, ..Default::default() }
}
/// Edit a copy of the saved value; Cancel must leave the original untouched.
@@ -57,11 +80,14 @@ impl SigDraft {
match saved {
SavedSig::Drawn(strokes) => Self { strokes: strokes.clone(), drawing: true, initials, editing: true, ..Default::default() },
SavedSig::Typed(text) => Self { text: text.clone(), initials, editing: true, ..Default::default() },
+ SavedSig::Image(image) => Self { image: Some(image.clone()), image_mode: true, initials, editing: true, ..Default::default() },
}
}
fn ready(&self) -> bool {
- if self.drawing {
+ if self.image_mode {
+ self.image.is_some()
+ } else if self.drawing {
self.strokes.iter().any(|s| s.len() > 1)
} else {
!self.text.trim().is_empty()
@@ -69,8 +95,14 @@ impl SigDraft {
}
}
- pub fn saved(&self) -> SavedSig {
- if self.drawing { SavedSig::Drawn(self.strokes.clone()) } else { SavedSig::Typed(self.text.trim().to_string()) }
+ pub fn saved(&self) -> Option {
+ if self.image_mode {
+ self.image.clone().map(SavedSig::Image)
+ } else if self.drawing {
+ Some(SavedSig::Drawn(self.strokes.clone()))
+ } else {
+ Some(SavedSig::Typed(self.text.trim().to_string()))
+ }
}
}
@@ -183,6 +215,7 @@ pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, au
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option {
match sig {
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
+ SavedSig::Image(image) => image.edit(page, at, initials, author),
SavedSig::Typed(text) => {
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
let height = if initials { 24.0_f64 } else { 32.0_f64 };
@@ -267,9 +300,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
match saved {
SavedSig::Typed(text) => {
let cache = &mut app.saved_signature_previews[i];
- if cache.as_ref().is_none_or(|(s, _)| s != text) {
+ if cache.as_ref().is_none_or(|(s, _)| s != saved) {
*cache = Some((
- text.clone(),
+ saved.clone(),
ui.ctx().load_texture(format!("saved-{what}"), script_preview(text, 480, 104), egui::TextureOptions::LINEAR),
));
}
@@ -283,6 +316,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
);
}
}
+ SavedSig::Image(image) => {
+ image_preview(ui, preview_rect, image, &mut app.saved_signature_previews[i]);
+ }
SavedSig::Drawn(strokes) => {
let bounds = strokes
.iter()
@@ -317,7 +353,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
command
}
-/// Clicks with a Fill & Sign tool on one page. Returns `true` when the click was used.
+/// Preview and clicks with a Fill & Sign tool on one page.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
@@ -329,18 +365,48 @@ pub(crate) fn page_input(
view: &mut DocView,
signature: Option<&SavedSig>,
initials: Option<&SavedSig>,
+ preview: &mut Option<(SavedSig, egui::TextureHandle)>,
author: &str,
today: (i64, u32, u32),
) -> Option {
let pointer = ui.input(|i| i.pointer.hover_pos())?;
- if !xf.rect.contains(pointer) {
- return None;
- }
- ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
- if !resp.clicked() {
+ if !resp.contains_pointer() || !xf.rect.contains(pointer) {
return None;
}
let at = to_user(xf, info, page, pointer);
+ let saved = match tool {
+ FillTool::Signature => signature,
+ FillTool::Initials => initials,
+ _ => None,
+ };
+ if let Some(SavedSig::Image(image)) = saved {
+ if let Some(rect) = image.rect(at, tool == FillTool::Initials) {
+ // Map each corner, rather than the bounding box, so page and view rotations
+ // turn the preview exactly as they turn the placed stamp.
+ let tex = image_texture(ui, image, preview);
+ let mut mesh = egui::Mesh::with_texture(tex);
+ let corners = [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)];
+ if let Some(p) = info.pages.get(page) {
+ for (x, y, u, v) in corners {
+ let p = p.user_to_view(x as f32, y as f32);
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos: xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph),
+ uv: pos2(u, v),
+ color: Color32::WHITE,
+ });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ ui.painter().add(egui::Shape::mesh(mesh));
+ ui.ctx().set_cursor_icon(egui::CursorIcon::None);
+ }
+ }
+ } else {
+ ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
+ }
+ if !resp.clicked() {
+ return None;
+ }
match tool {
FillTool::Text => {
view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
@@ -351,11 +417,11 @@ pub(crate) fn page_input(
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
}
FillTool::Signature => match signature {
- Some(s) => place(page, at, s, false, author).map(|e| FillAction::Edit(Box::new(e))),
+ Some(s) => place(page, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateSignature),
},
FillTool::Initials => match initials {
- Some(s) => place(page, at, s, true, author).map(|e| FillAction::Edit(Box::new(e))),
+ Some(s) => place(page, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateInitials),
},
mark => {
@@ -371,6 +437,8 @@ pub(crate) fn page_input(
#[derive(Clone, Debug, PartialEq)]
pub enum FillAction {
Edit(Box),
+ /// Place once, then select the new signature or initials for adjustment.
+ Signature(Box),
/// No signature yet: open the signature pad.
CreateSignature,
/// No initials yet.
@@ -421,20 +489,42 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
None
}
-/// The signature pad: draw with the pointer; returns the strokes (normalised) on Apply.
-pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, preview: &mut Option<(String, egui::TextureHandle)>) -> (bool, bool) {
+/// The signature pad: type, draw or import an image. Returns Apply, Cancel and Browse.
+pub(crate) fn signature_pad(
+ ui: &mut egui::Ui,
+ t: &Tokens,
+ d: &mut SigDraft,
+ preview: &mut Option<(SavedSig, egui::TextureHandle)>,
+) -> (bool, bool, bool) {
let what = if d.initials { tl!("initials") } else { tl!("signature") };
let title = if d.editing { tl!("Change {what}") } else { tl!("Create {what}") };
ui.label(egui::RichText::new(crate::i18n::fmt(title, &[("what", what)])).font(crate::theme::semibold(18.0)));
ui.horizontal(|ui| {
- if crate::widgets::pill_button(ui, tl!("Type"), !d.drawing).clicked() {
+ if crate::widgets::pill_button(ui, tl!("Type"), !d.drawing && !d.image_mode).clicked() {
d.drawing = false;
+ d.image_mode = false;
}
- if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing).clicked() {
+ if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing && !d.image_mode).clicked() {
d.drawing = true;
+ d.image_mode = false;
+ }
+ if crate::widgets::pill_button(ui, tl!("Image"), d.image_mode).clicked() {
+ d.image_mode = true;
}
});
ui.add_space(6.0);
+ if d.image_mode {
+ ui.label(egui::RichText::new(tl!("Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved.")).color(t.text_muted));
+ let browse = crate::widgets::ghost_button(ui, "folder", tl!("Browse…")).clicked();
+ let (rect, _) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::hover());
+ ui.painter().rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
+ ui.painter().rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
+ if let Some(image) = &d.image {
+ image_preview(ui, rect.shrink(8.0), image, preview);
+ }
+ let (apply, cancel) = pad_buttons(ui, d);
+ return (apply, cancel, browse);
+ }
if !d.drawing {
let l = ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Type your {what}."), &[("what", what)])).color(t.text_muted));
ui.add(
@@ -448,14 +538,16 @@ pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, pre
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
- if preview.as_ref().is_none_or(|(s, _)| *s != d.text) {
+ let key = SavedSig::Typed(d.text.clone());
+ if preview.as_ref().is_none_or(|(s, _)| *s != key) {
let img = script_preview(&d.text, 920, 300);
- *preview = Some((d.text.clone(), ui.ctx().load_texture("typed-signature", img, egui::TextureOptions::LINEAR)));
+ *preview = Some((key, ui.ctx().load_texture("typed-signature", img, egui::TextureOptions::LINEAR)));
}
if let Some((_, tex)) = preview {
painter.image(tex.id(), rect.shrink(4.0), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
- return pad_buttons(ui, d);
+ let (apply, cancel) = pad_buttons(ui, d);
+ return (apply, cancel, false);
}
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Draw your {what} below."), &[("what", what)])).color(t.text_muted));
let strokes = &mut d.strokes;
@@ -481,7 +573,33 @@ pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, pre
let pts: Vec = s.iter().map(|p| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.width())).collect();
painter.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::BLACK)));
}
- pad_buttons(ui, d)
+ let (apply, cancel) = pad_buttons(ui, d);
+ (apply, cancel, false)
+}
+
+fn image_texture(ui: &egui::Ui, image: &SignatureImage, cache: &mut Option<(SavedSig, egui::TextureHandle)>) -> egui::TextureId {
+ let key = SavedSig::Image(image.clone());
+ if cache.as_ref().is_some_and(|(s, _)| *s != key) {
+ *cache = None;
+ }
+ let (_, tex) = cache.get_or_insert_with(|| {
+ let pixels = egui::ColorImage::from_rgba_unmultiplied(image.size(), image.rgba());
+ (key, ui.ctx().load_texture("signature-image", pixels, egui::TextureOptions::LINEAR))
+ });
+ tex.id()
+}
+
+fn image_preview(ui: &egui::Ui, rect: egui::Rect, image: &SignatureImage, cache: &mut Option<(SavedSig, egui::TextureHandle)>) {
+ let tex = image_texture(ui, image, cache);
+ let [w, h] = image.size();
+ let size = vec2(w as f32, h as f32);
+ let size = size * (rect.width() / size.x).min(rect.height() / size.y);
+ ui.painter().image(
+ tex,
+ 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,
+ );
}
fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
@@ -491,6 +609,7 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
if ui.button(tl!("Clear")).clicked() {
d.strokes.clear();
d.text.clear();
+ d.image = None;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ready = d.ready();
@@ -505,6 +624,99 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
(apply, cancel)
}
+#[cfg(target_arch = "wasm32")]
+#[derive(Clone, Default)]
+pub(crate) struct ImageInbox {
+ busy: std::sync::Arc,
+ picked: std::sync::Arc>>,
+}
+
+#[cfg(target_arch = "wasm32")]
+struct PickedImage {
+ epoch: u64,
+ initials: bool,
+ bytes: Option, String>>,
+}
+
+impl crate::PdfCraftApp {
+ /// Browse asynchronously, answering only into the dialog that asked for the image.
+ pub(crate) fn pick_signature_image(&mut self) {
+ let epoch = self.dialog_epoch();
+ let initials = self.signature_draft.initials;
+ let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]);
+ #[cfg(not(target_arch = "wasm32"))]
+ self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
+ if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Signature) || app.signature_draft.initials != initials {
+ return;
+ }
+ let result =
+ std::fs::File::open(path).map_err(pdfcraft_engine::signature_image::SignatureImageError::from).and_then(SignatureImage::read);
+ app.use_signature_image(result.map_err(|e| e.to_string()));
+ });
+ #[cfg(target_arch = "wasm32")]
+ {
+ use std::sync::atomic::Ordering;
+ if self.signature_images.busy.swap(true, Ordering::SeqCst) {
+ self.notify_tr("Another file dialog is still open. Finish with it first.");
+ return;
+ }
+ let inbox = self.signature_images.clone();
+ let ctx = self.ctx.clone();
+ wasm_bindgen_futures::spawn_local(async move {
+ let bytes = if let Some(file) = dialog.pick_file().await {
+ if file.inner().size() > pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES as f64 {
+ Some(Err(pdfcraft_engine::signature_image::SignatureImageError::Size.to_string()))
+ } else {
+ // `pick_file` returns a readable handle. Read its Blob directly so a
+ // rejected browser read is an error (rfd's read helper unwraps it).
+ Some(match wasm_bindgen_futures::JsFuture::from(file.inner().array_buffer()).await {
+ Ok(buffer) => {
+ let bytes = js_sys::Uint8Array::new(&buffer);
+ if bytes.length() as usize > pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES {
+ Err(pdfcraft_engine::signature_image::SignatureImageError::Size.to_string())
+ } else {
+ Ok(bytes.to_vec())
+ }
+ }
+ Err(error) => Err(format!("{error:?}")),
+ })
+ }
+ } else {
+ None
+ };
+ *inbox.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PickedImage { epoch, initials, bytes });
+ if let Some(ctx) = ctx {
+ ctx.request_repaint();
+ }
+ });
+ }
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ pub(crate) fn process_signature_images(&mut self) {
+ let picked = self.signature_images.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
+ let Some(picked) = picked else { return };
+ self.signature_images.busy.store(false, std::sync::atomic::Ordering::SeqCst);
+ if self.dialog_epoch() != picked.epoch || self.dialog != Some(crate::Dialog::Signature) || self.signature_draft.initials != picked.initials {
+ return;
+ }
+ let Some(bytes) = picked.bytes else { return };
+ let result = bytes
+ .and_then(|bytes| pdfcraft_engine::guard(|| SignatureImage::from_bytes(&bytes)).map_err(|e| e.to_string())?.map_err(|e| e.to_string()));
+ self.use_signature_image(result);
+ }
+
+ fn use_signature_image(&mut self, result: Result) {
+ match result {
+ Ok(image) => {
+ self.signature_draft.image = Some(image);
+ self.toast = None;
+ }
+ Err(e) => self.notify_fmt("Couldn't import the signature image: {e}", &[("e", &e)]),
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
#[test]
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 2f70d40f..cf0e5d03 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -104,3 +104,5 @@
Fit to width scrolling Plynulé posouvání s přizpůsobením šířce
Fit one full page Celá jedna stránka
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována.
+ Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e}
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index f57ccb83..a3f96154 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -1955,3 +1955,5 @@
Default page display Presentación de página predeterminada
Default zoom Zoom predeterminado
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
+ Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 5f75508f..047daeba 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -1989,3 +1989,5 @@
{n} files {n} fichiers
{n} files need attention {n} fichiers demandent votre attention
{n} selected {n} sélectionnés
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Choisissez une image PNG ou JPEG (jusqu’à 4 Mio). La transparence est conservée.
+ Couldn't import the signature image: {e} Impossible d’importer l’image de la signature : {e}
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 3ecfdc00..14d72fd0 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -1931,3 +1931,5 @@
Page display: {layout} ページ表示:{layout}
Fit to width scrolling 幅に合わせてスクロール
Fit one full page 1ページずつ全体を表示
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG または JPEG 画像を選択してください(最大 4 MiB)。透明度は保持されます。
+ Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e}
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index d30e3181..5cf6f8bd 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -58,3 +58,5 @@
Enable Acrobat JavaScript Ativar o Acrobat JavaScript
OK OK
Auto Automático
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Escolha uma imagem PNG ou JPEG (até 4 MiB). A transparência é preservada.
+ Couldn't import the signature image: {e} Não foi possível importar a imagem da assinatura: {e}
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 6ad1c90e..e1f0200e 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2000,3 +2000,5 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA 表单:字段与 XFA 数据保持同步,以便其他阅读器显示您填写的内容。
@plural {n} page|{n} pages {n} 页
@plural {n} field|{n} fields {n} 个字段
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 选择 PNG 或 JPEG 图像(最大 4 MiB)。透明度会保留。
+ Couldn't import the signature image: {e} 无法导入签名图像:{e}
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 3729710d..704be78f 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -1924,3 +1924,5 @@
Page display: {layout} 頁面顯示:{layout}
Fit to width scrolling 符合寬度連續捲動
Fit one full page 單頁完整顯示
+ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 選擇 PNG 或 JPEG 圖像(最大 4 MiB)。透明度會保留。
+ Couldn't import the signature image: {e} 無法匯入簽名圖像:{e}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 400ce5df..bed9bb6f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -493,8 +493,10 @@ pub struct PdfCraftApp {
pub initials: Option,
/// The Create signature / initials dialog, and its typed preview.
pub signature_draft: fill_sign::SigDraft,
- pub(crate) signature_preview: Option<(String, egui::TextureHandle)>,
- pub(crate) saved_signature_previews: [Option<(String, egui::TextureHandle)>; 2],
+ pub(crate) signature_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
+ pub(crate) saved_signature_previews: [Option<(fill_sign::SavedSig, egui::TextureHandle)>; 2],
+ #[cfg(target_arch = "wasm32")]
+ pub(crate) signature_images: fill_sign::ImageInbox,
/// The Comment Properties dialog's state.
pub comment_props: Option,
pub field_props: Option,
@@ -669,6 +671,8 @@ impl PdfCraftApp {
signature_draft: Default::default(),
signature_preview: None,
saved_signature_previews: [None, None],
+ #[cfg(target_arch = "wasm32")]
+ signature_images: Default::default(),
comment_props: None,
field_props: None,
redact_prefs: RedactPrefs::default(),
@@ -1074,6 +1078,7 @@ impl PdfCraftApp {
// Drawn signatures keep their original form (older settings read the same).
"signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None },
"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,
// 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::>(),
@@ -1124,6 +1129,9 @@ impl PdfCraftApp {
if let Some(t) = v["signature_text"].as_str().filter(|t| !t.trim().is_empty()) {
self.signature = Some(fill_sign::SavedSig::Typed(t.to_string()));
}
+ if let Ok(s @ fill_sign::SavedSig::Image(_)) = serde_json::from_value::(v["signature_image"].clone()) {
+ self.signature = Some(s);
+ }
if let Ok(i) = serde_json::from_value::(v["initials"].clone()) {
self.initials = Some(i);
}
@@ -1463,6 +1471,8 @@ impl eframe::App for PdfCraftApp {
if self.active.is_some() {
self.combine_tab.focused = false;
}
+ #[cfg(target_arch = "wasm32")]
+ self.process_signature_images();
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
// Files dropped on the Combine files tab join its list instead of opening.
diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs
index c256e649..7ab81861 100644
--- a/crates/ui-egui/tests/fill_sign.rs
+++ b/crates/ui-egui/tests/fill_sign.rs
@@ -27,8 +27,16 @@ fn harness() -> Harness<'static, PdfCraftApp> {
}
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
- let r = h.state().views[0].page_screen_rect(0).expect("on screen");
- pos2(r.left() + x / 300.0 * r.width(), r.top() + (400.0 - y) / 400.0 * r.height())
+ let s = h.state();
+ let page = &s.session.get(s.views[0].id).unwrap().info.pages[0];
+ let xf = pdfcraft_ui_egui::canvas::PageXform {
+ rect: s.views[0].page_screen_rect(0).expect("on screen"),
+ rot: s.views[0].rotation,
+ pw: page.width,
+ ph: page.height,
+ };
+ let p = page.user_to_view(x, y);
+ xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph)
}
fn click(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
@@ -48,12 +56,28 @@ fn items(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option)> {
v
}
+/// Page rasters arrive from the render worker independently of kittest's virtual frames.
+fn rendered_ink(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) -> image::RgbaImage {
+ for _ in 0..50 {
+ h.run_steps(2);
+ let p = at(h, x, y);
+ let pixels = h.render().unwrap();
+ if pixels.get_pixel(p.x as u32, p.y as u32).0[..3].iter().all(|v| *v < 60) {
+ return pixels;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ panic!("signature ink did not render at ({x}, {y})");
+}
+
#[test]
fn text_marks_and_date() {
let mut h = harness();
assert!(h.state_mut().execute("sign.fill.check"));
assert!(matches!(h.state().quick_tool, QuickTool::Fill(_)));
click(&mut h, 50.0, 350.0);
+ click(&mut h, 80.0, 350.0);
+ assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "checkmarks stay repeatable");
h.state_mut().execute("sign.fill.date");
click(&mut h, 50.0, 300.0);
h.state_mut().execute("sign.fill.text");
@@ -63,7 +87,7 @@ fn text_marks_and_date() {
h.key_press(egui::Key::Enter);
h.run_steps(4);
let v = items(&h);
- assert_eq!(v.len(), 3, "{v:?}");
+ assert_eq!(v.len(), 4, "{v:?}");
assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref() == Some("Ada Lovelace")));
assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref().is_some_and(|c| c.matches('/').count() == 2)), "a date: {v:?}");
assert!(v.iter().any(|(t, _)| t == "Stamp"));
@@ -95,6 +119,8 @@ fn signing_draws_a_signature_once_and_places_it() {
assert_eq!(h.state().dialog, None);
click(&mut h, 60.0, 100.0);
assert!(items(&h).iter().any(|(t, _)| t == "Ink"));
+ assert_eq!(h.state().quick_tool, QuickTool::Select);
+ assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
// The signature is remembered (persisted with the app's settings).
let saved = h.state().persist();
let mut again = PdfCraftApp::new();
@@ -116,6 +142,7 @@ fn typed_signatures_and_initials() {
assert_eq!(h.state().signature, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Grace Hopper".into())));
click(&mut h, 60.0, 100.0);
assert!(items(&h).iter().any(|(t, _)| t == "Stamp"), "typed signatures are filled outlines");
+ assert_eq!(h.state().quick_tool, QuickTool::Select);
// Initials: their own pad (GH), then placed.
assert!(h.state_mut().execute("sign.fill.initials"));
h.run_steps(2);
@@ -147,6 +174,8 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
// The quick toolbar exposes replacement without changing the normal placement action.
h.get_by_label("Fill & Sign").click();
h.run_steps(2);
+ h.get_by_label("Fill & Sign").click();
+ h.run_steps(2);
h.get_by_label("Change signature").click();
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::Signature));
@@ -165,6 +194,8 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
click(&mut h, 40.0, 200.0);
assert_eq!(items(&h).len(), 2);
+ h.get_by_label("Fill & Sign").click();
+ h.run_steps(2);
h.get_by_label("Fill & Sign").click();
h.run_steps(2);
h.get_by_label("Change initials").click();
@@ -193,7 +224,7 @@ fn changing_drawn_signatures_can_be_cancelled_or_switched_to_type() {
assert!(h.state_mut().execute("sign.fill.signature.change"));
h.run_steps(2);
assert!(h.state().signature_draft.drawing);
- assert_eq!(h.state().signature_draft.saved(), drawn);
+ assert_eq!(h.state().signature_draft.saved(), Some(drawn.clone()));
h.get_by_label("Clear").click();
h.run_steps(2);
assert!(h.state().signature_draft.strokes.is_empty());
@@ -204,7 +235,7 @@ fn changing_drawn_signatures_can_be_cancelled_or_switched_to_type() {
assert!(h.state_mut().execute("sign.fill.signature.change"));
h.run_steps(2);
- assert_eq!(h.state().signature_draft.saved(), drawn);
+ assert_eq!(h.state().signature_draft.saved(), Some(drawn.clone()));
h.get_all_by_label("Type").last().unwrap().click();
h.run_steps(2);
h.state_mut().signature_draft.text = "Grace Hopper".into();
@@ -276,3 +307,252 @@ fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
let a = &s.session.get(s.views[0].id).unwrap().info.annotations[0];
assert!(a.rect[2] <= 190.001, "the full name fits on the page: {:?}", a.rect);
}
+
+fn signature_file(test: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!("pdfcraft-signature-{test}-{}.png", std::process::id()));
+ let image = image::RgbaImage::from_fn(120, 40, |x, y| {
+ if (10..110).contains(&x) && (15..25).contains(&y) || (10..20).contains(&x) && (5..15).contains(&y) {
+ image::Rgba([20, 30, 40, 255])
+ } else {
+ image::Rgba([0, 0, 0, 0])
+ }
+ });
+ image.save(&path).unwrap();
+ path
+}
+
+fn browse_image(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) {
+ h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
+ h.get_by_label("Browse…").click();
+ h.run_steps(3);
+}
+
+#[test]
+fn image_signatures_and_initials_can_be_imported_placed_and_remembered() {
+ let path = signature_file("import");
+ let mut h = harness();
+ for (command, y, initials) in [("sign.fill.signature", 250.0, false), ("sign.fill.initials", 150.0, true)] {
+ h.state_mut().execute(command);
+ h.run_steps(2);
+ h.get_by_label("Image").click();
+ h.run_steps(2);
+ assert!(h.state().signature_draft.saved().is_none(), "Apply needs an image");
+ browse_image(&mut h, &path);
+ assert!(h.state().signature_draft.image.is_some());
+ if !initials && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
+ h.render().unwrap().save(format!("{dir}/image-signature-dialog.png")).unwrap();
+ }
+ assert_eq!(items(&h).len(), usize::from(initials), "importing doesn't edit the PDF");
+ h.get_by_label("Apply").click();
+ h.run_steps(3);
+ click(&mut h, 40.0, y);
+ assert_eq!(h.state().quick_tool, QuickTool::Select);
+ assert_eq!(h.state().views[0].comments.selected, Some((0, usize::from(initials))));
+ }
+ assert_eq!(items(&h).len(), 2);
+ let state = h.state();
+ let doc = state.session.get(state.views[0].id).unwrap();
+ for a in &doc.info.annotations {
+ assert_eq!(a.subtype, "Stamp");
+ assert!(((a.rect[2] - a.rect[0]) / (a.rect[3] - a.rect[1]) - 3.0).abs() < 0.001);
+ assert!(a.rect[2] - a.rect[0] <= 150.0);
+ }
+ assert_eq!(doc.can_undo(), Some("Add initials"));
+ let settings = state.persist();
+ assert!(!settings.contains(&path.to_string_lossy().to_string()), "only the image is saved, never its source path");
+ let mut again = PdfCraftApp::new();
+ again.restore(&settings);
+ assert_eq!(again.signature, state.signature);
+ assert_eq!(again.initials, state.initials);
+ let saved_image = state.signature.clone();
+ h.state_mut().execute("sign.fill.signature.change");
+ h.run_steps(2);
+ assert!(h.state().signature_draft.image_mode);
+ assert!(h.state().signature_draft.image.is_some());
+ h.get_by_label("Clear").click();
+ h.run_steps(2);
+ h.get_by_label("Cancel").click();
+ h.run_steps(3);
+ assert_eq!(h.state().signature, saved_image);
+ h.state_mut().execute("edit.undo");
+ h.run_steps(3);
+ assert_eq!(items(&h).len(), 1);
+ h.state_mut().execute("edit.redo");
+ h.run_steps(3);
+ assert_eq!(items(&h).len(), 2);
+ // Show the saved preview cards in the actual shell as well as the dialog.
+ h.state_mut().set_option("tool", "fill_sign").unwrap();
+ h.run_steps(3);
+ h.get_by_label("Use signature");
+ h.get_by_label("Use initials");
+ if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
+ h.render().unwrap().save(format!("{dir}/image-signatures.png")).unwrap();
+ }
+ std::fs::remove_file(path).unwrap();
+}
+
+#[test]
+fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() {
+ let path = signature_file("pointer");
+ let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
+ let mut h = harness();
+ // A link under the placement point must not replace the image with a hand cursor.
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddLink {
+ page: 0,
+ rect: [30.0, 230.0, 60.0, 270.0],
+ action: pdfcraft_engine::LinkAction::Page(0),
+ style: pdfcraft_engine::LinkStyle::default(),
+ });
+ h.state_mut().signature = Some(SavedSig::Image(image));
+ h.state_mut().execute("sign.fill.signature");
+ // Exercise document rotation and view rotation separately and together.
+ let mut previous_document_rotation = 0;
+ for (document_rotation, view_rotation, zoom) in [(0, 0, "150"), (90, 0, "100"), (0, 90, "100"), (90, 90, "75"), (0, 180, "75"), (0, 270, "100")] {
+ let delta = document_rotation - previous_document_rotation;
+ if delta != 0 {
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: delta }));
+ }
+ previous_document_rotation = document_rotation;
+ while h.state().views[0].rotation != view_rotation {
+ h.state_mut().views[0].rotate_view(true);
+ }
+ h.state_mut().set_option("zoom", zoom).unwrap();
+ h.run_steps(3);
+ for y in [250.0, 150.0] {
+ h.hover_at(at(&h, 40.0, y));
+ h.run_steps(2);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the image replaces the crosshair");
+ let ink = at(&h, 90.0, y);
+ let margin = at(&h, 42.0, y);
+ let upper_stroke = at(&h, 52.0, y + 8.0);
+ let lower_margin = at(&h, 52.0, y - 8.0);
+ let pixels = h.render().unwrap();
+ assert!(pixels.get_pixel(ink.x as u32, ink.y as u32).0[..3].iter().all(|v| *v < 60), "ink follows the pointer at the placed size");
+ assert!(pixels.get_pixel(margin.x as u32, margin.y as u32).0[..3].iter().all(|v| *v > 240), "alpha exposes the page");
+ assert!(
+ pixels.get_pixel(upper_stroke.x as u32, upper_stroke.y as u32).0[..3].iter().all(|v| *v < 60),
+ "the asymmetric image rotates with the page"
+ );
+ assert!(pixels.get_pixel(lower_margin.x as u32, lower_margin.y as u32).0[..3].iter().all(|v| *v > 240), "the image isn't flipped");
+ if y == 150.0 {
+ let old = at(&h, 90.0, 250.0);
+ assert!(pixels.get_pixel(old.x as u32, old.y as u32).0[..3].iter().all(|v| *v > 240), "moving the pointer removes the old preview");
+ }
+ if document_rotation == 0
+ && view_rotation == 0
+ && y == 250.0
+ && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
+ {
+ pixels.save(format!("{dir}/image-signature-pointer.png")).unwrap();
+ }
+ }
+ assert!(items(&h).is_empty(), "hovering never adds a signature");
+ assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.links.len(), 1, "the original link remains");
+ }
+ h.hover_at(pos2(5.0, 5.0));
+ h.run_steps(2);
+ assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "restore the pointer off the page");
+ std::fs::remove_file(path).unwrap();
+}
+
+#[test]
+fn image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat() {
+ let path = signature_file("resize");
+ let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
+ let mut h = harness();
+ h.state_mut().signature = Some(SavedSig::Image(image.clone()));
+ h.state_mut().initials = Some(SavedSig::Image(image));
+ h.state_mut().comment_prefs.pinned = true;
+ h.state_mut().execute("sign.fill.signature");
+ click(&mut h, 40.0, 250.0);
+ assert_eq!(h.state().quick_tool, QuickTool::Select, "signatures place once even when comment tools are pinned");
+ assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
+ let placed = rendered_ink(&mut h, 90.0, 250.0);
+ if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
+ placed.save(format!("{dir}/image-signature-selected.png")).unwrap();
+ }
+ // The bottom-right handle is ready without another selection click.
+ let corner = at(&h, 136.0, 234.0);
+ h.hover_at(corner);
+ h.run_steps(2);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
+ h.drag_at(corner);
+ h.run_steps(1);
+ let end = corner + egui::vec2(96.0, 32.0);
+ h.hover_at(end);
+ h.run_steps(2);
+ h.drop_at(end);
+ h.run_steps(3);
+ let doc = h.state().session.get(h.state().views[0].id).unwrap();
+ assert!(doc.info.annotations[0].rect.iter().zip([40.0, 218.0, 184.0, 266.0]).all(|(a, b)| (*a - b).abs() < 0.001));
+ assert_eq!(doc.can_undo(), Some("Resize comment"));
+ rendered_ink(&mut h, 160.0, 242.0);
+ h.state_mut().undo();
+ h.run_steps(2);
+ assert!(
+ h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0]
+ .rect
+ .iter()
+ .zip([40.0, 234.0, 136.0, 266.0])
+ .all(|(a, b)| (*a - b).abs() < 0.001)
+ );
+ h.state_mut().redo();
+ h.run_steps(2);
+ click(&mut h, 40.0, 150.0);
+ assert_eq!(items(&h).len(), 1, "a later page click doesn't add another signature");
+ h.state_mut().execute("sign.fill.signature");
+ click(&mut h, 40.0, 150.0);
+ assert_eq!(items(&h).len(), 2, "selecting the saved signature again places another");
+ h.state_mut().execute("sign.fill.initials");
+ click(&mut h, 40.0, 100.0);
+ assert_eq!(h.state().quick_tool, QuickTool::Select);
+ assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
+ std::fs::remove_file(path).unwrap();
+}
+
+#[test]
+fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
+ let path = signature_file("cancel");
+ let mut h = harness();
+ let original = SavedSig::Typed("Ada Lovelace".into());
+ h.state_mut().signature = Some(original.clone());
+ h.state_mut().execute("sign.fill.signature.change");
+ h.run_steps(2);
+ h.get_by_label("Image").click();
+ h.run_steps(2);
+ h.state_mut().pick_override = Some(Vec::new());
+ h.get_by_label("Browse…").click();
+ h.run_steps(3);
+ assert!(h.state().signature_draft.image.is_none());
+ browse_image(&mut h, &path);
+ let loaded = h.state().signature_draft.image.clone();
+ std::fs::write(&path, b"broken PNG").unwrap();
+ browse_image(&mut h, &path);
+ assert_eq!(h.state().signature_draft.image, loaded, "an invalid replacement keeps the previous draft");
+ assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't import"));
+ h.get_by_label("Clear").click();
+ h.run_steps(2);
+ assert!(h.state().signature_draft.saved().is_none());
+ h.get_by_label("Cancel").click();
+ h.run_steps(3);
+ assert_eq!(h.state().signature, Some(original));
+ assert!(items(&h).is_empty());
+ // A picker answer from a closed dialog cannot populate a later draft.
+ h.state_mut().execute("sign.fill.signature.change");
+ h.run_steps(2);
+ h.get_by_label("Image").click();
+ h.run_steps(2);
+ assert_eq!(signature_file("cancel"), path, "replace the corrupt file with a valid image for the stale-answer check");
+ h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
+ h.get_by_label("Browse…").click();
+ h.run_steps(1);
+ h.state_mut().dialog = None;
+ h.run_steps(3);
+ assert!(h.state().signature_draft.image.is_none());
+ // Malformed settings are ignored while legacy typed/drawn values still restore.
+ let mut again = PdfCraftApp::new();
+ again.restore(r#"{"signature_text":"Ada","signature_image":{"Image":"bm90IGFuIGltYWdl"},"initials":{"Image":"%%%"}}"#);
+ assert_eq!(again.signature, Some(SavedSig::Typed("Ada".into())));
+ assert!(again.initials.is_none());
+ std::fs::remove_file(path).unwrap();
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 1f9e8154..d610056b 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5211,11 +5211,11 @@ title = "Fill & Sign: typed, drawn or image signature and initials"
area = "F"
tier = "P0"
milestone = "M5"
-status = "partial"
+status = "shipped"
commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
-tools = ["fill_sign_add"]
-evidence = ["crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"]
-notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded) or drawn, saved in settings; preview cards with add/change/remove controls in the panel and picker, also through ui.command; changes leave placed annotations untouched; long names fit within 150 pt with overhangs included in preview/PDF bounds; image signatures not yet"
+tools = ["fill_sign_add", "comment_edit"]
+evidence = ["crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"]
+notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
[[feature]]
id = "form.fill-sign-flatten"
diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs
index d66f08d4..5cb33439 100644
--- a/xtask/src/layers.rs
+++ b/xtask/src/layers.rs
@@ -202,11 +202,10 @@ pub fn check(crates: &[Crate]) -> Vec {
}
match classify(&d.name) {
None => {}
- Some(Class::Testkit) => {
- if d.kind != DepKind::Dev && class != Class::Testkit {
- out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() });
- }
+ Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => {
+ out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() });
}
+ Some(Class::Testkit) => {}
Some(dc) => {
let to = dc.layer().unwrap_or(u8::MAX);
let ok = to < layer || (to == layer && sideways_allowed(&c.name, &d.name));