diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8a2fefbbd9..f4974b09a0 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 055d858cbf..61a6fcbe78 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 ee94566d02..75ebe6d3b4 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 04695ebead..5fee1009d9 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 1691f8328b..be63a62af5 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"));