mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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 <DWAA1660@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
DWAA1660
Claude Opus 5.5
Brandon Thomas
parent
fb3fa5de57
commit
cc344a26de
+1
-1
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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]]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Vec<[f64; 2]>>,
|
||||
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.");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user