From 09287e547fdd3172ff2d0ac74068b2753869b1f7 Mon Sep 17 00:00:00 2001 From: Bjorneseth <166025044+Bjorneseth@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:16:34 +0200 Subject: [PATCH] M5.7: Fill & Sign Scan tab for photographed signatures (#1110) (#1111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Scan tab to Fill & Sign ▸ Add signature. A phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright). The frame can be moved, resized or redrawn, the photo turned in quarter turns, and an Ink sensitivity slider picks up faint ink. A morphological paper estimate (van Herk/Gil-Werman closing) removes shadows and uneven light; the trimmed ink in its own colour on transparency is saved and reused like an image signature. - engine: new signature_scan module (SignaturePhoto, PhotoRect, Turn, ScanError), bounded decoding, no new dependencies. - Image tab: accepts full-resolution photos (up to 40 MiB, 50 MP) and scales them down to the saved-signature limits instead of refusing them. - Photos and images can be dropped on the Image and Scan tabs. - fill_sign_add: `scan` and `sensitivity` with `path`. - Translations for all catalogs, parity entry, roadmap log. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 40 +- ROADMAP.md | 1 + crates/automation/src/comments.rs | 23 +- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 62 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/signature_image.rs | 68 +- crates/engine/src/signature_scan.rs | 904 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/fill_sign.rs | 499 +++++++++++++- crates/ui-egui/src/i18n/ar.tsv | 8 + crates/ui-egui/src/i18n/bg.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/kk.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sl.tsv | 8 + crates/ui-egui/src/i18n/te.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/tests/fill_sign.rs | 232 +++++++ parity/acrobat-features.toml | 6 +- 33 files changed, 1935 insertions(+), 74 deletions(-) create mode 100644 crates/engine/src/signature_scan.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index a4bbd27e4..66593d8ba 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a7582830210ccda4e50f54a53d4abcba9aec5fe4f26c8b7dcbed1e0d6a45b944" +sha256 = "75ff8eaf26b014f36d77930ded888c5b66602f9af54d41ef3736f7dacadbd3b0" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,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 = "f91d2af486b14b63cd7374eda18d7d4755ff31ad1c796a6164d207a80a87be64" +sha256 = "78c01cd3d757a67641f430bb859b5aec0a9c09f69f93c03dc17e3123f2435088" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,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 = "64ecf43f271479518206ed085b2573fbee77879d82b45bc8381e844e216e804f" +sha256 = "79a895e833f9defe51286cbebf9116f872d1a97c35bb1f96e06e486ceb5daca6" [[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 = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cc4bc44013c6129d1d27adfc6c448c93466fbb0327a13def6f509f55213e2bfb" +sha256 = "b11b95a369d61d931ee57a80a29d6cc0f063515eba48446aff0049b735428da7" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,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 = "623ba09938dc665f64e38ce6adfc47404227fef8be96aae1f431db5f9d7269e3" +sha256 = "6f00dbbe9e761a8d8d3ea4b686cbd922a52934f3b076396a09b020d0bcdacd99" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,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 = "3463f5e26e0c928027606cd1dc83d182a9a4f821e186b387423890148b269ab2" +sha256 = "107b6252dfb7f6a496676d22e7e6d92f15a534f7e8074b30463d9db9195df81b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,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 = "5b1a8936d734961945f6fa313043ca9560627e4a9749c8c901f4734b02bece1d" +sha256 = "22c54fa66b10cd104c042ca3190711dc1fdf021c58ff9158cf3e15d33f6ad5ed" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,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 = "2763ca6a5589a2267ca932f765f2dc67d7e4def335a119cf8ea8db6076d39f29" +sha256 = "2b309b341c1253691328e4996f8fa837c6ddc10c4ea4f2404ae56c23a84ac995" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,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 = "cc345a27318a92dcb3708ba124ec0288bc263b566134e9380dcc9c3662ce5212" +sha256 = "13a7ed2b84a70a31a460457547aa8e7775861321352811dab6bb00293bfe8a64" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,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 = "109a054417cc3fd002e6d5478e2777490bf8694a4e4689df141e80a54bf89857" +sha256 = "0586dc5ed21e7414eabe9b4d3b2b2cfa3933bd299040a5b09ed4b82691096b00" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,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 = "859903397c8ee9468c1fdd867a38f7ef832c0b48043577370ab0835e030d98a7" +sha256 = "3f8b27806f7bb47198f94db53c54a001215e1290cb58745772fe9daeb680c3ac" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,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 = "99abf57aa923e7b27027b5079ab261a0652d13c5f49f072bd3b0c0e61c3d2669" +sha256 = "a6892bb1d5693c87a5a551e1bda3bbeeca7cc8e436f88b49b66202a72ba9ff73" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,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 = "1a05b28d5b0ab067205ce9c08af0155d8b58512be4119bd1174ec772d6a3932b" +sha256 = "47af07bc3ba18941ca5dab2aef6d0c3249e497af00db47daf5dafcc6dd96b8fb" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,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 = "4ea214bbabd8c3e16475cd8f582286f0e795b00f1c2f1522fe0d70c145d2c159" +sha256 = "6c008e3641f080d1f457553935744641014d4ee6c89e65d2fe0b55a4d7dfa7bd" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3f64cc060a9981b69387b88c68a2dd7f0fdc69cab61bdad8e62f51dd521a179d" +sha256 = "27765e0db6c2f5be8b900afc1e957225478a524db16007051976128bf8452d8a" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,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 = "d08fa0f9bf6c0a44221b6909965f604b6aa0bde5a9aa6733cb77d6b359c0b5f6" +sha256 = "4a9c054ea4a6c1a2611e4057259901b871e09a7fa24f0dedc01381135d587193" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,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 = "b35ab82f4b6c04e7265965c7a6ea71e8961366bc0d422dd9e52507078acdea7a" +sha256 = "014b35326ce9082426512f959bcf2ab8e0f830dced65ce33edb1edb95f99cafe" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,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 = "7d4c96e2c3c0c1a58eb72c5ebbe2d5bf4d119f04f54d9cb680a13403953a4928" +sha256 = "08de1da009efedcf173421eec4a60006f6a4a5c6f8e8927c3bea9b9f0595de6a" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfb6f207a89a0b327569f31f66d211500207aff2ac9a6f3fb9aa825645967509" +sha256 = "fe53290bae4167b899ef16bebe3df264c51a6f15a20cab6b7ea1307b658e4040" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "000d35f32ae561c4ee7ec2a318bbcc3239f42be1121b0b3d9882688a98245316" +sha256 = "2a55048528aed00fa7501842c2c26f4ec02a3c1e163c365cfa28b01b99288f9d" diff --git a/ROADMAP.md b/ROADMAP.md index b3bba10bf..7573b960d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,6 +154,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-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. - **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. - **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. - **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 29c11a1c6..ac31d7347 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -469,13 +469,34 @@ impl Automation { let at = to_user(&info, x, y); let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); let kind = a.str("type")?; + let scan = a.opt_bool("scan")?.unwrap_or(false); + let sensitivity = a.opt_int("sensitivity")?; + if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() { + return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into())); + } + if sensitivity.is_some() && !scan { + return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into())); + } 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 image = if scan { + let sensitivity = match sensitivity { + None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY, + Some(s) => u8::try_from(s) + .ok() + .filter(|s| *s <= 100) + .ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?, + }; + let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?; + let frame = photo.auto_frame(); + photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))? + } else { + pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))? + }; let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; return self.apply(a, edit); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b256203af..9a50c1716 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1019,7 +1019,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 (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), 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 32 megapixels, scaled down to 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. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "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 (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), 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 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `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. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1030,6 +1030,8 @@ pub fn tools() -> Vec { "at": point(), "text": { "type": "string", "minLength": 1 }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, + "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, + "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, "format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." }, "language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." }, "author": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2ab05bdd4..bd43f55a7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2091,6 +2091,68 @@ fn image_signatures_through_tools_preserve_transparency_and_survive_save() { assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact"); } +#[test] +fn scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments() { + let dir = workdir("scanned-signatures"); + // A grey, unevenly lit sheet with a pen stroke for the signature, and a blank one. + let photo = |ink: bool| { + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, 400, 300); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let rgb: Vec = (0..300_u32) + .flat_map(|y| { + (0..400_u32).flat_map(move |x| { + let paper = (215 - x / 20) as u8; + if ink && (100..300).contains(&x) && (144..148).contains(&y) { [20, 20, 25] } else { [paper, paper, paper] } + }) + }) + .collect(); + encoder.write_header().unwrap().write_image_data(&rgb).unwrap(); + png + }; + std::fs::write(dir.join("photo.png"), photo(true)).unwrap(); + std::fs::write(dir.join("blank.png"), photo(false)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 60], "path": "photo.png", "scan": true })); + let output = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let page = image::load_from_memory(data).unwrap().to_rgba8(); + let dark = (20..170).filter(|&x| page.get_pixel(x, 60).0[0] < 80).count(); + assert!(dark > 100, "the stroke is placed as ink across the signature width ({dark} px)"); + for (x, y) in [(22, 50), (100, 50), (100, 70)] { + assert_eq!(page.get_pixel(x, y).0, [255, 255, 255, 255], "the grey paper is gone at {x},{y}"); + } + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 150], "path": "photo.png", "scan": true, "sensitivity": 0 }), + ); + for args in [ + json!({ "type": "signature", "path": "blank.png", "scan": true }), + json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": 101 }), + json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": -1 }), + json!({ "type": "signature", "path": "photo.png", "sensitivity": 50 }), + json!({ "type": "signature", "text": "Ada", "scan": true }), + json!({ "type": "signature", "path": "photo.png", "scan": "yes" }), + ] { + let mut args = args; + args["doc"] = json!(doc); + args["page"] = json!(1); + args["at"] = json!([20, 80]); + let error = a.call("fill_sign_add", &args).unwrap_err(); + assert!(matches!(error, ToolError::InvalidArgs(_)), "{args}: {error:?}"); + } + let Err(ToolError::InvalidArgs(no_ink)) = + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 80], "path": "blank.png", "scan": true })) + else { + panic!() + }; + assert!(no_ink.contains("No signature was found"), "{no_ink}"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact"); +} + #[test] fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() { let dir = workdir("signature-preview"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ed0512165..a152033d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,12 +26,14 @@ pub mod links; pub mod ocr; pub mod optimizer; pub mod signature_image; +pub mod signature_scan; pub mod text_fonts; mod text_preview; mod upright_image; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; +pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use text_preview::TextEditPreview; pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges}; diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index 3e1d621df..daff674c1 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -118,6 +118,43 @@ pub struct SignatureImage { size: [usize; 2], } +/// Decode an imported PNG or JPEG within the source limits (file size, 32 megapixels, 8192 pixels +/// per side, bounded allocation), upright per its EXIF orientation and scaled down to at most +/// 4 megapixels and 4096 pixels per side. Shared by image signatures and scanned photos. +pub(crate) fn decode_source(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(MAX_SOURCE_SIDE); + limits.max_image_height = Some(MAX_SOURCE_SIDE); + // 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size. + limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4); + reader.limits(limits); + let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() }; + let mut decoder = reader.into_decoder().map_err(too_big)?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS { + return Err(SignatureImageError::Size); + } + let orientation = decoder.orientation()?; + let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?; + image.apply_orientation(orientation); + let (w, h) = (f64::from(image.width()), f64::from(image.height())); + let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt()); + if scale < 1.0 { + let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1)); + // `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps. + image = image.thumbnail(nw, nh); + } + Ok(image) +} + 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 { @@ -127,36 +164,7 @@ impl SignatureImage { } 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(MAX_SOURCE_SIDE); - limits.max_image_height = Some(MAX_SOURCE_SIDE); - // 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size. - limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4); - reader.limits(limits); - let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() }; - let mut decoder = reader.into_decoder().map_err(too_big)?; - let (w, h) = decoder.dimensions(); - if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS { - return Err(SignatureImageError::Size); - } - let orientation = decoder.orientation()?; - let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?; - image.apply_orientation(orientation); - let (w, h) = (f64::from(image.width()), f64::from(image.height())); - let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt()); - if scale < 1.0 { - let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1)); - // `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps. - image = image.thumbnail(nw, nh); - } + let image = decode_source(bytes)?; let rgba = image.into_rgba8(); let size = [rgba.width() as usize, rgba.height() as usize]; let mut png = Cursor::new(Vec::new()); diff --git a/crates/engine/src/signature_scan.rs b/crates/engine/src/signature_scan.rs new file mode 100644 index 000000000..ba6ca269a --- /dev/null +++ b/crates/engine/src/signature_scan.rs @@ -0,0 +1,904 @@ +//! Signatures scanned from a photo of paper (Fill & Sign ▸ Sign ▸ Scan). +//! +//! People sign a sheet of paper and photograph it, usually with a phone. Such a photo is far from +//! a clean signature image: it shows the table around the sheet, shadows of the phone or hand, +//! uneven light and sensor noise, and it is often larger than an image signature may be. This +//! module turns the framed part of the photo into a [`SignatureImage`]: the ink in its own colour +//! on a transparent background, trimmed, so it is placed, saved and reused like any image +//! signature. +//! +//! Everything is local and bounded: the photo is decoded within the same limits as an imported +//! image signature (a 4 MiB file, 32 megapixels, 8192 pixels per side), downscaled to at most +//! 4 megapixels and [`WORKING_MAX_SIDE`] pixels per side, and only that copy is kept. So one +//! scan of the whole frame touches at most 4 megapixels. +//! +//! The ink is separated from the paper in four steps: +//! 1. **Paper estimate.** A grayscale morphological closing (a maximum filter, then a minimum +//! filter, van Herk/Gil-Werman, O(1) per pixel) removes every dark detail narrower than the +//! window, i.e. the pen strokes, while following lighting gradients and sharp shadow edges. +//! Bright noise peaks are clipped first, and edge pixels are repeated outside the image, so a +//! table cut off by the frame keeps its size and stays background. +//! 2. **Darkness** of each pixel relative to that paper, slightly smoothed so that glossy +//! ballpoint strokes with a lighter centre come out solid. The border of large dark areas +//! that are not paper (the table) is cleared; shadows on the paper are kept. +//! 3. **Hysteresis.** Pixels darker than the threshold are ink; fainter pixels are kept only when +//! they belong to a blob with real ink, so faint stroke ends survive and stray marks don't. +//! Blobs with too little ink are dropped as dust. +//! 4. **Output.** The ink is trimmed with a small margin, drawn in its average colour with the +//! coverage as alpha, and normalized through [`SignatureImage`]. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use image::{DynamicImage, ImageFormat, RgbImage}; + +use crate::signature_image::{SignatureImage, SignatureImageError}; + +/// Largest photo file accepted: the image signature limit (decoding shares its limits too). +pub const MAX_PHOTO_BYTES: usize = crate::signature_image::MAX_SIGNATURE_IMAGE_BYTES; +/// Longest side of the copy that is kept and scanned (it also holds at most 4 megapixels). +pub const WORKING_MAX_SIDE: u32 = 4096; +/// Longest side of the framed area that is cleaned for the final signature. +pub const SCAN_MAX_SIDE: u32 = 2400; +/// Longest side used for a quick preview, e.g. while the frame is being dragged. +pub const PREVIEW_MAX_SIDE: u32 = 900; +/// Longest side of the downscaled photo used to find the signature. +const DETECTION_MAX_SIDE: u32 = 1024; +/// Largest signature produced, in pixels (the image signature limit). +const MAX_OUTPUT_PIXELS: u64 = 4 << 20; + +/// The default of the Ink sensitivity slider (0–100). +pub const DEFAULT_SENSITIVITY: u8 = 50; +/// Detection uses a higher sensitivity so that faint strokes are framed too. +const DETECTION_SENSITIVITY: u8 = 70; +/// Darkness threshold (0–255, relative to the paper) at sensitivity 0 and 100. +const MAX_THRESHOLD: f32 = 100.0; +const MIN_THRESHOLD: f32 = 12.0; +/// Fainter pixels down to this fraction of the threshold are kept when they touch ink. +const HYSTERESIS_RATIO: f32 = 0.5; +/// Paper estimate window, relative to the photo's longest side; wider than half a felt-tip stroke. +const BACKGROUND_RADIUS_RATIO: f32 = 0.012; +const MIN_BACKGROUND_RADIUS: usize = 4; +/// One pixel of smoothing per this many pixels of the longest side. +const SMOOTHING_SCALE: usize = 1000; +/// A background darker than this fraction of the nearby paper is not paper (e.g. the table). +const DARK_AREA_RATIO: f32 = 0.45; +/// Blobs with fewer ink pixels than this fraction of the area (at least the minimum) are dust. +const SPECKLE_AREA_RATIO: f64 = 0.000_02; +const MIN_SPECKLE_AREA: usize = 6; +/// Blobs with less ink than this fraction of the largest one are not part of the signature. +const DETECTION_MIN_INK_RATIO: f64 = 0.1; +/// A detected signature this much taller than wide was photographed sideways. +const UPRIGHT_RATIO: u32 = 2; +/// Transparent margin around the trimmed signature, in pixels. +const TRIM_PADDING: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum ScanError { + #[error("Choose a PNG or JPEG photo.")] + Format, + #[error("Choose a photo smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")] + Size, + #[error("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.")] + NoInk, + #[error("Couldn't read the photo: {0}")] + Read(#[from] std::io::Error), + #[error("Couldn't decode the photo: {0}")] + Decode(#[from] image::ImageError), + #[error(transparent)] + Signature(#[from] SignatureImageError), +} + +/// A rectangle in photo pixels, top-left origin. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PhotoRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +impl PhotoRect { + /// Keep the rectangle inside a `width`×`height` photo, at least one pixel large. + pub fn clamped(self, width: u32, height: u32) -> Self { + let w = self.width.clamp(1, width.max(1)); + let h = self.height.clamp(1, height.max(1)); + Self { x: self.x.min(width.saturating_sub(w)), y: self.y.min(height.saturating_sub(h)), width: w, height: h } + } + + /// The same area after the photo (`width`×`height` before turning) is turned a quarter. + pub fn turned(self, turn: Turn, width: u32, height: u32) -> Self { + match turn { + Turn::Clockwise => { + Self { x: height.saturating_sub(self.y.saturating_add(self.height)), y: self.x, width: self.height, height: self.width } + } + Turn::CounterClockwise => { + Self { x: self.y, y: width.saturating_sub(self.x.saturating_add(self.width)), width: self.height, height: self.width } + } + } + } +} + +/// A quarter turn of the photo. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Turn { + Clockwise, + CounterClockwise, +} + +/// A decoded, upright and downscaled photo of a signature on paper. +#[derive(Clone, Debug, PartialEq)] +pub struct SignaturePhoto { + rgb: Arc, +} + +impl SignaturePhoto { + /// 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_PHOTO_BYTES as u64 + 1).read_to_end(&mut bytes)?; + Self::from_bytes(&bytes) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PHOTO_BYTES { + return Err(ScanError::Size); + } + // The image signature decoder: same limits, EXIF orientation (phones store portrait + // photos as landscape pixels plus an orientation), scaled down to 4 megapixels. + let image = crate::signature_image::decode_source(bytes).map_err(|e| match e { + SignatureImageError::Size => ScanError::Size, + SignatureImageError::Format => ScanError::Format, + e => ScanError::Signature(e), + })?; + Ok(Self { rgb: Arc::new(image.into_rgb8()) }) + } + + /// Width and height in pixels. + pub fn size(&self) -> [u32; 2] { + [self.rgb.width(), self.rgb.height()] + } + + /// The photo as RGB pixels, for showing it. + pub fn rgb(&self) -> &RgbImage { + &self.rgb + } + + /// The photo downscaled so that its longest side is at most `max_side`, as RGBA for display. + pub fn thumbnail(&self, max_side: u32) -> image::RgbaImage { + let [w, h] = self.size(); + let scale = (f64::from(max_side.max(1)) / f64::from(w.max(h).max(1))).min(1.0); + let nw = ((f64::from(w) * scale).round() as u32).max(1); + let nh = ((f64::from(h) * scale).round() as u32).max(1); + DynamicImage::ImageRgb8(image::imageops::resize(&*self.rgb, nw, nh, image::imageops::FilterType::Triangle)).into_rgba8() + } + + /// Turn the photo a quarter, returning `rect` moved along with it. + pub fn turn(&mut self, turn: Turn, rect: PhotoRect) -> PhotoRect { + let [w, h] = self.size(); + self.rgb = Arc::new(match turn { + Turn::Clockwise => image::imageops::rotate90(&*self.rgb), + Turn::CounterClockwise => image::imageops::rotate270(&*self.rgb), + }); + rect.turned(turn, w, h) + } + + /// Find the signature, or `None` when the photo shows no clear ink. + pub fn detect(&self) -> Option { + let [w, h] = self.size(); + let small = scaled(&self.rgb, PhotoRect { x: 0, y: 0, width: w, height: h }, DETECTION_MAX_SIDE); + let (sw, sh) = (small.width(), small.height()); + let region = detect_region(&luminance(&small), sw as usize, sh as usize)?; + let ratio = f64::from(w) / f64::from(sw.max(1)); + let scale = |v: usize| ((v as f64) * ratio).round().clamp(0.0, f64::from(u32::MAX)) as u32; + Some(PhotoRect { x: scale(region.x), y: scale(region.y), width: scale(region.width), height: scale(region.height) }.clamped(w, h)) + } + + /// Frame the signature for the user: turn a sideways signature upright, then return its + /// frame, or a centred default frame when no signature was found. + pub fn auto_frame(&mut self) -> PhotoRect { + if let Some(rect) = self.detect() { + if rect.height > rect.width.saturating_mul(UPRIGHT_RATIO) { + // Signatures are written horizontally. The direction is a guess; the user can + // turn the photo further. + return self.turn(Turn::CounterClockwise, rect); + } + return rect; + } + let [w, h] = self.size(); + PhotoRect { x: w / 7, y: h * 7 / 20, width: w * 5 / 7, height: h * 3 / 10 }.clamped(w, h) + } + + /// Clean the framed area into a transparent signature. `max_side` bounds the processed area: + /// [`SCAN_MAX_SIDE`] for the result, [`PREVIEW_MAX_SIDE`] for a quick preview. + pub fn scan(&self, rect: PhotoRect, sensitivity: u8, max_side: u32) -> Result { + let [w, h] = self.size(); + let rect = rect.clamped(w, h); + let area = scaled(&self.rgb, rect, max_side.clamp(16, WORKING_MAX_SIDE)); + let (aw, ah) = (area.width() as usize, area.height() as usize); + // Size the paper estimate on the whole photo, not on the frame, so that thick strokes in + // a tight frame are not taken for paper. + let scale = aw as f32 / rect.width.max(1) as f32; + let radius = (w.max(h) as f32 * BACKGROUND_RADIUS_RATIO * scale).round() as usize; + let ink = extract_ink(&luminance(&area), aw, ah, Options { sensitivity, despeckle: true, background_radius: Some(radius) }); + let bounds = ink.bounds.ok_or(ScanError::NoInk)?; + let png = render(&area, &ink.alpha, bounds)?; + Ok(SignatureImage::from_bytes(&png)?) + } +} + +/// The framed area of `rgb`, downscaled so that its longest side is at most `max_side`. +fn scaled(rgb: &RgbImage, rect: PhotoRect, max_side: u32) -> RgbImage { + let rect = rect.clamped(rgb.width(), rgb.height()); + let view = image::imageops::crop_imm(rgb, rect.x, rect.y, rect.width, rect.height).to_image(); + let longest = rect.width.max(rect.height); + if longest <= max_side { + return view; + } + let scale = f64::from(max_side) / f64::from(longest); + let nw = ((f64::from(rect.width) * scale).round() as u32).max(1); + let nh = ((f64::from(rect.height) * scale).round() as u32).max(1); + image::imageops::resize(&view, nw, nh, image::imageops::FilterType::Triangle) +} + +/// Perceived luminance (ITU BT.601). +fn luminance(rgb: &RgbImage) -> Vec { + rgb.pixels().map(|p| (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round().clamp(0.0, 255.0) as u8).collect() +} + +/// A rectangle in the pixels of a processed buffer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Region { + x: usize, + y: usize, + width: usize, + height: usize, +} + +#[derive(Clone, Copy, Debug)] +struct Options { + sensitivity: u8, + despeckle: bool, + /// Paper estimate window; by default a fraction of the buffer's longest side. + background_radius: Option, +} + +/// Ink coverage (0 = paper, 255 = solid ink) and the bounding box of the solid ink. +struct Ink { + alpha: Vec, + bounds: Option, +} + +/// The darkness threshold for a sensitivity of 0–100; higher sensitivity keeps fainter strokes. +fn threshold(sensitivity: u8) -> f32 { + let s = f32::from(sensitivity.min(100)) / 100.0; + MAX_THRESHOLD - s * (MAX_THRESHOLD - MIN_THRESHOLD) +} + +/// In-place separable box blur. +fn box_blur(values: &mut [f32], width: usize, height: usize, radius: usize) { + if radius == 0 || width == 0 || height == 0 || values.len() != width * height { + return; + } + let mut line = vec![0.0_f32; width.max(height)]; + let mut blur = |get: &dyn Fn(usize) -> f32, len: usize, out: &mut dyn FnMut(usize, f32)| { + for (i, slot) in line.iter_mut().take(len).enumerate() { + *slot = get(i); + } + let (mut sum, mut count) = (0.0_f32, 0_u32); + for v in line.iter().take(radius.min(len)) { + sum += v; + count += 1; + } + for i in 0..len { + if let Some(v) = line.get(i + radius).filter(|_| i + radius < len) { + sum += v; + count += 1; + } + if let Some(v) = i.checked_sub(radius + 1).and_then(|j| line.get(j)) { + sum -= v; + count = count.saturating_sub(1); + } + out(i, if count == 0 { 0.0 } else { sum / count as f32 }); + } + }; + let mut row = vec![0.0_f32; width]; + let mut tmp = vec![0.0_f32; width]; + for y in 0..height { + let start = y * width; + let Some(src) = values.get(start..start + width) else { return }; + row.copy_from_slice(src); + blur(&|x| row.get(x).copied().unwrap_or(0.0), width, &mut |x, v| { + if let Some(slot) = tmp.get_mut(x) { + *slot = v; + } + }); + if let Some(dst) = values.get_mut(start..start + width) { + dst.copy_from_slice(&tmp); + } + } + let mut column = vec![0.0_f32; height]; + let mut tmp = vec![0.0_f32; height]; + for x in 0..width { + for (y, slot) in column.iter_mut().enumerate() { + *slot = values.get(y * width + x).copied().unwrap_or(0.0); + } + blur(&|y| column.get(y).copied().unwrap_or(0.0), height, &mut |y, v| { + if let Some(slot) = tmp.get_mut(y) { + *slot = v; + } + }); + for (y, v) in tmp.iter().enumerate() { + if let Some(slot) = values.get_mut(y * width + x) { + *slot = *v; + } + } + } +} + +/// Sliding-window maximum or minimum over `radius` pixels on each side (van Herk/Gil-Werman). +/// Edge values are repeated outside the line. +fn sliding_extremum(src: &[u8], dst: &mut [u8], radius: usize, max: bool) { + let len = src.len().min(dst.len()); + if len == 0 { + return; + } + let pick = |a: u8, b: u8| if max { a.max(b) } else { a.min(b) }; + let size = 2 * radius + 1; + let total = len + 2 * radius; + let at = |i: usize| src.get(i.saturating_sub(radius).min(len - 1)).copied().unwrap_or(0); + let mut g = vec![0_u8; total]; + let mut h = vec![0_u8; total]; + let mut prev = 0; + for (i, slot) in g.iter_mut().enumerate() { + let v = at(i); + prev = if i % size == 0 { v } else { pick(prev, v) }; + *slot = prev; + } + let mut next = 0; + for (i, slot) in h.iter_mut().enumerate().rev() { + let v = at(i); + next = if i == total - 1 || (i + 1) % size == 0 { v } else { pick(next, v) }; + *slot = next; + } + for (i, out) in dst.iter_mut().take(len).enumerate() { + if let (Some(&a), Some(&b)) = (h.get(i), g.get(i + 2 * radius)) { + *out = pick(a, b); + } + } +} + +/// Separable square maximum (`max`) or minimum filter. +fn square_filter(src: &[u8], width: usize, height: usize, radius: usize, max: bool) -> Vec { + let mut rows = vec![0_u8; src.len()]; + for (s, d) in src.chunks_exact(width.max(1)).zip(rows.chunks_exact_mut(width.max(1))) { + sliding_extremum(s, d, radius, max); + } + let mut out = vec![0_u8; src.len()]; + let mut column = vec![0_u8; height]; + let mut filtered = vec![0_u8; height]; + for x in 0..width { + for (y, slot) in column.iter_mut().enumerate() { + *slot = rows.get(y * width + x).copied().unwrap_or(0); + } + sliding_extremum(&column, &mut filtered, radius, max); + for (y, v) in filtered.iter().enumerate() { + if let Some(slot) = out.get_mut(y * width + x) { + *slot = *v; + } + } + } + out +} + +/// The brightness of the paper behind every pixel (see the module documentation). +fn estimate_background(gray: &[u8], width: usize, height: usize, radius: usize) -> Vec { + let r = radius.max(MIN_BACKGROUND_RADIUS); + // Clip bright noise peaks, which the maximum filter would otherwise pick, so that the faint + // halo around every stroke doesn't count as ink. Only clipping keeps shadow edges sharp. + let mut blurred: Vec = gray.iter().map(|&v| f32::from(v)).collect(); + box_blur(&mut blurred, width, height, (r / 6).max(1)); + let clipped: Vec = gray.iter().zip(&blurred).map(|(&g, &b)| f32::from(g).min(b).round().clamp(0.0, 255.0) as u8).collect(); + let closed = square_filter(&square_filter(&clipped, width, height, r, true), width, height, r, false); + let mut smooth: Vec = closed.iter().map(|&v| f32::from(v)).collect(); + box_blur(&mut smooth, width, height, (r / 4).max(1)); + // The blur must never make the paper darker than the photo itself. + smooth.iter().zip(gray).map(|(&s, &g)| s.max(f32::from(g)).round().clamp(1.0, 255.0) as u8).collect() +} + +/// Label 8-connected blobs of non-zero `mask` pixels; returns the labels (0 = none) and count. +fn label_components(mask: &[bool], width: usize, height: usize) -> (Vec, usize) { + let mut labels = vec![0_u32; mask.len()]; + let mut stack: Vec = Vec::new(); + let mut count = 0_usize; + for start in 0..mask.len() { + if !mask.get(start).copied().unwrap_or(false) || labels.get(start).copied().unwrap_or(1) != 0 { + continue; + } + count += 1; + let label = u32::try_from(count).unwrap_or(u32::MAX); + if let Some(l) = labels.get_mut(start) { + *l = label; + } + stack.push(start); + while let Some(index) = stack.pop() { + let (x, y) = (index % width, index / width); + for ny in y.saturating_sub(1)..=(y + 1).min(height.saturating_sub(1)) { + for nx in x.saturating_sub(1)..=(x + 1).min(width.saturating_sub(1)) { + let n = ny * width + nx; + if mask.get(n).copied().unwrap_or(false) + && let Some(l) = labels.get_mut(n) + && *l == 0 + { + *l = label; + stack.push(n); + } + } + } + } + } + (labels, count) +} + +/// Separate the ink from the paper (see the module documentation). +fn extract_ink(gray: &[u8], width: usize, height: usize, options: Options) -> Ink { + let n = width * height; + if n == 0 || gray.len() != n { + return Ink { alpha: vec![0; gray.len()], bounds: None }; + } + let radius = + options.background_radius.unwrap_or_else(|| (width.max(height) as f32 * BACKGROUND_RADIUS_RATIO).round() as usize).max(MIN_BACKGROUND_RADIUS); + let background = estimate_background(gray, width, height, radius); + + let mut darkness: Vec = + gray.iter().zip(&background).map(|(&g, &bg)| if g >= bg { 0.0 } else { 255.0 * f32::from(bg - g) / f32::from(bg.max(1)) }).collect(); + let smoothing = width.max(height) / SMOOTHING_SCALE; + box_blur(&mut darkness, width, height, smoothing); + + // Where the sheet meets a much darker area (the table) is not ink; a shadow on the paper is + // only moderately darker and is kept. + let edge = smoothing + 2; + let brightest = square_filter(&background, width, height, edge, true); + let darkest = square_filter(&background, width, height, edge, false); + for ((d, &lo), &hi) in darkness.iter_mut().zip(&darkest).zip(&brightest) { + if f32::from(lo) < f32::from(hi) * DARK_AREA_RATIO { + *d = 0.0; + } + } + + let strong = threshold(options.sensitivity); + let weak = strong * HYSTERESIS_RATIO; + let candidates: Vec = darkness.iter().map(|&d| d >= weak).collect(); + let (labels, count) = label_components(&candidates, width, height); + let mut strong_pixels = vec![0_usize; count + 1]; + for (&label, &d) in labels.iter().zip(&darkness) { + if label != 0 + && d >= strong + && let Some(c) = strong_pixels.get_mut(label as usize) + { + *c += 1; + } + } + let min_strong = if options.despeckle { MIN_SPECKLE_AREA.max((n as f64 * SPECKLE_AREA_RATIO).round() as usize) } else { 1 }; + + let mut alpha = vec![0_u8; n]; + let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0, 0); + for (i, ((a, &label), &d)) in alpha.iter_mut().zip(&labels).zip(&darkness).enumerate() { + if label == 0 || strong_pixels.get(label as usize).copied().unwrap_or(0) < min_strong { + continue; + } + // Smooth ramp from the weak to the strong threshold. + let c = ((d - weak) / (strong - weak).max(f32::EPSILON)).clamp(0.0, 1.0); + *a = (255.0 * c * c * (3.0 - 2.0 * c)).round() as u8; + if *a >= 128 { + let (x, y) = (i % width, i / width); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + let bounds = (min_x <= max_x && min_y <= max_y).then(|| Region { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 }); + Ink { alpha, bounds } +} + +/// Where the signature is in a (downscaled) photo of the whole sheet: nearby strokes are merged, +/// blobs touching the photo border (paper edges, table, fingers) are ignored, and the blob with +/// the most ink is framed together with the other words on its line. +fn detect_region(gray: &[u8], width: usize, height: usize) -> Option { + let ink = extract_ink(gray, width, height, Options { sensitivity: DETECTION_SENSITIVITY, despeckle: true, background_radius: None }); + ink.bounds?; + let solid: Vec = ink.alpha.iter().map(|&a| if a >= 128 { 255 } else { 0 }).collect(); + let radius = ((width.max(height) as f64) * 0.03).round().max(2.0) as usize; + let merged: Vec = square_filter(&solid, width, height, radius, true).iter().map(|&v| v != 0).collect(); + let (labels, count) = label_components(&merged, width, height); + + #[derive(Clone, Copy)] + struct Blob { + ink: usize, + min_x: usize, + min_y: usize, + max_x: usize, + max_y: usize, + border: bool, + } + let mut blobs = vec![Blob { ink: 0, min_x: usize::MAX, min_y: usize::MAX, max_x: 0, max_y: 0, border: false }; count + 1]; + for (i, (&label, &s)) in labels.iter().zip(&solid).enumerate() { + let Some(blob) = blobs.get_mut(label as usize).filter(|_| label != 0) else { continue }; + let (x, y) = (i % width, i / width); + if x == 0 || y == 0 || x + 1 == width || y + 1 == height { + blob.border = true; + } + if s != 0 { + blob.ink += 1; + blob.min_x = blob.min_x.min(x); + blob.min_y = blob.min_y.min(y); + blob.max_x = blob.max_x.max(x); + blob.max_y = blob.max_y.max(y); + } + } + let candidates = || blobs.iter().skip(1).filter(|b| !b.border && b.ink > 0); + let best = *candidates().max_by_key(|b| b.ink)?; + // Signatures often have several words: add every blob with a fair amount of ink on the same line. + let span = best.max_y - best.min_y; + let (top, bottom) = (best.min_y.saturating_sub(span), best.max_y + span); + let min_ink = best.ink as f64 * DETECTION_MIN_INK_RATIO; + let mut frame = best; + for b in candidates().filter(|b| b.ink as f64 >= min_ink && b.max_y >= top && b.min_y <= bottom) { + frame.min_x = frame.min_x.min(b.min_x); + frame.min_y = frame.min_y.min(b.min_y); + frame.max_x = frame.max_x.max(b.max_x); + frame.max_y = frame.max_y.max(b.max_y); + } + let pad = radius; + let (x, y) = (frame.min_x.saturating_sub(pad), frame.min_y.saturating_sub(pad)); + Some(Region { x, y, width: (frame.max_x + 1 + pad).min(width) - x, height: (frame.max_y + 1 + pad).min(height) - y }) +} + +/// The trimmed ink in its average colour with the coverage as alpha, as PNG. +fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result, ScanError> { + let (w, h) = (area.width() as usize, area.height() as usize); + let x0 = bounds.x.saturating_sub(TRIM_PADDING); + let y0 = bounds.y.saturating_sub(TRIM_PADDING); + let x1 = (bounds.x + bounds.width + TRIM_PADDING).min(w); + let y1 = (bounds.y + bounds.height + TRIM_PADDING).min(h); + let (ow, oh) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + if ow == 0 || oh == 0 { + return Err(ScanError::NoInk); + } + + // One ink colour: the average of the solid strokes, so blue ink stays blue and paper tint at + // the stroke edges doesn't show. + let (mut sum, mut solid) = ([0_u64; 3], 0_u64); + for (p, &a) in area.pixels().zip(alpha) { + if a == 255 { + for (s, &c) in sum.iter_mut().zip(p.0.iter()) { + *s += u64::from(c); + } + solid += 1; + } + } + let colour = if solid == 0 { [0, 0, 0] } else { sum.map(|s| (s / solid).min(255) as u8) }; + + let mut out = image::RgbaImage::new(u32::try_from(ow).unwrap_or(1), u32::try_from(oh).unwrap_or(1)); + for (y, row) in out.rows_mut().enumerate() { + for (x, px) in row.enumerate() { + let a = alpha.get((y0 + y) * w + x0 + x).copied().unwrap_or(0); + *px = image::Rgba([colour[0], colour[1], colour[2], a]); + } + } + let mut out = DynamicImage::ImageRgba8(out); + let pixels = (ow as u64).saturating_mul(oh as u64); + if pixels > MAX_OUTPUT_PIXELS || ow > WORKING_MAX_SIDE as usize || oh > WORKING_MAX_SIDE as usize { + let scale = (MAX_OUTPUT_PIXELS as f64 / pixels as f64).sqrt().min(f64::from(WORKING_MAX_SIDE) / ow.max(oh) as f64); + let nw = ((ow as f64 * scale).floor() as u32).max(1); + let nh = ((oh as f64 * scale).floor() as u32).max(1); + out = out.resize_exact(nw, nh, image::imageops::FilterType::Triangle); + } + let mut png = Cursor::new(Vec::new()); + out.write_to(&mut png, ImageFormat::Png)?; + // A PNG larger than a saved signature may be is scaled down by `SignatureImage::from_bytes`. + Ok(png.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. + fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { + let mut out = vec![0; width * height]; + for y in 0..height { + for x in 0..width { + let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); + out[y * width + x] = if inside { ink_value } else { paper(x, y) }; + } + } + out + } + + fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { + RgbImage::from_fn(width as u32, height as u32, |x, y| { + let v = gray[y as usize * width + x as usize]; + image::Rgb([v, v, v]) + }) + } + + fn jpeg(image: &RgbImage) -> Vec { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); + out.into_inner() + } + + /// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and + /// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. + #[test] + fn photos_keep_the_image_signature_limits() { + let png = |image: &RgbImage| { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + out.into_inner() + }; + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); + assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); + assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); + } + + fn region(r: [usize; 4]) -> Region { + Region { x: r[0], y: r[1], width: r[2], height: r[3] } + } + + fn opts() -> Options { + Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } + } + + #[test] + fn box_blur_averages_and_keeps_a_constant_signal() { + let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; + box_blur(&mut values, 5, 1, 1); + assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); + let mut flat = vec![7.0; 12]; + box_blur(&mut flat, 4, 3, 2); + assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); + } + + #[test] + fn square_filters_match_a_naive_window_with_repeated_edges() { + let (w, h) = (13, 7); + let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); + for radius in [1, 2, 5] { + for max in [true, false] { + let fast = square_filter(&src, w, h, radius, max); + for y in 0..h { + for x in 0..w { + let mut want = if max { 0 } else { 255 }; + for dy in -(radius as i64)..=radius as i64 { + for dx in -(radius as i64)..=radius as i64 { + let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; + let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; + let v = src[sy * w + sx]; + want = if max { want.max(v) } else { want.min(v) }; + } + } + assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); + } + } + } + } + } + + #[test] + fn components_are_eight_connected() { + let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; + let (labels, count) = label_components(&mask, 4, 3); + assert_eq!(count, 2); + assert_eq!(labels[0], labels[5]); + assert_ne!(labels[7], labels[0]); + assert_eq!(labels[1], 0); + } + + #[test] + fn sensitivity_lowers_the_threshold() { + assert!(threshold(0) > threshold(50)); + assert!(threshold(50) > threshold(100)); + assert_eq!(threshold(200), threshold(100)); + } + + #[test] + fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { + let (w, h) = (160, 80); + let stroke = [20, 30, 120, 6]; + let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[10 * w + 150], 0); + assert_eq!(ink.alpha[33 * w + 30], 255); + assert_eq!(ink.alpha[33 * w + 130], 255); + assert_eq!(ink.bounds, Some(region(stroke))); + } + + #[test] + fn a_sharp_shadow_edge_is_not_ink() { + let (w, h) = (200, 120); + let stroke = [30, 52, 140, 4]; + let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + for y in 58..63 { + assert_eq!(ink.alpha[y * w + 10], 0); + assert_eq!(ink.alpha[y * w + 190], 0); + } + assert_eq!(ink.bounds, Some(region(stroke))); + } + + #[test] + fn a_table_at_the_edge_of_the_frame_is_not_ink() { + let (w, h) = (300, 120); + let stroke = [60, 60, 200, 4]; + let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 150], 0); + assert_eq!(ink.alpha[60 * w + 5], 0); + assert_eq!(ink.alpha[11 * w + 150], 0); + assert_eq!(ink.bounds, Some(region(stroke))); + } + + #[test] + fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { + let (w, h) = (600, 200); + let stroke = [50, 90, 500, 18]; + let gray = photo(w, h, |_, _| 225, &[stroke], 30); + let middle = 99 * w + 300; + // Sized on the tight frame alone, the window is narrower than the stroke. + assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); + let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); + assert_eq!(ink.bounds, Some(region(stroke))); + assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); + } + + #[test] + fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { + let (w, h) = (200, 60); + let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); + for y in 28..32 { + for x in (100..180).chain(10..15) { + gray[y * w + x] = 175; + } + } + let ink = extract_ink(&gray, w, h, opts()); + assert!(ink.alpha[30 * w + 150] > 0); + assert_eq!(ink.alpha[30 * w + 12], 0); + assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); + } + + #[test] + fn dust_is_removed_unless_despeckling_is_off() { + let (w, h) = (200, 100); + let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); + assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 190], 0); + assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); + } + + #[test] + fn faint_strokes_need_a_higher_sensitivity() { + let (w, h) = (120, 60); + let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); + } + + #[test] + fn blank_paper_has_no_ink() { + let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); + assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); + assert!(detect_region(&gray, 64, 64).is_none()); + } + + #[test] + fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { + let (w, h) = (400, 200); + let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); + assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); + } + + #[test] + fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { + let (w, h) = (400, 300); + let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; + let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); + assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); + } + + #[test] + fn rects_turn_with_the_photo_and_back() { + let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; + let turned = rect.turned(Turn::Clockwise, 100, 80); + assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); + assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); + assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); + assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); + } + + #[test] + fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { + let (w, h) = (600, 400); + let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); + for (x, y, p) in image.enumerate_pixels_mut() { + if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { + *p = image::Rgb([30, 50, 140]); + } + } + let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); + let rect = photo.auto_frame(); + assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); + let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); + let [sw, sh] = signature.size(); + assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); + let pixels = signature.rgba().as_chunks::<4>().0; + assert_eq!(pixels[0][3], 0, "the margin is transparent"); + let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); + assert!(!solid.is_empty()); + assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); + let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); + assert!(quick.size()[0] <= sw); + } + + #[test] + fn a_sideways_signature_is_turned_upright() { + let (w, h) = (400, 600); + let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); + let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); + let rect = photo.auto_frame(); + assert_eq!(photo.size(), [600, 400]); + assert!(rect.width > rect.height * 2, "{rect:?}"); + } + + #[test] + fn an_empty_frame_reports_that_no_signature_was_found() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); + let mut copy = photo.clone(); + assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); + assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); + } + + #[test] + fn large_photos_are_downscaled_and_invalid_ones_rejected() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); + let [w, h] = photo.size(); + assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); + assert!(photo.thumbnail(512).width() <= 512); + assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + // A header claiming a huge image is rejected before decoding. + let mut png = Vec::new(); + png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + assert!(SignaturePhoto::from_bytes(&png).is_err()); + } + + #[test] + fn degenerate_buffers_do_not_panic() { + assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); + assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); + assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); + assert!(detect_region(&[255; 4], 2, 2).is_none()); + let mut empty: Vec = Vec::new(); + box_blur(&mut empty, 0, 0, 3); + assert!(square_filter(&[], 0, 0, 2, true).is_empty()); + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); + let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 89040f828..499d0e4f8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -845,7 +845,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } Dialog::Signature => { - let (apply, cancel, browse) = 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, &mut app.signature_photo); if browse { app.pick_signature_image(); } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 974d344c5..5647bc23b 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -4,7 +4,8 @@ //! can be moved, deleted and undone like any comment. use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style}; +use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; +use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; @@ -76,7 +77,11 @@ pub struct SigDraft { pub drawing: bool, /// The Image tab. The file is held in the draft until Apply. pub image_mode: bool, + /// The Image or Scan tab's result. pub image: Option, + /// The Scan tab: a photo of a signature on paper, cleaned into `image`. + pub scan_mode: bool, + pub scan: Option, /// Creating initials (otherwise the signature). pub initials: bool, /// Replacing an existing saved value. @@ -99,7 +104,10 @@ impl SigDraft { } fn ready(&self) -> bool { - if self.image_mode { + if self.scan_mode { + // A preview made while the frame or slider is still moving is not the result. + self.scan.as_ref().is_some_and(|s| s.drag.is_none() && s.full && !s.scanning()) && self.image.is_some() + } else if self.image_mode { self.image.is_some() } else if self.drawing { self.strokes.iter().any(|s| s.len() > 1) @@ -110,7 +118,9 @@ impl SigDraft { } pub fn saved(&self) -> Option { - if self.image_mode { + if self.scan_mode { + self.ready().then(|| self.image.clone().map(SavedSig::Image)).flatten() + } else if self.image_mode { self.image.clone().map(SavedSig::Image) } else if self.drawing { Some(SavedSig::Drawn(self.strokes.clone())) @@ -120,6 +130,203 @@ impl SigDraft { } } +/// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up. +#[derive(Clone, Debug, PartialEq)] +pub struct ScanDraft { + pub photo: SignaturePhoto, + pub frame: PhotoRect, + pub sensitivity: u8, + /// Changes whenever the photo's pixels do (a new photo or a turn), to refresh its texture. + version: u64, + drag: Option, + /// The draft's image is the full-resolution result, not a quick preview. + full: bool, + /// Why the frame gives no signature, shown in place of the preview. + pub problem: Option, + /// Scans run here while the frame or slider moves, so a frame never waits for one. + worker: ScanWorker, +} + +/// What one scan cleans: the frame and sensitivity at the time it was asked for. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ScanRequest { + frame: PhotoRect, + sensitivity: u8, + full: bool, +} + +type ScanAnswer = Result, String>; + +/// The scan running on a worker thread (desktop) and the newest request made meanwhile, which +/// starts when it answers; requests in between are skipped. It is not part of the draft's value: +/// a clone has nothing running and comparisons ignore it. +#[derive(Default)] +struct ScanWorker { + running: Option<(ScanRequest, std::sync::mpsc::Receiver)>, + queued: Option, +} + +impl Clone for ScanWorker { + fn clone(&self) -> Self { + Self::default() + } +} + +impl PartialEq for ScanWorker { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for ScanWorker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScanWorker").field("running", &self.running.as_ref().map(|(r, _)| r)).field("queued", &self.queued).finish() + } +} + +/// A drag on the photo, in photo pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FrameDrag { + /// Moving the frame, grabbed at this offset from its top-left corner. + Move([f32; 2]), + /// Drawing a new frame or resizing from a corner, with the opposite corner fixed here. + Span([f32; 2]), +} + +fn next_photo_version() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +impl ScanDraft { + /// Frame the signature (turning a sideways one upright) with the default sensitivity. + pub fn new(mut photo: SignaturePhoto) -> Self { + let frame = photo.auto_frame(); + Self { + photo, + frame, + sensitivity: DEFAULT_SENSITIVITY, + version: next_photo_version(), + drag: None, + full: false, + problem: None, + worker: ScanWorker::default(), + } + } + + fn request(&self, full: bool) -> ScanRequest { + ScanRequest { frame: self.frame, sensitivity: self.sensitivity, full } + } + + fn run(photo: &SignaturePhoto, req: ScanRequest) -> ScanAnswer { + let max_side = if req.full { SCAN_MAX_SIDE } else { PREVIEW_MAX_SIDE }; + pdfcraft_engine::guard(|| photo.scan(req.frame, req.sensitivity, max_side)) + } + + /// Clean the framed area into `image` now: quickly (`full` false) while something is being + /// dragged, at full resolution otherwise. A scan still running in the background is dropped. + pub fn rescan(&mut self, image: &mut Option, full: bool) { + self.worker = ScanWorker::default(); + let req = self.request(full); + let answer = Self::run(&self.photo, req); + self.apply(image, req, answer); + } + + /// Like [`Self::rescan`], but on a worker thread on desktop, so the frame that asked doesn't + /// wait (a scan of a large frame takes a noticeable fraction of a second). The draft isn't + /// ready until the answer for the current frame and sensitivity arrives; [`Self::poll`] + /// picks it up. Where threads are unavailable (the web) it scans at once. + pub fn rescan_in_background(&mut self, image: &mut Option, full: bool, ctx: Option<&egui::Context>) { + if cfg!(target_arch = "wasm32") { + return self.rescan(image, full); + } + let req = self.request(full); + self.full = false; + if self.worker.running.is_some() { + self.worker.queued = Some(req); + } else if !self.start(req, ctx) { + self.rescan(image, full); + } + } + + /// Start `req` on a worker thread; false if no thread could be started. + fn start(&mut self, req: ScanRequest, ctx: Option<&egui::Context>) -> bool { + let photo = self.photo.clone(); + let ctx = ctx.cloned(); + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("signature-scan".into()).spawn(move || { + // The receiver may be gone (the dialog closed or a newer photo): nothing to report. + let _ = tx.send(Self::run(&photo, req)); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }); + if spawned.is_err() { + return false; + } + self.worker.running = Some((req, rx)); + true + } + + /// Whether a background scan is running or waiting to run. + pub fn scanning(&self) -> bool { + self.worker.running.is_some() || self.worker.queued.is_some() + } + + /// Pick up a finished background scan (each frame), and start the newest waiting request. + pub fn poll(&mut self, image: &mut Option, ctx: Option<&egui::Context>) { + let Some((req, rx)) = &self.worker.running else { return }; + let answer = match rx.try_recv() { + Ok(answer) => answer, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the scan stopped unexpectedly".into()), + }; + let req = *req; + self.worker.running = None; + match self.worker.queued.take() { + Some(next) => { + // Shown as a preview while the newer request runs. + self.apply(image, ScanRequest { full: false, ..req }, answer); + if !self.start(next, ctx) { + self.rescan(image, next.full); + } + } + None => self.apply(image, req, answer), + } + } + + /// Show a scan's answer. It is the result only if it was full and for the current frame and + /// sensitivity. + fn apply(&mut self, image: &mut Option, req: ScanRequest, result: ScanAnswer) { + self.full = req.full && req.frame == self.frame && req.sensitivity == self.sensitivity; + match result { + Ok(Ok(scanned)) => { + *image = Some(scanned); + self.problem = None; + } + Ok(Err(ScanError::NoInk)) => { + *image = None; + self.problem = + Some(tl!("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.").to_string()); + } + Ok(Err(e)) => { + *image = None; + self.problem = Some(e.to_string()); + } + Err(e) => { + *image = None; + self.problem = Some(e); + } + } + } + + pub fn turn(&mut self, turn: Turn, image: &mut Option, ctx: Option<&egui::Context>) { + self.frame = self.photo.turn(turn, self.frame); + self.version = next_photo_version(); + self.rescan_in_background(image, true, ctx); + } +} + impl FillTool { pub fn command(self) -> &'static str { match self { @@ -509,30 +716,48 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, None } -/// The signature pad: type, draw or import an image. Returns Apply, Cancel and Browse. +/// The signature pad: type, draw, import an image or scan a photo. Returns Apply, Cancel and +/// Browse (for an image or, on the Scan tab, a photo). pub(crate) fn signature_pad( ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, preview: &mut Option<(SavedSig, egui::TextureHandle)>, + photo_texture: &mut Option<(u64, 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_ctx!("signature pad", "Type"), !d.drawing && !d.image_mode).clicked() { + if crate::widgets::pill_button(ui, tl_ctx!("signature pad", "Type"), !d.drawing && !d.image_mode && !d.scan_mode).clicked() { d.drawing = false; d.image_mode = false; + d.scan_mode = false; } - if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing && !d.image_mode).clicked() { + if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing && !d.image_mode && !d.scan_mode).clicked() { d.drawing = true; d.image_mode = false; + d.scan_mode = false; } if crate::widgets::pill_button(ui, tl!("Image"), d.image_mode).clicked() { d.image_mode = true; + d.scan_mode = false; + } + if crate::widgets::pill_button(ui, tl!("Scan"), d.scan_mode).clicked() && !d.scan_mode { + d.scan_mode = true; + d.image_mode = false; + // The Image tab shares the result; show this tab's own again. + if let Some(scan) = d.scan.as_mut() { + scan.rescan_in_background(&mut d.image, true, Some(ui.ctx())); + } } }); ui.add_space(6.0); + if d.scan_mode { + let browse = scan_tab(ui, t, d, preview, photo_texture); + let (apply, cancel) = pad_buttons(ui, d); + return (apply, cancel, browse); + } 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(); @@ -597,6 +822,173 @@ pub(crate) fn signature_pad( (apply, cancel, false) } +/// The Scan tab above the pad buttons. Returns whether a photo should be chosen. +fn scan_tab( + ui: &mut egui::Ui, + t: &Tokens, + d: &mut SigDraft, + preview: &mut Option<(SavedSig, egui::TextureHandle)>, + photo_texture: &mut Option<(u64, egui::TextureHandle)>, +) -> bool { + ui.label( + egui::RichText::new(tl!( + "Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper." + )) + .color(t.text_muted), + ); + let mut browse = false; + ui.horizontal(|ui| { + browse = crate::widgets::ghost_button(ui, "camera", tl!("Choose photo…")).clicked(); + if let Some(scan) = d.scan.as_mut() { + ui.add_space(8.0); + if crate::icons::button(ui, "rotate-ccw", 28.0, false, tl!("Rotate counterclockwise")).clicked() { + scan.turn(Turn::CounterClockwise, &mut d.image, Some(ui.ctx())); + } + if crate::icons::button(ui, "rotate-cw", 28.0, false, tl!("Rotate clockwise")).clicked() { + scan.turn(Turn::Clockwise, &mut d.image, Some(ui.ctx())); + } + } + }); + let (area, resp) = ui.allocate_exact_size(vec2(460.0, 230.0), Sense::drag()); + let painter = ui.painter_at(area); + painter.rect_filled(area, CornerRadius::same(6), t.pasteboard); + painter.rect_stroke(area, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + if let Some(scan) = d.scan.as_mut() { + scan.poll(&mut d.image, Some(ui.ctx())); + } + let Some(scan) = d.scan.as_mut() else { + painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + return browse; + }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + // The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points. + let [pw, ph] = scan.photo.size(); + let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32); + let fit = area.shrink(8.0); + let scale = (fit.width() / pw).min(fit.height() / ph); + let shown = egui::Rect::from_center_size(fit.center(), vec2(pw * scale, ph * scale)); + if photo_texture.as_ref().is_none_or(|(v, _)| *v != scan.version) { + let thumb = scan.photo.thumbnail(1024); + let size = [thumb.width() as usize, thumb.height() as usize]; + let pixels = egui::ColorImage::from_rgba_unmultiplied(size, thumb.as_raw()); + *photo_texture = Some((scan.version, ui.ctx().load_texture("signature-photo", pixels, egui::TextureOptions::LINEAR))); + } + if let Some((_, tex)) = photo_texture { + painter.image(tex.id(), shown, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let to_screen = |x: f32, y: f32| pos2(shown.left() + x * scale, shown.top() + y * scale); + let to_photo = |p: Pos2| [((p.x - shown.left()) / scale).clamp(0.0, pw), ((p.y - shown.top()) / scale).clamp(0.0, ph)]; + let frame_screen = |f: PhotoRect| { + egui::Rect::from_min_max( + to_screen(f.x as f32, f.y as f32), + to_screen(f.x.saturating_add(f.width) as f32, f.y.saturating_add(f.height) as f32), + ) + }; + + // Move the frame from inside, resize it from a corner, or draw a new one elsewhere. + let handle = 10.0; + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + { + let fr = frame_screen(scan.frame); + let corners = [fr.left_top(), fr.right_top(), fr.left_bottom(), fr.right_bottom()]; + let opposite = [fr.right_bottom(), fr.left_bottom(), fr.right_top(), fr.left_top()]; + let at = to_photo(p); + scan.drag = Some(if let Some(i) = corners.iter().position(|c| c.distance(p) <= handle) { + FrameDrag::Span(opposite.get(i).map_or(at, |o| to_photo(*o))) + } else if fr.contains(p) { + FrameDrag::Move([at[0] - scan.frame.x as f32, at[1] - scan.frame.y as f32]) + } else { + FrameDrag::Span(at) + }); + } + if resp.dragged() + && let (Some(drag), Some(p)) = (scan.drag, resp.interact_pointer_pos()) + { + let at = to_photo(p); + let (x0, y0, x1, y1) = match drag { + FrameDrag::Move(grab) => { + let (w, h) = (scan.frame.width as f32, scan.frame.height as f32); + let x = (at[0] - grab[0]).clamp(0.0, (pw - w).max(0.0)); + let y = (at[1] - grab[1]).clamp(0.0, (ph - h).max(0.0)); + (x, y, x + w, y + h) + } + FrameDrag::Span(anchor) => (anchor[0].min(at[0]), anchor[1].min(at[1]), anchor[0].max(at[0]), anchor[1].max(at[1])), + }; + // At least a few screen points, so a frame can always be grabbed again. + let min = (12.0 / scale).max(1.0); + let frame = PhotoRect { + x: x0.round() as u32, + y: y0.round() as u32, + width: (x1 - x0).max(min).round() as u32, + height: (y1 - y0).max(min).round() as u32, + } + .clamped(pw as u32, ph as u32); + if frame != scan.frame { + scan.frame = frame; + scan.rescan_in_background(&mut d.image, false, Some(ui.ctx())); + } + } + if resp.drag_stopped() && scan.drag.take().is_some() { + scan.rescan_in_background(&mut d.image, true, Some(ui.ctx())); + } + + // Dim the photo outside the frame, then outline it with corner handles. + let fr = frame_screen(scan.frame).intersect(shown); + let dim = Color32::from_black_alpha(120); + for r in [ + egui::Rect::from_min_max(shown.left_top(), pos2(shown.right(), fr.top())), + egui::Rect::from_min_max(pos2(shown.left(), fr.bottom()), shown.right_bottom()), + egui::Rect::from_min_max(pos2(shown.left(), fr.top()), pos2(fr.left(), fr.bottom())), + egui::Rect::from_min_max(pos2(fr.right(), fr.top()), pos2(shown.right(), fr.bottom())), + ] { + if r.is_positive() { + painter.rect_filled(r, CornerRadius::ZERO, dim); + } + } + painter.rect_stroke(fr, CornerRadius::ZERO, Stroke::new(2.0, t.accent), egui::StrokeKind::Middle); + for c in [fr.left_top(), fr.right_top(), fr.left_bottom(), fr.right_bottom()] { + let r = egui::Rect::from_center_size(c, vec2(9.0, 9.0)); + painter.rect_filled(r, CornerRadius::same(2), Color32::WHITE); + painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + if resp.hovered() && scan.drag.is_none() { + let hover = ui.input(|i| i.pointer.hover_pos()); + let fr = frame_screen(scan.frame); + let icon = match hover { + Some(p) if [fr.left_top(), fr.right_bottom()].iter().any(|c| c.distance(p) <= handle) => egui::CursorIcon::ResizeNwSe, + Some(p) if [fr.right_top(), fr.left_bottom()].iter().any(|c| c.distance(p) <= handle) => egui::CursorIcon::ResizeNeSw, + Some(p) if fr.contains(p) => egui::CursorIcon::Grab, + _ => egui::CursorIcon::Crosshair, + }; + ui.ctx().set_cursor_icon(icon); + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + let l = ui.label(tl!("Ink sensitivity")); + let slider = ui.add(egui::Slider::new(&mut scan.sensitivity, 0..=100)).labelled_by(l.id); + if slider.changed() { + // Quick previews while dragging; keyboard steps get the full result at once. + scan.rescan_in_background(&mut d.image, !slider.dragged(), Some(ui.ctx())); + } + if slider.drag_stopped() { + scan.rescan_in_background(&mut d.image, true, Some(ui.ctx())); + } + }); + ui.add_space(4.0); + let (rect, _) = ui.allocate_exact_size(vec2(460.0, 110.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); + } else if let Some(problem) = &scan.problem { + ui.put(rect.shrink(12.0), egui::Label::new(egui::RichText::new(problem.as_str()).color(Color32::from_rgb(0x8A, 0x5A, 0x00))).wrap()); + } + browse +} + 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) { @@ -630,6 +1022,9 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) { d.strokes.clear(); d.text.clear(); d.image = None; + if d.scan_mode { + d.scan = None; + } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let ready = d.ready(); @@ -655,18 +1050,31 @@ pub(crate) struct ImageInbox { struct PickedImage { epoch: u64, initials: bool, + /// A photo for the Scan tab (otherwise an image for the Image tab). + scan: bool, bytes: Option, String>>, } impl crate::PdfCraftApp { - /// Browse asynchronously, answering only into the dialog that asked for the image. + /// Browse asynchronously, answering only into the dialog (and tab) that asked for the image + /// or, on the Scan tab, the photo. 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"]); + let scan = self.signature_draft.scan_mode; + let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "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 { + if app.dialog_epoch() != epoch + || app.dialog != Some(crate::Dialog::Signature) + || app.signature_draft.initials != initials + || app.signature_draft.scan_mode != scan + { + return; + } + if scan { + let result = std::fs::File::open(path).map_err(ScanError::from).and_then(SignaturePhoto::read); + app.use_signature_photo(result.map_err(|e| e.to_string())); return; } let result = @@ -682,21 +1090,22 @@ impl crate::PdfCraftApp { } let inbox = self.signature_images.clone(); let ctx = self.ctx.clone(); + let (limit, too_large) = if scan { + (pdfcraft_engine::signature_scan::MAX_PHOTO_BYTES, ScanError::Size.to_string()) + } else { + (pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES, pdfcraft_engine::signature_image::SignatureImageError::Size.to_string()) + }; 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())) + if file.inner().size() > limit as f64 { + Some(Err(too_large)) } 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()) - } + if bytes.length() as usize > limit { Err(too_large) } else { Ok(bytes.to_vec()) } } Err(error) => Err(format!("{error:?}")), }) @@ -704,7 +1113,7 @@ impl crate::PdfCraftApp { } else { None }; - *inbox.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PickedImage { epoch, initials, bytes }); + *inbox.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PickedImage { epoch, initials, scan, bytes }); if let Some(ctx) = ctx { ctx.request_repaint(); } @@ -717,10 +1126,21 @@ impl crate::PdfCraftApp { 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 { + if self.dialog_epoch() != picked.epoch + || self.dialog != Some(crate::Dialog::Signature) + || self.signature_draft.initials != picked.initials + || self.signature_draft.scan_mode != picked.scan + { return; } let Some(bytes) = picked.bytes else { return }; + if picked.scan { + let result = bytes.and_then(|bytes| { + pdfcraft_engine::guard(|| SignaturePhoto::from_bytes(&bytes)).map_err(|e| e.to_string())?.map_err(|e| e.to_string()) + }); + self.use_signature_photo(result); + 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); @@ -736,6 +1156,49 @@ impl crate::PdfCraftApp { Err(e) => self.notify_fmt("Couldn't import the signature image: {e}", &[("e", &e)]), } } + + /// Files dropped while the signature pad shows the Image or Scan tab are imported there (the + /// first one) instead of opening behind the dialog. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn drop_on_signature_pad(&mut self, dropped: Vec) -> Vec { + let d = &self.signature_draft; + if dropped.is_empty() || self.dialog != Some(crate::Dialog::Signature) || !(d.scan_mode || d.image_mode) { + return dropped; + } + let scan = d.scan_mode; + let Some(file) = dropped.into_iter().next() else { return Vec::new() }; + let limit = pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES; + let path = file.path().to_path_buf(); + let bytes: Result, String> = if path.is_absolute() { + use std::io::Read as _; + let mut bytes = Vec::new(); + std::fs::File::open(&path).and_then(|f| f.take(limit as u64 + 1).read_to_end(&mut bytes)).map(|_| bytes).map_err(|e| e.to_string()) + } else { + file.bytes().map_err(|e| e.to_string()) + }; + if scan { + let photo = + bytes.and_then(|b| pdfcraft_engine::guard(|| SignaturePhoto::from_bytes(&b)).map_err(|e| e.to_string())?.map_err(|e| e.to_string())); + self.use_signature_photo(photo); + } else { + let image = + bytes.and_then(|b| pdfcraft_engine::guard(|| SignatureImage::from_bytes(&b)).map_err(|e| e.to_string())?.map_err(|e| e.to_string())); + self.use_signature_image(image); + } + Vec::new() + } + + /// Frame the signature in a new photo and clean it up. A previous photo stays on failure. + fn use_signature_photo(&mut self, result: Result) { + match result.and_then(|photo| pdfcraft_engine::guard(|| ScanDraft::new(photo))) { + Ok(mut scan) => { + scan.rescan_in_background(&mut self.signature_draft.image, true, self.ctx.as_ref()); + self.signature_draft.scan = Some(scan); + self.toast = None; + } + Err(e) => self.notify_fmt("Couldn't use the photo: {e}", &[("e", &e)]), + } + } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 455d55ea3..894b4b5ea 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2285,3 +2285,11 @@ scroll speed Fast سريعة Tools panel side جانب لوحة الأدوات Add comment إضافة تعليق Change tag properties تغيير خصائص الوسم + Scan مسح ضوئي + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق. + Choose photo… اختيار صورة… + No photo chosen لم تُختر صورة + Photo صورة + Ink sensitivity حساسية الحبر + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. لم يُعثر على توقيع داخل الإطار. حرّك الإطار فوق التوقيع أو ارفع حساسية الحبر. + Couldn't use the photo: {e} تعذّر استخدام الصورة: {e} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 41992bd12..945549f04 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2227,3 +2227,11 @@ scroll speed Fast Висока Tools panel side Страна на панела с инструменти Add comment Добавяне на коментар Change tag properties Промяна на свойствата на етикета + Scan Сканиране + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията. + Choose photo… Избор на снимка… + No photo chosen Няма избрана снимка + Photo Снимка + Ink sensitivity Чувствителност към мастило + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамката не е открит подпис. Преместете рамката върху подписа или увеличете чувствителността към мастило. + Couldn't use the photo: {e} Снимката не може да се използва: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9da79ee9f..37ee5c26d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2338,3 +2338,11 @@ scroll speed Fast Rychlá Tools panel side Strana panelu nástrojů Add comment Přidat komentář Change tag properties Změnit vlastnosti značky + Scan Skenovat + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír. + Choose photo… Vybrat fotografii… + No photo chosen Není vybrána žádná fotografie + Photo Fotografie + Ink sensitivity Citlivost na inkoust + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V rámečku nebyl nalezen žádný podpis. Přesuňte rámeček na podpis nebo zvyšte citlivost na inkoust. + Couldn't use the photo: {e} Fotografii nelze použít: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 925539523..596bb8f78 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2265,3 +2265,11 @@ scroll speed Fast Schnell Tools panel side Seite des Werkzeugbereichs Add comment Kommentar hinzufügen Change tag properties Tag-Eigenschaften ändern + Scan Scannen + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier. + Choose photo… Foto auswählen… + No photo chosen Kein Foto ausgewählt + Photo Foto + Ink sensitivity Tintenempfindlichkeit + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Im Rahmen wurde keine Unterschrift gefunden. Ziehen Sie den Rahmen über die Unterschrift oder erhöhen Sie die Tintenempfindlichkeit. + Couldn't use the photo: {e} Das Foto kann nicht verwendet werden: {e} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 36f6db11b..4e24e2efd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2280,3 +2280,11 @@ scroll speed Fast Γρήγορη Tools panel side Πλευρά πλαισίου εργαλείων Add comment Προσθήκη σχολίου Change tag properties Αλλαγή ιδιοτήτων ετικέτας + Scan Σάρωση + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί. + Choose photo… Επιλογή φωτογραφίας… + No photo chosen Δεν επιλέχθηκε φωτογραφία + Photo Φωτογραφία + Ink sensitivity Ευαισθησία μελανιού + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Δεν βρέθηκε υπογραφή στο πλαίσιο. Μετακινήστε το πλαίσιο πάνω από την υπογραφή ή αυξήστε την ευαισθησία μελανιού. + Couldn't use the photo: {e} Δεν ήταν δυνατή η χρήση της φωτογραφίας: {e} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5698b4041..f56ae0e61 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2394,3 +2394,11 @@ scroll speed Fast Rápida Tools panel side Lado del panel de herramientas Add comment Añadir comentario Change tag properties Cambiar propiedades de la etiqueta + Scan Escanear + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firme en una hoja de papel blanco y hágale una foto con luz intensa y uniforme (use el flash). Elija la foto o suéltela aquí: PdfCraft encuentra la firma y elimina el papel. + Choose photo… Elegir foto… + No photo chosen No se ha elegido ninguna foto + Photo Foto + Ink sensitivity Sensibilidad de la tinta + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. No se encontró ninguna firma en el marco. Mueva el marco sobre la firma o aumente la sensibilidad de la tinta. + Couldn't use the photo: {e} No se puede usar la foto: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1439c0c7a..2afe1222e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2264,3 +2264,11 @@ scroll speed Fast Rapide Tools panel side Côté du panneau Outils Add comment Ajouter un commentaire Change tag properties Modifier les propriétés de la balise + Scan Numériser + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier. + Choose photo… Choisir une photo… + No photo chosen Aucune photo choisie + Photo Photo + Ink sensitivity Sensibilité à l’encre + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Aucune signature trouvée dans le cadre. Placez le cadre sur la signature ou augmentez la sensibilité à l’encre. + Couldn't use the photo: {e} Impossible d’utiliser la photo : {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0d370fa6..d387523ec 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2263,3 +2263,11 @@ scroll speed Fast Gyors Tools panel side Az Eszközök panel oldala Add comment Megjegyzés hozzáadása Change tag properties Címke tulajdonságainak módosítása + Scan Beolvasás + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt. + Choose photo… Fénykép kiválasztása… + No photo chosen Nincs kiválasztott fénykép + Photo Fénykép + Ink sensitivity Tintaérzékenység + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. A keretben nem található aláírás. Húzza a keretet az aláírásra, vagy növelje a tintaérzékenységet. + Couldn't use the photo: {e} A fénykép nem használható: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ee9034452..13bdf8029 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2273,3 +2273,11 @@ scroll speed Fast Veloce Tools panel side Lato del pannello Strumenti Add comment Aggiungi commento Change tag properties Modifica proprietà del tag + Scan Scansiona + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta. + Choose photo… Scegli foto… + No photo chosen Nessuna foto scelta + Photo Foto + Ink sensitivity Sensibilità all’inchiostro + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nessuna firma trovata nella cornice. Sposta la cornice sulla firma o aumenta la sensibilità all’inchiostro. + Couldn't use the photo: {e} Impossibile usare la foto: {e} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 81b9753e7..70fbf5834 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2264,3 +2264,11 @@ scroll speed Fast 速い Tools panel side ツールパネルの位置 Add comment コメントを追加 Change tag properties タグのプロパティを変更 + Scan スキャン + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。 + Choose photo… 写真を選択… + No photo chosen 写真が選択されていません + Photo 写真 + Ink sensitivity インクの感度 + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 枠内に署名が見つかりません。枠を署名に合わせるか、インクの感度を上げてください。 + Couldn't use the photo: {e} 写真を使用できません: {e} diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 00e5803c2..54514df19 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2262,3 +2262,11 @@ scroll speed Fast Жылдам Tools panel side Құралдар панелінің жағы Add comment Түсініктеме қосу Change tag properties Тег қасиеттерін өзгерту + Scan Сканерлеу + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды. + Choose photo… Суретті таңдау… + No photo chosen Сурет таңдалмаған + Photo Сурет + Ink sensitivity Сия сезімталдығы + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Жақтауда қолтаңба табылмады. Жақтауды қолтаңбаның үстіне жылжытыңыз немесе сия сезімталдығын арттырыңыз. + Couldn't use the photo: {e} Суретті пайдалану мүмкін болмады: {e} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c50f89d3d..508c548d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2244,3 +2244,11 @@ scroll speed Fast Szybka Tools panel side Strona panelu narzędzi Add comment Dodaj komentarz Change tag properties Zmień właściwości znacznika + Scan Skanuj + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier. + Choose photo… Wybierz zdjęcie… + No photo chosen Nie wybrano zdjęcia + Photo Zdjęcie + Ink sensitivity Czułość na atrament + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. W ramce nie znaleziono podpisu. Przesuń ramkę na podpis lub zwiększ czułość na atrament. + Couldn't use the photo: {e} Nie można użyć zdjęcia: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f9c6be2ce..d2e7961f6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2278,3 +2278,11 @@ scroll speed Fast Rápida Tools panel side Lado do painel Ferramentas Add comment Adicionar comentário Change tag properties Alterar propriedades da tag + Scan Digitalizar + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel. + Choose photo… Escolher foto… + No photo chosen Nenhuma foto escolhida + Photo Foto + Ink sensitivity Sensibilidade à tinta + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nenhuma assinatura encontrada no quadro. Mova o quadro sobre a assinatura ou aumente a sensibilidade à tinta. + Couldn't use the photo: {e} Não foi possível usar a foto: {e} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3fe38181e..80e309040 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2177,3 +2177,11 @@ scroll speed Fast Высокая Tools panel side Сторона панели инструментов Add comment Добавить комментарий Change tag properties Изменить свойства тега + Scan Сканировать + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу. + Choose photo… Выбрать фото… + No photo chosen Фото не выбрано + Photo Фото + Ink sensitivity Чувствительность к чернилам + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамке не найдена подпись. Наведите рамку на подпись или увеличьте чувствительность к чернилам. + Couldn't use the photo: {e} Не удалось использовать фото: {e} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index a29443ab2..2c8895974 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2269,3 +2269,11 @@ scroll speed Fast Hitro Tools panel side Stran podokna z orodji Add comment Dodaj komentar Change tag properties Spremeni lastnosti oznake + Scan Skeniraj + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir. + Choose photo… Izberi fotografijo… + No photo chosen Fotografija ni izbrana + Photo Fotografija + Ink sensitivity Občutljivost za črnilo + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V okvirju ni bilo najdenega podpisa. Premaknite okvir nad podpis ali povečajte občutljivost za črnilo. + Couldn't use the photo: {e} Fotografije ni bilo mogoče uporabiti: {e} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 66c654b0d..13de2e2d5 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2177,3 +2177,11 @@ scroll speed Fast వేగంగా Tools panel side టూల్స్ ప్యానెల్ వైపు Add comment వ్యాఖ్యను జోడించు Change tag properties ట్యాగ్ లక్షణాలను మార్చు + Scan స్కాన్ + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది. + Choose photo… ఫోటో ఎంచుకోండి… + No photo chosen ఫోటో ఎంచుకోలేదు + Photo ఫోటో + Ink sensitivity ఇంక్ సున్నితత్వం + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. ఫ్రేమ్‌లో సంతకం కనబడలేదు. ఫ్రేమ్‌ను సంతకం మీదకు జరపండి లేదా ఇంక్ సున్నితత్వాన్ని పెంచండి. + Couldn't use the photo: {e} ఫోటోను ఉపయోగించలేకపోయాం: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8c87c45a3..103d81695 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2260,3 +2260,11 @@ scroll speed Fast Hızlı Tools panel side Araçlar panelinin yeri Add comment Yorum ekle Change tag properties Etiket özelliklerini değiştir + Scan Tara + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır. + Choose photo… Fotoğraf seç… + No photo chosen Fotoğraf seçilmedi + Photo Fotoğraf + Ink sensitivity Mürekkep hassasiyeti + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Çerçevede imza bulunamadı. Çerçeveyi imzanın üzerine taşıyın veya mürekkep hassasiyetini artırın. + Couldn't use the photo: {e} Fotoğraf kullanılamadı: {e} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d559df0f9..d156009d6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2286,3 +2286,11 @@ scroll speed Fast Швидка Tools panel side Бік панелі інструментів Add comment Додати коментар Change tag properties Змінити властивості тегу + Scan Сканувати + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір. + Choose photo… Вибрати фото… + No photo chosen Фото не вибрано + Photo Фото + Ink sensitivity Чутливість до чорнила + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. У рамці не знайдено підпису. Наведіть рамку на підпис або збільште чутливість до чорнила. + Couldn't use the photo: {e} Не вдалося використати фото: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 620659a72..7e4f13e87 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2228,3 +2228,11 @@ scroll speed Fast 快 Tools panel side 工具面板位置 Add comment 添加注释 Change tag properties 更改标签属性 + Scan 扫描 + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。 + Choose photo… 选择照片… + No photo chosen 未选择照片 + Photo 照片 + Ink sensitivity 墨迹灵敏度 + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框内未找到签名。请将框移到签名上,或提高墨迹灵敏度。 + Couldn't use the photo: {e} 无法使用该照片:{e} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2cfe8a63d..0ed02b3ce 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2138,3 +2138,11 @@ scroll speed Fast 快 Tools panel side 工具面板位置 Add comment 新增註解 Change tag properties 變更標籤屬性 + Scan 掃描 + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。 + Choose photo… 選擇相片… + No photo chosen 未選擇相片 + Photo 相片 + Ink sensitivity 墨跡靈敏度 + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框內找不到簽名。請將框移到簽名上,或提高墨跡靈敏度。 + Couldn't use the photo: {e} 無法使用此相片:{e} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15ee7f408..60dad3bf6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -668,6 +668,8 @@ pub struct PdfCraftApp { /// The Create signature / initials dialog, and its typed preview. pub signature_draft: fill_sign::SigDraft, pub(crate) signature_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>, + /// The Scan tab's photo, keyed by its version. + pub(crate) signature_photo: Option<(u64, 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, @@ -902,6 +904,7 @@ impl PdfCraftApp { initials: None, signature_draft: Default::default(), signature_preview: None, + signature_photo: None, saved_signature_previews: [None, None], sign_graphic: sign_ui::SignGraphic::Name, sign_graphic_preview: None, @@ -2274,6 +2277,8 @@ impl eframe::App for PdfCraftApp { self.process_signature_images(); let dropped = ctx.input(|i| i.raw.dropped_files.clone()); #[cfg(not(target_arch = "wasm32"))] + let dropped = self.drop_on_signature_pad(dropped); + #[cfg(not(target_arch = "wasm32"))] let dropped = self.drop_on_grid(dropped, ctx); 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 2b447df63..6918a898d 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1235,3 +1235,235 @@ fn select_tool_picks_up_a_signature_over_a_form_field() { click(&mut h, 270.0, 190.0); assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); } + +/// A synthetic phone photo of a signature on paper: a dark table along the left edge, a sheet +/// with a lighting gradient and a soft shadow, and a ballpoint-blue signature in two words. +fn signature_photo(test: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("pdfcraft-signature-photo-{test}-{}.png", std::process::id())); + let image = image::RgbImage::from_fn(800, 600, |x, y| { + if x < 60 { + return image::Rgb([70, 55, 45]); + } + let light = 235.0 - x as f32 * 0.04 - if (500..700).contains(&x) && y > 380 { 45.0 } else { 0.0 }; + let (fx, fy) = (x as f32, y as f32); + let wave = |x0: f32, x1: f32| (x0..x1).contains(&fx) && (fy - (300.0 + 25.0 * ((fx - x0) / 18.0).sin())).abs() < 3.0; + if wave(180.0, 380.0) || wave(420.0, 640.0) { + image::Rgb([30, 40, 110]) + } else { + let v = light.clamp(0.0, 255.0) as u8; + image::Rgb([v, v, v.saturating_sub(6)]) + } + }); + image.save(&path).unwrap(); + path +} + +fn choose_photo(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("Choose photo…").click(); + h.run_steps(3); + settle_scan(h); +} + +/// Scans run on a worker thread: step frames until the answer has been picked up. +fn settle_scan(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..1000 { + if !h.state().signature_draft.scan.as_ref().is_some_and(|s| s.scanning()) { + h.run_steps(1); + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + h.run_steps(1); + } + panic!("the signature scan did not finish"); +} + +/// A transparent signature in the ink's colour: no paper, table or shadow is left. +fn assert_clean_signature(image: &pdfcraft_engine::SignatureImage) { + let [w, h] = image.size(); + assert!(w > 200 && w > h * 2, "both words are framed and trimmed: {w}×{h}"); + let rgba = image.rgba(); + let opaque: Vec<&[u8; 4]> = rgba.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).collect(); + assert!(!opaque.is_empty()); + assert!(opaque.iter().all(|p| p[2] > p[0]), "strokes keep their blue ink"); + assert!(rgba.as_chunks::<4>().0.iter().filter(|p| p[3] == 0).count() > rgba.len() / 8, "the paper is transparent"); +} + +#[test] +fn scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered() { + let _gpu = gpu(); + let path = signature_photo("scan"); + let mut h = harness(); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + h.get_by_label("Scan").click(); + h.run_steps(2); + assert!(h.state().signature_draft.scan_mode); + assert!(h.state().signature_draft.saved().is_none(), "Apply needs a photo"); + choose_photo(&mut h, &path); + let scan = h.state().signature_draft.scan.clone().expect("the photo is loaded"); + assert_eq!(scan.photo.size(), [800, 600]); + let frame = scan.frame; + assert!(frame.x > 60 && frame.x < 190 && frame.x + frame.width > 630, "the frame holds both words, not the table: {frame:?}"); + assert_clean_signature(h.state().signature_draft.image.as_ref().expect("a cleaned signature")); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/scan-signature-dialog.png")).unwrap(); + } + + // Drawing a frame around the first word only leaves the second out. + let area = h.get_by_label("Photo").rect(); + let to_screen = |x: f32, y: f32| { + let fit = area.shrink(8.0); + let scale = (fit.width() / 800.0).min(fit.height() / 600.0); + let origin = fit.center() - egui::vec2(400.0, 300.0) * scale; + origin + egui::vec2(x, y) * scale + }; + let (a, b) = (to_screen(110.0, 190.0), to_screen(400.0, 370.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(a + (b - a) * (k as f32 / 5.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(3); + settle_scan(&mut h); + let one_word = h.state().signature_draft.scan.as_ref().unwrap().frame; + assert!(one_word.x + one_word.width < 420, "{one_word:?}"); + let [w, _] = h.state().signature_draft.image.as_ref().unwrap().size(); + assert!(w < 300, "only the first word is left: {w}"); + assert!(h.state().signature_draft.saved().is_some(), "the full result is ready after the drag"); + + // Turning the photo turns the frame with it; turning back restores both. + h.get_by_label("Rotate clockwise").click(); + h.run_steps(2); + assert_eq!(h.state().signature_draft.scan.as_ref().unwrap().photo.size(), [600, 800]); + h.get_by_label("Rotate counterclockwise").click(); + h.run_steps(2); + let scan = h.state().signature_draft.scan.clone().unwrap(); + assert_eq!((scan.photo.size(), scan.frame), ([800, 600], one_word)); + + // A photo with nothing in the frame says so instead of offering an empty signature. + { + let d = &mut h.state_mut().signature_draft; + let scan = d.scan.as_mut().unwrap(); + scan.frame = pdfcraft_engine::PhotoRect { x: 650, y: 40, width: 120, height: 100 }; + scan.rescan(&mut d.image, true); + } + h.run_steps(2); + assert!(h.state().signature_draft.saved().is_none()); + assert!(h.state().signature_draft.scan.as_ref().unwrap().problem.as_ref().unwrap().contains("No signature was found")); + h.get_by_label("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity."); + { + let d = &mut h.state_mut().signature_draft; + let scan = d.scan.as_mut().unwrap(); + scan.frame = frame; + scan.rescan(&mut d.image, true); + } + h.run_steps(2); + + h.get_by_label("Apply").click(); + h.run_steps(3); + let Some(SavedSig::Image(saved)) = h.state().signature.clone() else { panic!("the scan is saved as an image signature") }; + assert_clean_signature(&saved); + click(&mut h, 40.0, 250.0); + assert_eq!(items(&h).len(), 1); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].subtype, "Stamp"); + + // It is remembered like any image signature, without the photo or its path. + let settings = h.state().persist(); + assert!(!settings.contains(&path.to_string_lossy().to_string())); + assert!(settings.len() < 200_000, "only the trimmed signature is kept: {} bytes", settings.len()); + let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); + again.restore(&settings); + assert_eq!(again.signature, h.state().signature); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn scan_cancel_errors_and_stale_answers_preserve_saved_signatures() { + let path = signature_photo("scan-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("Scan").click(); + h.run_steps(2); + h.state_mut().pick_override = Some(Vec::new()); + h.get_by_label("Choose photo…").click(); + h.run_steps(3); + assert!(h.state().signature_draft.scan.is_none(), "a cancelled pick loads nothing"); + choose_photo(&mut h, &path); + let loaded = h.state().signature_draft.clone(); + assert!(loaded.image.is_some()); + let broken = std::env::temp_dir().join(format!("pdfcraft-signature-photo-broken-{}.jpg", std::process::id())); + std::fs::write(&broken, b"\xFF\xD8\xFF not a JPEG").unwrap(); + choose_photo(&mut h, &broken); + assert_eq!(h.state().signature_draft, loaded, "an invalid photo keeps the previous one"); + assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't use the photo")); + // A photo picked for the Scan tab does not land in the Image tab. + h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]); + h.get_by_label("Choose photo…").click(); + h.run_steps(1); + h.state_mut().signature_draft.scan = None; + h.state_mut().signature_draft.image = None; + h.state_mut().signature_draft.scan_mode = false; + h.state_mut().signature_draft.image_mode = true; + h.run_steps(3); + assert!(h.state().signature_draft.image.is_none() && h.state().signature_draft.scan.is_none()); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().signature, Some(original)); + assert!(items(&h).is_empty()); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(broken).unwrap(); +} + +/// A file dropped on the window, as the windowing layer hands it over. +#[derive(Debug)] +struct Dropped(std::path::PathBuf, Vec); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +#[test] +fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { + let photo = signature_photo("drop"); + let mut h = harness(); + let tabs = h.state().views.len(); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + h.get_by_label("Scan").click(); + h.run_steps(2); + // From a file manager (an absolute path) and from a browser-like source (bytes only). + for file in [Dropped(photo.clone(), Vec::new()), Dropped("photo.png".into(), std::fs::read(&photo).unwrap())] { + h.state_mut().signature_draft.scan = None; + h.input_mut().dropped_files.push(std::sync::Arc::new(file)); + h.run_steps(3); + settle_scan(&mut h); + assert!(h.state().signature_draft.scan.is_some(), "the photo is scanned"); + assert!(h.state().signature_draft.image.is_some()); + } + h.get_by_label("Image").click(); + h.run_steps(2); + h.state_mut().signature_draft.image = None; + h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("photo.png".into(), std::fs::read(&photo).unwrap()))); + h.run_steps(3); + assert!(h.state().signature_draft.image.is_some(), "the Image tab takes the picture as it is"); + h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("broken.png".into(), b"broken".to_vec()))); + h.run_steps(3); + assert!(h.state().signature_draft.image.is_some(), "an invalid file keeps the previous draft"); + assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't import")); + assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog"); + std::fs::remove_file(photo).unwrap(); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ea8db06c9..1c3daaf61 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5255,15 +5255,15 @@ notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 12 presets [[feature]] id = "form.fill-sign-signature" -title = "Fill & Sign: typed, drawn or image signature and initials" +title = "Fill & Sign: typed, drawn, image or scanned signature and initials" area = "F" tier = "P0" milestone = "M5" 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", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "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::large_signature_images_are_scaled_down", "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", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); 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; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; 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, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075)" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "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::large_signature_images_are_scaled_down", "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", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); 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; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; 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, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] id = "form.fill-sign-flatten"