From cc344a26dedecae5fbe5d58bb39c53a35c459c58 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sun, 11 Oct 2026 11:20:11 -0600 Subject: [PATCH] Typed signatures draw Cyrillic and Greek letters (#846) (#963) Dancing Script, the typed-signature face, is Latin and Vietnamese only, so script_outline skipped every Cyrillic or Greek character and a Serbian Cyrillic name came out empty. Characters the script lacks are now drawn from the bundled Inter Regular, slanted about 12 degrees and scaled to the script's capital height. Characters neither face has are still skipped. Inter's bytes are now exported as pdfcraft_fonts::INTER_REGULAR and the UI uses that static, so the font is still embedded once. Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 2 +- crates/fonts/src/lib.rs | 2 +- crates/fonts/src/script.rs | 75 ++++++++++++++++++++++++++++++++----- crates/ui-egui/src/theme.rs | 3 +- 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8a2fefbbd..f4974b09a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -199,7 +199,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | +| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths | | `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 055d858cb..61a6fcbe7 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2119,7 +2119,7 @@ source = "https://github.com/rsms/inter" licence = "OFL-1.1" licence_file = "assets/fonts/OFL-Inter.txt" kind = "font" -usage = "UI font, embedded in the app; demo PDF labels" +usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths" sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82" [[asset]] diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ee94566d0..75ebe6d3b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -15,7 +15,7 @@ pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts, }; -pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline}; +pub use script::{GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline}; /// Approximate advance of `s` in Helvetica (or Arial) at `size` points. pub fn helvetica_width(s: &str, size: f64) -> f64 { diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index 04695ebea..5fee1009d 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -6,6 +6,13 @@ use skrifa::{FontRef, GlyphId, MetadataProvider}; static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf"); +/// Inter Regular, the interface font. Typed signatures fall back to it, slanted, for characters +/// the script font lacks (Dancing Script is Latin and Vietnamese only: no Cyrillic or Greek). +pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + +/// Forward slant of fallback glyphs in a typed signature (about 12°), so they lean like the script. +const FALLBACK_SLANT: f64 = 0.21; + /// Bound signature work while allowing long personal names. pub const MAX_SIGNATURE_CHARS: usize = 256; @@ -51,6 +58,8 @@ struct Flatten { contours: Vec>, cur: Vec<[f64; 2]>, scale: f64, + /// Horizontal shear applied to every point (x += slant × y), for slanted fallback glyphs. + slant: f64, dx: f64, points: usize, too_complex: bool, @@ -59,11 +68,12 @@ struct Flatten { impl Flatten { fn new(scale: f64) -> Self { - Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false, max_points: 4096 } + Self { contours: Vec::new(), cur: Vec::new(), scale, slant: 0.0, dx: 0.0, points: 0, too_complex: false, max_points: 4096 } } fn pt(&self, x: f32, y: f32) -> [f64; 2] { - [self.dx + x as f64 * self.scale, y as f64 * self.scale] + let (x, y) = (x as f64 * self.scale, y as f64 * self.scale); + [self.dx + x + self.slant * y, y] } fn last(&self) -> [f64; 2] { @@ -178,8 +188,9 @@ pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Resul Ok(GlyphOutline { contours: pen.contours, width, bbox }) } -/// The outlines of `text` in the script font (characters it lacks are skipped). -/// Over-limit input returns an empty outline instead of a silently truncated signature. +/// The outlines of `text` in the script font. Characters it lacks (Cyrillic, Greek, …) are drawn +/// from Inter, slanted and scaled to the script's capital height; characters neither font has are +/// skipped. Over-limit input returns an empty outline instead of a silently truncated signature. pub fn script_outline(text: &str) -> ScriptOutline { if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS { return ScriptOutline::default(); @@ -191,17 +202,39 @@ pub fn script_outline(text: &str) -> ScriptOutline { let charmap = font.charmap(); let glyphs = font.outline_glyphs(); let advances = font.glyph_metrics(Size::unscaled(), loc); + // The fallback face, scaled so its capitals are as tall as the script's. + let fallback = FontRef::new(INTER_REGULAR).ok().map(|f| { + let m = f.metrics(Size::unscaled(), loc); + let em = 1.0 / m.units_per_em.max(1) as f64; + let ratio = match (metrics.cap_height, m.cap_height) { + (Some(ours), Some(theirs)) if ours > 0.0 && theirs > 0.0 => (ours as f64 * scale) / (theirs as f64 * em), + _ => 1.0, + }; + let ratio = if ratio.is_finite() { ratio.clamp(0.5, 2.0) } else { 1.0 }; + (f.charmap(), f.outline_glyphs(), f.glyph_metrics(Size::unscaled(), loc), em * ratio) + }); let mut pen = Flatten::new(scale); // The 4096-point budget is for one untrusted document glyph (japanese_glyph), not a // whole signature in our bundled font. Keep a separate bounded budget for the line. pen.max_points = 262_144; 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(); + if let Some(gid) = charmap.map(ch) { + (pen.scale, pen.slant) = (scale, 0.0); + 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; + } else if let Some((map, outlines, adv, em)) = &fallback + && let Some(gid) = map.map(ch) + { + (pen.scale, pen.slant) = (*em, FALLBACK_SLANT); + if let Some(g) = outlines.get(gid) { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen); + pen.close(); + } + pen.dx += adv.advance_width(gid).unwrap_or(0.0) as f64 * em; } - pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale; } pen.close(); if pen.too_complex { @@ -224,6 +257,30 @@ mod tests { assert!(super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS + 1)).contours.is_empty()); } + /// Issue #846: Cyrillic (and Greek) names come out of the fallback face instead of vanishing. + #[test] + fn cyrillic_and_greek_names_are_drawn_from_the_fallback_face() { + for name in ["Саша Дермановић", "Љубица Ђорђевић", "Ђурђа Џаковић", "Νίκος"] { + let o = super::script_outline(name); + let letters = name.chars().filter(|c| !c.is_whitespace()).count(); + assert!(o.contours.len() >= letters, "{name}: {} contours for {letters} letters", o.contours.len()); + for ch in name.chars().filter(|c| !c.is_whitespace()) { + let one = super::script_outline(&ch.to_string()); + assert!(!one.contours.is_empty() && one.width > 0.0, "{name}: {ch} has no outline"); + } + let [_, bottom, right, top] = o.bounds(); + assert!(right <= o.width + 0.5 && top <= o.ascent + 0.2 && bottom >= o.descent - 0.2, "{name}: {:?}", o.bounds()); + } + // Capitals from the fallback are about as tall as the script's. + let cap = |c: &str| super::script_outline(c).contours.iter().flatten().map(|p| p[1]).fold(0.0, f64::max); + let (latin, cyrillic) = (cap("H"), cap("Н")); + assert!((cyrillic / latin - 1.0).abs() < 0.2, "Н {cyrillic} vs H {latin}"); + // Mixed names keep every letter, in order: the Cyrillic follows the Latin. + let mixed = super::script_outline("Ana Ана"); + let latin_only = super::script_outline("Ana "); + assert!(mixed.width > latin_only.width + 0.5, "{} vs {}", mixed.width, latin_only.width); + } + #[test] fn a_name_becomes_outlines() { let o = super::script_outline("Ada L."); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 1691f8328..be63a62af 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -177,7 +177,8 @@ pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions { let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); }; - add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")); + // Shared with typed signatures' fallback glyphs, so the font is embedded once. + add(&mut fonts, "Inter", pdfcraft_fonts::INTER_REGULAR); add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")); add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")); add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));