mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
M5: Fill & Sign typed signatures and initials (Type or Draw; script-font outlines drawn as filled paths); fill_sign_add signature/initials
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1f2e267f32
commit
01d0dc67ba
+2
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (237)
|
||||
## In this repository (238)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -245,6 +245,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material
|
||||
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PrintCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PrintCraft did not make |
|
||||
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
|
||||
@@ -2948,3 +2948,14 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "631d24bde604be36b2dcf3b6f86ddf6026879cdcd272c0dfe37ab0cc6b341fda"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/fonts/DancingScript.ttf"
|
||||
title = "Dancing Script (variable)"
|
||||
author = "The Dancing Script Project Authors (Pablo Impallari)"
|
||||
source = "https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf"
|
||||
licence = "OFL-1.1"
|
||||
licence_file = "assets/fonts/OFL-DancingScript.txt"
|
||||
kind = "font"
|
||||
usage = "Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs)"
|
||||
sha256 = "21808625578fe8d8cd10cb684be546dca077b27cd03a53a2f1ec11dc743c924c"
|
||||
|
||||
Generated
+4
@@ -3460,6 +3460,7 @@ dependencies = [
|
||||
"printcraft-cos",
|
||||
"printcraft-create",
|
||||
"printcraft-edit",
|
||||
"printcraft-fonts",
|
||||
"printcraft-forms",
|
||||
"printcraft-model",
|
||||
"printcraft-optimize",
|
||||
@@ -3485,6 +3486,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "printcraft-fonts"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "printcraft-forms"
|
||||
|
||||
@@ -8,6 +8,7 @@ policy is AGENTS.md §1.
|
||||
- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
|
||||
- Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
- JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
- Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt.
|
||||
- hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit
|
||||
standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles
|
||||
Adobe CMap resources under BSD-3-Clause.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Dancing Script Project Authors (https://github.com/googlefonts/DancingScript), with Reserved Font Name 'Dancing Script'.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -401,6 +401,23 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
}
|
||||
b"Stamp" => {
|
||||
// A typed signature: filled outlines normalised to the rectangle.
|
||||
if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
c.push_str(&rg(stroke.unwrap_or([0.0; 3])));
|
||||
for contour in outline {
|
||||
let v: Vec<f64> = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
|
||||
for (i, p) in v.chunks_exact(2).enumerate() {
|
||||
c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
if v.len() >= 6 {
|
||||
c.push_str("h\n");
|
||||
}
|
||||
}
|
||||
c.push_str("f*\n");
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
}
|
||||
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
|
||||
// the rectangle.
|
||||
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
|
||||
|
||||
+16
-1
@@ -327,6 +327,12 @@ pub enum Shape {
|
||||
stamp: StampKind,
|
||||
by: Option<String>,
|
||||
},
|
||||
/// A typed signature or initials: filled outlines (each contour as points normalised to
|
||||
/// `rect`, 0–1, y up), drawn with the even-odd rule.
|
||||
TypedSignature {
|
||||
rect: [f64; 4],
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
|
||||
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
|
||||
CustomStamp {
|
||||
@@ -417,7 +423,7 @@ impl Shape {
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
|
||||
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Stamp",
|
||||
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp",
|
||||
Shape::Redact { .. } => "Redact",
|
||||
Shape::Polygon { .. } => "Polygon",
|
||||
Shape::PolyLine { .. } => "PolyLine",
|
||||
@@ -523,6 +529,7 @@ impl Style {
|
||||
Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
Shape::Stamp { stamp, .. } => (stamp.color(), 2.0),
|
||||
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
// Red outline while marked; a black box once applied.
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
|
||||
};
|
||||
@@ -715,6 +722,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
| Shape::Typewriter { rect, .. }
|
||||
| Shape::Stamp { rect, .. }
|
||||
| Shape::CustomStamp { rect, .. }
|
||||
| Shape::TypedSignature { rect, .. }
|
||||
| Shape::Mark { rect, .. } => {
|
||||
let r = normalize(*rect);
|
||||
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
|
||||
@@ -811,6 +819,7 @@ fn subject(shape: &Shape) -> &'static str {
|
||||
StampGroup::StandardBusiness => "Stamp",
|
||||
},
|
||||
Shape::CustomStamp { .. } => "Stamp",
|
||||
Shape::TypedSignature { .. } => "Signature",
|
||||
Shape::Polygon { cloud: true, .. } => "Cloud",
|
||||
Shape::Polygon { .. } => "Polygon",
|
||||
Shape::PolyLine { .. } => "Polygonal Line",
|
||||
@@ -916,6 +925,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"PCByLine".to_vec(), PdfString::text(b));
|
||||
}
|
||||
}
|
||||
Shape::TypedSignature { contours, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name("PCTypedSignature"));
|
||||
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
|
||||
d.set(b"PCOutline".to_vec(), Object::Array(list));
|
||||
}
|
||||
Shape::CustomStamp { name, picture, image, size, .. } => {
|
||||
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
|
||||
|
||||
@@ -352,6 +352,14 @@ impl Automation {
|
||||
let t = format!("{m}/{d}/{yy}");
|
||||
(text_at(&t), t)
|
||||
}
|
||||
// A typed signature or initials in the script font, left edge at `at`.
|
||||
kind @ ("signature" | "initials") => {
|
||||
let t = a.str("text")?;
|
||||
let h = if kind == "initials" { 24.0 } else { 32.0 };
|
||||
let shape =
|
||||
printcraft_engine::typed_signature_shape(at, t, h).ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
|
||||
(shape, String::new())
|
||||
}
|
||||
kind => {
|
||||
let mark = match kind {
|
||||
"check" => FillMark::Check,
|
||||
|
||||
@@ -754,14 +754,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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, or today's date, at `at` [x, y] in points from the top-left of the page (the text's top-left; a mark's centre). Creates movable, undoable annotations.",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date, or a typed signature or initials (`text` drawn in a script font as filled outlines; at is its left edge, centred vertically), at `at` [x, y] in points from the top-left of the page (the text's top-left; a mark's centre). Creates movable, undoable annotations.",
|
||||
)
|
||||
.cmd("sign.fill.text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date"] },
|
||||
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
|
||||
"at": point(),
|
||||
"text": { "type": "string", "minLength": 1 },
|
||||
"author": { "type": "string" },
|
||||
|
||||
@@ -676,10 +676,15 @@ fn fill_and_sign_through_tools() {
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Ada Lovelace" }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [20, 80] }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 100] }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 150], "text": "Ada Lovelace" }));
|
||||
assert!(matches!(
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 190], "text": " " })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
let texts = page_text(&mut a, doc);
|
||||
assert!(texts[0].contains("Ada Lovelace") && texts[0].contains("11/14/2023"), "{texts:?}");
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 3);
|
||||
assert_eq!(list["count"], 4);
|
||||
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ printcraft-xfdf = { workspace = true }
|
||||
printcraft-sign = { workspace = true }
|
||||
printcraft-optimize = { workspace = true }
|
||||
printcraft-a11y = { workspace = true }
|
||||
printcraft-fonts = { workspace = true }
|
||||
printcraft-create = { workspace = true }
|
||||
printcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -97,6 +97,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Line", "minus", "sign.fill.line", Ready),
|
||||
item("Date", "clock-3", "sign.fill.date", Ready),
|
||||
item("Signature", "signature", "sign.fill.signature", Ready),
|
||||
item("Initials", "signature", "sign.fill.initials", Ready),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -242,6 +242,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("sign.fill.line", "Fill & Sign: line", None, None, Annotate, "minus"),
|
||||
c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"),
|
||||
c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"),
|
||||
c("sign.fill.initials", "Fill & Sign: initials", None, None, Annotate, "signature"),
|
||||
c("export.image", "Export to image…", FILE, None, Document, "image"),
|
||||
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
|
||||
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
|
||||
|
||||
@@ -31,6 +31,21 @@ pub use printcraft_forms::{
|
||||
};
|
||||
|
||||
pub use printcraft_a11y as a11y;
|
||||
pub use printcraft_fonts::{ScriptOutline, script_outline};
|
||||
|
||||
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
|
||||
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
|
||||
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option<Shape> {
|
||||
let o = script_outline(text);
|
||||
let span = (o.ascent - o.descent).max(0.1);
|
||||
if o.contours.is_empty() || o.width <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = height / span;
|
||||
let rect = [at[0], at[1] - height / 2.0, at[0] + o.width * k, at[1] + height / 2.0];
|
||||
let contours = o.contours.iter().map(|c| c.iter().map(|p| [p[0] / o.width, (p[1] - o.descent) / span]).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use printcraft_annot::appearance as annot_text;
|
||||
pub use printcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
@@ -933,7 +948,7 @@ fn annotation_noun(s: &Shape) -> &'static str {
|
||||
Shape::Mark { mark: FillMark::Cross, .. } => "cross",
|
||||
Shape::Mark { mark: FillMark::Dot, .. } => "dot",
|
||||
Shape::Mark { mark: FillMark::Line, .. } => "line",
|
||||
Shape::Signature { .. } => "signature",
|
||||
Shape::Signature { .. } | Shape::TypedSignature { .. } => "signature",
|
||||
Shape::Redact { .. } => "redaction mark",
|
||||
Shape::Stamp { .. } | Shape::CustomStamp { .. } => "stamp",
|
||||
Shape::Polygon { cloud: true, .. } => "cloud",
|
||||
|
||||
@@ -9,3 +9,5 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Glyph outlines of the bundled script font (typed signatures); already in the tree via egui.
|
||||
skrifa = "0.44"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! See the README: the metrics are approximations by character class (no vendor metrics files
|
||||
//! are bundled). The full font subsystem lands in M2.2/M7.
|
||||
|
||||
mod script;
|
||||
pub use script::{ScriptOutline, script_outline};
|
||||
|
||||
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
|
||||
pub fn helvetica_width(s: &str, size: f64) -> f64 {
|
||||
let units: f64 = s
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Typed signatures: a name in the bundled script font (Dancing Script, OFL-1.1) as glyph
|
||||
//! outlines, flattened to polygons. Drawn as filled paths, so no font is embedded in the PDF.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
|
||||
|
||||
/// Outlines of a line of text at a font size of 1 (em units): y up, the baseline at 0, starting
|
||||
/// at x = 0. Fill them with the even-odd (or nonzero) rule.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScriptOutline {
|
||||
pub contours: Vec<Vec<[f64; 2]>>,
|
||||
pub width: f64,
|
||||
pub ascent: f64,
|
||||
pub descent: f64,
|
||||
}
|
||||
|
||||
struct Flatten {
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
cur: Vec<[f64; 2]>,
|
||||
scale: f64,
|
||||
dx: f64,
|
||||
}
|
||||
|
||||
impl Flatten {
|
||||
fn pt(&self, x: f32, y: f32) -> [f64; 2] {
|
||||
[self.dx + x as f64 * self.scale, y as f64 * self.scale]
|
||||
}
|
||||
fn last(&self) -> [f64; 2] {
|
||||
self.cur.last().copied().unwrap_or([self.dx, 0.0])
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS: usize = 8;
|
||||
|
||||
impl OutlinePen for Flatten {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if self.cur.len() > 2 {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.cur.clear();
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
}
|
||||
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
|
||||
let (p0, c, p) = (self.last(), self.pt(cx, cy), self.pt(x, y));
|
||||
for i in 1..=STEPS {
|
||||
let t = i as f64 / STEPS as f64;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
|
||||
let (p0, c0, c1, p) = (self.last(), self.pt(c0x, c0y), self.pt(c1x, c1y), self.pt(x, y));
|
||||
for i in 1..=STEPS {
|
||||
let t = i as f64 / STEPS as f64;
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
self.cur.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
if self.cur.len() > 2 {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.cur.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The outlines of `text` in the script font (characters it lacks are skipped).
|
||||
pub fn script_outline(text: &str) -> ScriptOutline {
|
||||
let Ok(font) = FontRef::new(FONT) else { return ScriptOutline::default() };
|
||||
let loc = LocationRef::default();
|
||||
let metrics = font.metrics(Size::unscaled(), loc);
|
||||
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
|
||||
let charmap = font.charmap();
|
||||
let glyphs = font.outline_glyphs();
|
||||
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
||||
let mut pen = Flatten { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0 };
|
||||
for ch in text.chars() {
|
||||
let Some(gid) = charmap.map(ch) else { continue };
|
||||
if let Some(g) = glyphs.get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
||||
pen.close();
|
||||
}
|
||||
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
||||
}
|
||||
ScriptOutline { contours: pen.contours, width: pen.dx, ascent: metrics.ascent as f64 * scale, descent: metrics.descent as f64 * scale }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_name_becomes_outlines() {
|
||||
let o = super::script_outline("Ada L.");
|
||||
assert!(o.contours.len() >= 5, "{}", o.contours.len());
|
||||
assert!(o.width > 1.0 && o.width < 6.0, "{}", o.width);
|
||||
assert!(o.ascent > 0.5 && o.descent < 0.0);
|
||||
let max_x = o.contours.iter().flatten().map(|p| p[0]).fold(0.0, f64::max);
|
||||
assert!(max_x <= o.width + 0.2);
|
||||
assert!(super::script_outline("").contours.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -974,6 +974,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let can_crop = doc.allows_assembly();
|
||||
let mut open_boxes = false;
|
||||
let signature = app.signature.clone();
|
||||
let initials = app.initials.clone();
|
||||
let mut open_initials = false;
|
||||
let author = app.comment_prefs.author.clone();
|
||||
let today = app.session.today();
|
||||
let by_line = app.session.stamp_by_line(&author);
|
||||
@@ -1170,9 +1172,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if let QuickTool::Fill(ft) = tool
|
||||
&& allowed
|
||||
{
|
||||
match crate::fill_sign::page_input(ui, &resp, &xf, i, info, ft, view, signature.as_ref(), &author, today) {
|
||||
match crate::fill_sign::page_input(ui, &resp, &xf, i, info, ft, view, signature.as_ref(), initials.as_ref(), &author, today) {
|
||||
Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e),
|
||||
Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true,
|
||||
Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -1598,8 +1601,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
app.redact_prefs.mark(page, quads, &author)
|
||||
});
|
||||
}
|
||||
if open_signature {
|
||||
app.signature_draft.clear();
|
||||
if open_signature || open_initials {
|
||||
app.signature_draft = crate::fill_sign::SigDraft::new(open_initials, &app.comment_prefs.author);
|
||||
app.dialog = Some(crate::Dialog::Signature);
|
||||
}
|
||||
if open_boxes {
|
||||
|
||||
@@ -369,8 +369,9 @@ impl PrintCraftApp {
|
||||
fill if crate::fill_sign::FillTool::from_command(fill).is_some() => {
|
||||
let tool = crate::fill_sign::FillTool::from_command(fill).expect("checked");
|
||||
self.quick_tool = crate::QuickTool::Fill(tool);
|
||||
if tool == crate::fill_sign::FillTool::Signature && self.signature.is_none() {
|
||||
self.signature_draft.clear();
|
||||
let initials = tool == crate::fill_sign::FillTool::Initials;
|
||||
if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) {
|
||||
self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author);
|
||||
self.dialog = Some(Dialog::Signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,10 +780,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
Dialog::Signature => {
|
||||
let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft);
|
||||
let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview);
|
||||
if apply {
|
||||
app.signature = Some(std::mem::take(&mut app.signature_draft));
|
||||
app.quick_tool = crate::QuickTool::Fill(crate::fill_sign::FillTool::Signature);
|
||||
let d = std::mem::take(&mut app.signature_draft);
|
||||
let tool = if d.initials {
|
||||
app.initials = Some(d.saved());
|
||||
crate::fill_sign::FillTool::Initials
|
||||
} else {
|
||||
app.signature = Some(d.saved());
|
||||
crate::fill_sign::FillTool::Signature
|
||||
};
|
||||
app.quick_tool = crate::QuickTool::Fill(tool);
|
||||
app.toast = None;
|
||||
}
|
||||
close = apply || cancel;
|
||||
|
||||
+137
-10
@@ -19,10 +19,45 @@ pub enum FillTool {
|
||||
Line,
|
||||
Date,
|
||||
Signature,
|
||||
Initials,
|
||||
}
|
||||
|
||||
pub const FILL_TOOLS: [FillTool; 7] =
|
||||
[FillTool::Text, FillTool::Cross, FillTool::Check, FillTool::Dot, FillTool::Line, FillTool::Date, FillTool::Signature];
|
||||
pub const FILL_TOOLS: [FillTool; 8] =
|
||||
[FillTool::Text, FillTool::Cross, FillTool::Check, FillTool::Dot, FillTool::Line, FillTool::Date, FillTool::Signature, FillTool::Initials];
|
||||
|
||||
/// A saved signature or initials: drawn strokes (normalised to the pad width, y up) or typed
|
||||
/// text (drawn in the script font).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SavedSig {
|
||||
Drawn(Vec<Vec<[f32; 2]>>),
|
||||
Typed(String),
|
||||
}
|
||||
|
||||
/// The Create signature / initials dialog.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SigDraft {
|
||||
pub strokes: Vec<Vec<[f32; 2]>>,
|
||||
pub text: String,
|
||||
/// The Draw tab (otherwise Type).
|
||||
pub drawing: bool,
|
||||
/// Creating initials (otherwise the signature).
|
||||
pub initials: bool,
|
||||
}
|
||||
|
||||
impl SigDraft {
|
||||
pub fn new(initials: bool, name: &str) -> Self {
|
||||
let text = if initials { name.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { name.trim().to_string() };
|
||||
Self { strokes: Vec::new(), text, drawing: false, initials }
|
||||
}
|
||||
|
||||
fn ready(&self) -> bool {
|
||||
if self.drawing { self.strokes.iter().any(|s| s.len() > 1) } else { !self.text.trim().is_empty() }
|
||||
}
|
||||
|
||||
pub fn saved(&self) -> SavedSig {
|
||||
if self.drawing { SavedSig::Drawn(self.strokes.clone()) } else { SavedSig::Typed(self.text.trim().to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl FillTool {
|
||||
pub fn command(self) -> &'static str {
|
||||
@@ -34,6 +69,7 @@ impl FillTool {
|
||||
FillTool::Line => "sign.fill.line",
|
||||
FillTool::Date => "sign.fill.date",
|
||||
FillTool::Signature => "sign.fill.signature",
|
||||
FillTool::Initials => "sign.fill.initials",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +86,7 @@ impl FillTool {
|
||||
FillTool::Line => "Line",
|
||||
FillTool::Date => "Date",
|
||||
FillTool::Signature => "Sign",
|
||||
FillTool::Initials => "Initials",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +98,7 @@ impl FillTool {
|
||||
FillTool::Dot => "circle-dot",
|
||||
FillTool::Line => "minus",
|
||||
FillTool::Date => "clock-3",
|
||||
FillTool::Signature => "signature",
|
||||
FillTool::Signature | FillTool::Initials => "signature",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +159,57 @@ pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author
|
||||
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
|
||||
}
|
||||
|
||||
/// Place typed text in the script font with its left edge at `at`, `height` points tall.
|
||||
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, author: &str) -> Option<Edit> {
|
||||
printcraft_engine::typed_signature_shape(at, text, height).map(|shape| new(page, shape, String::new(), author))
|
||||
}
|
||||
|
||||
/// Place a saved signature or initials.
|
||||
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
|
||||
match sig {
|
||||
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
|
||||
SavedSig::Typed(text) => typed_signature_at(page, at, text, if initials { 24.0 } else { 32.0 }, author),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text in the script font as a picture (`w`×`h` px, black on transparent), for previews.
|
||||
pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage {
|
||||
let o = printcraft_engine::script_outline(text);
|
||||
let mut img = egui::ColorImage::filled([w, h], Color32::TRANSPARENT);
|
||||
let span = (o.ascent - o.descent).max(0.1);
|
||||
if o.contours.is_empty() {
|
||||
return img;
|
||||
}
|
||||
let k = ((h as f64 * 0.9) / span).min((w as f64 * 0.95) / o.width.max(0.01));
|
||||
let x0 = (w as f64 - o.width * k) / 2.0;
|
||||
// Device points (y down), then an even-odd scanline fill.
|
||||
let polys: Vec<Vec<(f64, f64)>> = o
|
||||
.contours
|
||||
.iter()
|
||||
.map(|c| c.iter().map(|p| (x0 + p[0] * k, h as f64 * 0.5 + (o.ascent + o.descent) / 2.0 * k - p[1] * k)).collect())
|
||||
.collect();
|
||||
for y in 0..h {
|
||||
let sy = y as f64 + 0.5;
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for poly in &polys {
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
if (a.1 <= sy) != (b.1 <= sy) {
|
||||
xs.push(a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
for pair in xs.chunks_exact(2) {
|
||||
let (from, to) = (pair[0].round().max(0.0) as usize, (pair[1].round() as usize).min(w));
|
||||
for x in from..to {
|
||||
img[(x, y)] = Color32::BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Clicks with a Fill & Sign tool on one page. Returns `true` when the click was used.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn page_input(
|
||||
@@ -132,7 +220,8 @@ pub(crate) fn page_input(
|
||||
info: &DocInfo,
|
||||
tool: FillTool,
|
||||
view: &mut DocView,
|
||||
signature: Option<&Vec<Vec<[f32; 2]>>>,
|
||||
signature: Option<&SavedSig>,
|
||||
initials: Option<&SavedSig>,
|
||||
author: &str,
|
||||
today: (i64, u32, u32),
|
||||
) -> Option<FillAction> {
|
||||
@@ -155,9 +244,13 @@ pub(crate) fn page_input(
|
||||
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
|
||||
}
|
||||
FillTool::Signature => match signature {
|
||||
Some(s) => signature_at(page, at, s, author).map(|e| FillAction::Edit(Box::new(e))),
|
||||
Some(s) => place(page, at, s, false, author).map(|e| FillAction::Edit(Box::new(e))),
|
||||
None => Some(FillAction::CreateSignature),
|
||||
},
|
||||
FillTool::Initials => match initials {
|
||||
Some(s) => place(page, at, s, true, author).map(|e| FillAction::Edit(Box::new(e))),
|
||||
None => Some(FillAction::CreateInitials),
|
||||
},
|
||||
mark => {
|
||||
let mark = mark.mark().expect("marks");
|
||||
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
@@ -173,6 +266,8 @@ pub enum FillAction {
|
||||
Edit(Box<Edit>),
|
||||
/// No signature yet: open the signature pad.
|
||||
CreateSignature,
|
||||
/// No initials yet.
|
||||
CreateInitials,
|
||||
}
|
||||
|
||||
/// The in-place editor for typed text. Returns the edit once committed.
|
||||
@@ -220,10 +315,37 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
}
|
||||
|
||||
/// The signature pad: draw with the pointer; returns the strokes (normalised) on Apply.
|
||||
pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, strokes: &mut Vec<Vec<[f32; 2]>>) -> (bool, bool) {
|
||||
ui.label(egui::RichText::new("Create signature").font(crate::theme::semibold(18.0)));
|
||||
ui.label(egui::RichText::new("Draw your signature below.").color(t.text_muted));
|
||||
pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, preview: &mut Option<(String, egui::TextureHandle)>) -> (bool, bool) {
|
||||
let what = if d.initials { "initials" } else { "signature" };
|
||||
ui.label(egui::RichText::new(format!("Create {what}")).font(crate::theme::semibold(18.0)));
|
||||
ui.horizontal(|ui| {
|
||||
if crate::widgets::pill_button(ui, "Type", !d.drawing).clicked() {
|
||||
d.drawing = false;
|
||||
}
|
||||
if crate::widgets::pill_button(ui, "Draw", d.drawing).clicked() {
|
||||
d.drawing = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if !d.drawing {
|
||||
let l = ui.label(egui::RichText::new(format!("Type your {what}.")).color(t.text_muted));
|
||||
ui.add(egui::TextEdit::singleline(&mut d.text).desired_width(460.0).hint_text(if d.initials { "Initials" } else { "Your name" }))
|
||||
.labelled_by(l.id);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
|
||||
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
if preview.as_ref().is_none_or(|(s, _)| *s != d.text) {
|
||||
let img = script_preview(&d.text, 920, 300);
|
||||
*preview = Some((d.text.clone(), ui.ctx().load_texture("typed-signature", img, egui::TextureOptions::LINEAR)));
|
||||
}
|
||||
if let Some((_, tex)) = preview {
|
||||
painter.image(tex.id(), rect.shrink(4.0), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
return pad_buttons(ui, d);
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("Draw your {what} below.")).color(t.text_muted));
|
||||
let strokes = &mut d.strokes;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::drag());
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
|
||||
@@ -246,14 +368,19 @@ pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, strokes: &mut Vec<Vec
|
||||
let pts: Vec<Pos2> = s.iter().map(|p| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.width())).collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::BLACK)));
|
||||
}
|
||||
pad_buttons(ui, d)
|
||||
}
|
||||
|
||||
fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
|
||||
ui.add_space(10.0);
|
||||
let (mut apply, mut cancel) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Clear").clicked() {
|
||||
strokes.clear();
|
||||
d.strokes.clear();
|
||||
d.text.clear();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ready = strokes.iter().any(|s| s.len() > 1);
|
||||
let ready = d.ready();
|
||||
if ui.add_enabled_ui(ready, |ui| crate::widgets::pill_button(ui, "Apply", true)).inner.clicked() {
|
||||
apply = true;
|
||||
}
|
||||
|
||||
@@ -349,10 +349,12 @@ pub struct PrintCraftApp {
|
||||
pub export_draft: export_ui::ExportDraft,
|
||||
/// A running export's progress.
|
||||
export_status: Option<export_ui::ExportStatus>,
|
||||
/// The saved Fill & Sign signature (strokes normalised to the pad width, y up).
|
||||
pub signature: Option<Vec<Vec<[f32; 2]>>>,
|
||||
/// Strokes being drawn in the signature pad.
|
||||
pub signature_draft: Vec<Vec<[f32; 2]>>,
|
||||
/// The saved Fill & Sign signature and initials (drawn or typed).
|
||||
pub signature: Option<fill_sign::SavedSig>,
|
||||
pub initials: Option<fill_sign::SavedSig>,
|
||||
/// The Create signature / initials dialog, and its typed preview.
|
||||
pub signature_draft: fill_sign::SigDraft,
|
||||
pub(crate) signature_preview: Option<(String, egui::TextureHandle)>,
|
||||
/// The Comment Properties dialog's state.
|
||||
pub comment_props: Option<comment_props::PropsDraft>,
|
||||
pub field_props: Option<prepare::FieldDraft>,
|
||||
@@ -460,7 +462,9 @@ impl PrintCraftApp {
|
||||
export_draft: Default::default(),
|
||||
export_status: None,
|
||||
signature: None,
|
||||
signature_draft: Vec::new(),
|
||||
initials: None,
|
||||
signature_draft: Default::default(),
|
||||
signature_preview: None,
|
||||
comment_props: None,
|
||||
field_props: None,
|
||||
redact_prefs: RedactPrefs::default(),
|
||||
@@ -751,7 +755,10 @@ impl PrintCraftApp {
|
||||
serde_json::json!({
|
||||
"recent": self.recent,
|
||||
"theme": self.theme,
|
||||
"signature": self.signature,
|
||||
// Drawn signatures keep their original form (older settings read the same).
|
||||
"signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None },
|
||||
"signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None },
|
||||
"initials": self.initials,
|
||||
// Keychain identities are read from macOS each time.
|
||||
"digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:")).collect::<Vec<_>>(),
|
||||
"trusted": trusted,
|
||||
@@ -775,7 +782,13 @@ impl PrintCraftApp {
|
||||
if let Ok(s) = serde_json::from_value::<Vec<Vec<[f32; 2]>>>(v["signature"].clone())
|
||||
&& s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite())))
|
||||
{
|
||||
self.signature = Some(s);
|
||||
self.signature = Some(fill_sign::SavedSig::Drawn(s));
|
||||
}
|
||||
if let Some(t) = v["signature_text"].as_str().filter(|t| !t.trim().is_empty()) {
|
||||
self.signature = Some(fill_sign::SavedSig::Typed(t.to_string()));
|
||||
}
|
||||
if let Ok(i) = serde_json::from_value::<fill_sign::SavedSig>(v["initials"].clone()) {
|
||||
self.initials = Some(i);
|
||||
}
|
||||
if let Ok(ids) = serde_json::from_value::<Vec<DigitalIdEntry>>(v["digital_ids"].clone()) {
|
||||
self.digital_ids = ids;
|
||||
|
||||
@@ -74,6 +74,8 @@ fn signing_draws_a_signature_once_and_places_it() {
|
||||
assert!(h.state_mut().execute("sign.fill.signature"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Signature), "no signature yet: the pad opens");
|
||||
h.get_all_by_label("Draw").last().unwrap().click();
|
||||
h.run_steps(2);
|
||||
let pad = h.get_by_label("Draw your signature below.").rect();
|
||||
let start = pos2(pad.left() + 40.0, pad.bottom() + 70.0);
|
||||
h.hover_at(start);
|
||||
@@ -98,3 +100,34 @@ fn signing_draws_a_signature_once_and_places_it() {
|
||||
again.restore(&saved);
|
||||
assert!(again.signature.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_signatures_and_initials() {
|
||||
let mut h = harness();
|
||||
h.state_mut().comment_prefs.author = "Grace Hopper".into();
|
||||
assert!(h.state_mut().execute("sign.fill.signature"));
|
||||
h.run_steps(2);
|
||||
// Type is the default, with the author's name filled in.
|
||||
h.get_by_label("Type your signature.");
|
||||
assert_eq!(h.state().signature_draft.text, "Grace Hopper");
|
||||
h.get_by_label("Apply").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().signature, Some(printcraft_ui_egui::fill_sign::SavedSig::Typed("Grace Hopper".into())));
|
||||
click(&mut h, 60.0, 100.0);
|
||||
assert!(items(&h).iter().any(|(t, _)| t == "Stamp"), "typed signatures are filled outlines");
|
||||
// Initials: their own pad (GH), then placed.
|
||||
assert!(h.state_mut().execute("sign.fill.initials"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Create initials");
|
||||
assert_eq!(h.state().signature_draft.text, "GH");
|
||||
h.get_by_label("Apply").click();
|
||||
h.run_steps(3);
|
||||
click(&mut h, 60.0, 160.0);
|
||||
assert_eq!(items(&h).iter().filter(|(t, _)| t == "Stamp").count(), 2);
|
||||
// Both are remembered.
|
||||
let saved = h.state().persist();
|
||||
let mut again = PrintCraftApp::new();
|
||||
again.restore(&saved);
|
||||
assert_eq!(again.signature, h.state().signature);
|
||||
assert_eq!(again.initials, Some(printcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into())));
|
||||
}
|
||||
|
||||
@@ -5176,10 +5176,10 @@ area = "F"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "partial"
|
||||
commands = ["sign.fill.signature"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it"]
|
||||
notes = "drawn signature, saved in settings; typed/image signatures and initials not yet"
|
||||
commands = ["sign.fill.signature", "sign.fill.initials"]
|
||||
tools = ["fill_sign_add"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded) or drawn, saved in settings; image signatures not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-sign-flatten"
|
||||
|
||||
Reference in New Issue
Block a user