diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6b3a38a7e..a05f8422a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index fd107792e..92a0df3f9 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/Cargo.lock b/Cargo.lock index 8f939d711..52f41aff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/NOTICE b/NOTICE index 466cd63a7..cafc3a17f 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/assets/fonts/DancingScript.ttf b/assets/fonts/DancingScript.ttf new file mode 100644 index 000000000..9f521e7fb Binary files /dev/null and b/assets/fonts/DancingScript.ttf differ diff --git a/assets/fonts/OFL-DancingScript.txt b/assets/fonts/OFL-DancingScript.txt new file mode 100644 index 000000000..07ff8df96 --- /dev/null +++ b/assets/fonts/OFL-DancingScript.txt @@ -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. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0b097a9b7..95fc98f43 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -401,6 +401,23 @@ pub fn build(d: &Dict) -> Option { } } 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 = 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) { diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f8ce3cbae..4132daf31 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -327,6 +327,12 @@ pub enum Shape { stamp: StampKind, by: Option, }, + /// 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>, + }, /// 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::>())).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}"))); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index eb338ddfd..0f41bdbb6 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -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, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 636210442..e284939ea 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -754,14 +754,14 @@ 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, 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" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 938831022..dcaada256 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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(_)))); } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1e888d755..74f5d0c04 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7ee72d8d0..de02b8645 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d2ba98890..91a8e8ce4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d24f01493..f26a6ac2c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { + 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", diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 5d9e4424f..c182a22ae 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -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" diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index d40ecc9c9..4048e7219 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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 diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs new file mode 100644 index 000000000..36737598c --- /dev/null +++ b/crates/fonts/src/script.rs @@ -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>, + pub width: f64, + pub ascent: f64, + pub descent: f64, +} + +struct Flatten { + contours: Vec>, + 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()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 050d1c383..446814e07 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 18088e9b0..3eb283ed8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 27c912eeb..8aa3aa301 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 120a8ad99..8ec400e8c 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -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>), + Typed(String), +} + +/// The Create signature / initials dialog. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SigDraft { + pub strokes: Vec>, + 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 { + 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 { + 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> = 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 = 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>>, + signature: Option<&SavedSig>, + initials: Option<&SavedSig>, author: &str, today: (i64, u32, u32), ) -> Option { @@ -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), /// 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>) -> (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 = 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; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c62c6ee17..0aa5aacf5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -349,10 +349,12 @@ pub struct PrintCraftApp { pub export_draft: export_ui::ExportDraft, /// A running export's progress. export_status: Option, - /// The saved Fill & Sign signature (strokes normalised to the pad width, y up). - pub signature: Option>>, - /// Strokes being drawn in the signature pad. - pub signature_draft: Vec>, + /// The saved Fill & Sign signature and initials (drawn or typed). + pub signature: Option, + pub initials: Option, + /// The Create signature / initials dialog, and its typed preview. + pub signature_draft: fill_sign::SigDraft, + pub(crate) signature_preview: Option<(String, egui::TextureHandle)>, /// The Comment Properties dialog's state. pub comment_props: Option, pub field_props: Option, @@ -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::>(), "trusted": trusted, @@ -775,7 +782,13 @@ impl PrintCraftApp { if let Ok(s) = serde_json::from_value::>>(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::(v["initials"].clone()) { + self.initials = Some(i); } if let Ok(ids) = serde_json::from_value::>(v["digital_ids"].clone()) { self.digital_ids = ids; diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index f848466e8..19613bb6b 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -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()))); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 731945e82..9087c783e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"