Skip to main content

forme/font/
mod.rs

1//! # Font Management
2//!
3//! Loading, parsing, and subsetting fonts for PDF embedding.
4//!
5//! For v1, we support the 14 standard PDF fonts (Helvetica, Times, Courier, etc.)
6//! which don't require embedding. Custom font support via ttf-parser comes next.
7
8pub mod builtin;
9pub mod fallback;
10pub mod metrics;
11pub mod subset;
12
13pub use metrics::{unicode_to_winansi, StandardFontMetrics};
14use std::collections::HashMap;
15
16/// A font registry that maps font family + weight + style to font data.
17pub struct FontRegistry {
18    fonts: HashMap<FontKey, FontData>,
19}
20
21#[derive(Debug, Clone, Hash, PartialEq, Eq)]
22pub struct FontKey {
23    pub family: String,
24    pub weight: u32,
25    pub italic: bool,
26}
27
28#[derive(Debug, Clone)]
29pub enum FontData {
30    /// One of the 14 standard PDF fonts. No embedding needed.
31    Standard(StandardFont),
32    /// A TrueType/OpenType font that needs to be embedded.
33    Custom {
34        data: Vec<u8>,
35        /// Glyph IDs that are actually used (for subsetting).
36        used_glyphs: Vec<u16>,
37        /// Parsed metrics from ttf-parser, if available.
38        metrics: Option<CustomFontMetrics>,
39    },
40}
41
42/// Parsed metrics from a TrueType/OpenType font via ttf-parser.
43#[derive(Debug, Clone)]
44pub struct CustomFontMetrics {
45    pub units_per_em: u16,
46    pub advance_widths: HashMap<char, u16>,
47    pub default_advance: u16,
48    pub ascender: i16,
49    pub descender: i16,
50    /// Maps characters to their glyph IDs in the original font.
51    pub glyph_ids: HashMap<char, u16>,
52}
53
54impl CustomFontMetrics {
55    /// Get the advance width of a character in points.
56    pub fn char_width(&self, ch: char, font_size: f64) -> f64 {
57        let w = self
58            .advance_widths
59            .get(&ch)
60            .copied()
61            .unwrap_or(self.default_advance);
62        (w as f64 / self.units_per_em as f64) * font_size
63    }
64
65    /// Parse metrics from font data using ttf-parser.
66    pub fn from_font_data(data: &[u8]) -> Option<Self> {
67        let face = ttf_parser::Face::parse(data, 0).ok()?;
68        let units_per_em = face.units_per_em();
69        let ascender = face.ascender();
70        let descender = face.descender();
71
72        let mut advance_widths = HashMap::new();
73        let mut glyph_ids = HashMap::new();
74        let mut default_advance = 0u16;
75
76        // Sample common characters to build width and glyph ID maps
77        for code in 32u32..=0xFFFF {
78            if let Some(ch) = char::from_u32(code) {
79                if let Some(glyph_id) = face.glyph_index(ch) {
80                    let advance = face.glyph_hor_advance(glyph_id).unwrap_or(0);
81                    advance_widths.insert(ch, advance);
82                    glyph_ids.insert(ch, glyph_id.0);
83                    if ch == ' ' {
84                        default_advance = advance;
85                    }
86                }
87            }
88        }
89
90        if default_advance == 0 {
91            default_advance = units_per_em / 2;
92        }
93
94        Some(CustomFontMetrics {
95            units_per_em,
96            advance_widths,
97            default_advance,
98            ascender,
99            descender,
100            glyph_ids,
101        })
102    }
103}
104
105/// The 14 standard PDF fonts.
106#[derive(Debug, Clone, Copy)]
107pub enum StandardFont {
108    Helvetica,
109    HelveticaBold,
110    HelveticaOblique,
111    HelveticaBoldOblique,
112    TimesRoman,
113    TimesBold,
114    TimesItalic,
115    TimesBoldItalic,
116    Courier,
117    CourierBold,
118    CourierOblique,
119    CourierBoldOblique,
120    Symbol,
121    ZapfDingbats,
122}
123
124impl FontData {
125    /// Check whether this font has a glyph for the given character.
126    pub fn has_char(&self, ch: char) -> bool {
127        match self {
128            FontData::Custom {
129                metrics: Some(m), ..
130            } => m.glyph_ids.contains_key(&ch),
131            FontData::Custom { metrics: None, .. } => false,
132            FontData::Standard(_) => {
133                unicode_to_winansi(ch).is_some() || (ch as u32) >= 32 && (ch as u32) <= 255
134            }
135        }
136    }
137}
138
139impl StandardFont {
140    /// The PDF name for this font.
141    pub fn pdf_name(&self) -> &'static str {
142        match self {
143            Self::Helvetica => "Helvetica",
144            Self::HelveticaBold => "Helvetica-Bold",
145            Self::HelveticaOblique => "Helvetica-Oblique",
146            Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
147            Self::TimesRoman => "Times-Roman",
148            Self::TimesBold => "Times-Bold",
149            Self::TimesItalic => "Times-Italic",
150            Self::TimesBoldItalic => "Times-BoldItalic",
151            Self::Courier => "Courier",
152            Self::CourierBold => "Courier-Bold",
153            Self::CourierOblique => "Courier-Oblique",
154            Self::CourierBoldOblique => "Courier-BoldOblique",
155            Self::Symbol => "Symbol",
156            Self::ZapfDingbats => "ZapfDingbats",
157        }
158    }
159}
160
161impl Default for FontRegistry {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl FontRegistry {
168    pub fn new() -> Self {
169        let mut fonts = HashMap::new();
170
171        let standard_mappings = vec![
172            (("Helvetica", 400, false), StandardFont::Helvetica),
173            (("Helvetica", 700, false), StandardFont::HelveticaBold),
174            (("Helvetica", 400, true), StandardFont::HelveticaOblique),
175            (("Helvetica", 700, true), StandardFont::HelveticaBoldOblique),
176            (("Times", 400, false), StandardFont::TimesRoman),
177            (("Times", 700, false), StandardFont::TimesBold),
178            (("Times", 400, true), StandardFont::TimesItalic),
179            (("Times", 700, true), StandardFont::TimesBoldItalic),
180            (("Courier", 400, false), StandardFont::Courier),
181            (("Courier", 700, false), StandardFont::CourierBold),
182            (("Courier", 400, true), StandardFont::CourierOblique),
183            (("Courier", 700, true), StandardFont::CourierBoldOblique),
184        ];
185
186        for ((family, weight, italic), font) in standard_mappings {
187            fonts.insert(
188                FontKey {
189                    family: family.to_string(),
190                    weight,
191                    italic,
192                },
193                FontData::Standard(font),
194            );
195        }
196
197        let mut registry = Self { fonts };
198        builtin::register_builtin_fonts(&mut registry);
199        registry
200    }
201
202    /// Look up a font by family name (or comma-separated fallback chain),
203    /// falling back to Helvetica if none match.
204    ///
205    /// Supports CSS-style font family lists: `"Inter, Helvetica"` tries Inter
206    /// first, then Helvetica. Quoted families are unquoted automatically.
207    pub fn resolve(&self, families: &str, weight: u32, italic: bool) -> &FontData {
208        let snapped_weight = if weight >= 600 { 700 } else { 400 };
209
210        for family in families.split(',') {
211            let family = family.trim().trim_matches('"').trim_matches('\'');
212            if family.is_empty() {
213                continue;
214            }
215
216            // Try exact weight
217            let key = FontKey {
218                family: family.to_string(),
219                weight,
220                italic,
221            };
222            if let Some(font) = self.fonts.get(&key) {
223                return font;
224            }
225
226            // Try with normalized weight (snap to 400 or 700)
227            let key = FontKey {
228                family: family.to_string(),
229                weight: snapped_weight,
230                italic,
231            };
232            if let Some(font) = self.fonts.get(&key) {
233                return font;
234            }
235
236            // Try opposite weight (400 if bold requested, 700 if regular requested)
237            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
238            let key = FontKey {
239                family: family.to_string(),
240                weight: opposite_weight,
241                italic,
242            };
243            if let Some(font) = self.fonts.get(&key) {
244                return font;
245            }
246        }
247
248        // Final fallback: Helvetica
249        let key = FontKey {
250            family: "Helvetica".to_string(),
251            weight: snapped_weight,
252            italic,
253        };
254        self.fonts.get(&key).unwrap_or_else(|| {
255            self.fonts
256                .get(&FontKey {
257                    family: "Helvetica".to_string(),
258                    weight: 400,
259                    italic: false,
260                })
261                .expect("Helvetica must be registered")
262        })
263    }
264
265    /// Resolve a font for a specific character from a comma-separated fallback chain.
266    ///
267    /// Walks the families in order, returning the first font that has a glyph for `ch`.
268    /// Falls back to Helvetica if no font covers the character.
269    /// Returns a tuple of (font_data, resolved_single_family_name).
270    pub fn resolve_for_char(
271        &self,
272        families: &str,
273        ch: char,
274        weight: u32,
275        italic: bool,
276    ) -> (&FontData, String) {
277        let snapped_weight = if weight >= 600 { 700 } else { 400 };
278
279        for family in families.split(',') {
280            let family = family.trim().trim_matches('"').trim_matches('\'');
281            if family.is_empty() {
282                continue;
283            }
284
285            // Try exact weight
286            let key = FontKey {
287                family: family.to_string(),
288                weight,
289                italic,
290            };
291            if let Some(font) = self.fonts.get(&key) {
292                if font.has_char(ch) {
293                    return (font, family.to_string());
294                }
295            }
296
297            // Try with normalized weight
298            let key = FontKey {
299                family: family.to_string(),
300                weight: snapped_weight,
301                italic,
302            };
303            if let Some(font) = self.fonts.get(&key) {
304                if font.has_char(ch) {
305                    return (font, family.to_string());
306                }
307            }
308
309            // Try opposite weight (400 if bold requested, 700 if regular requested)
310            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
311            let key = FontKey {
312                family: family.to_string(),
313                weight: opposite_weight,
314                italic,
315            };
316            if let Some(font) = self.fonts.get(&key) {
317                if font.has_char(ch) {
318                    return (font, family.to_string());
319                }
320            }
321        }
322
323        // Try builtin Unicode font (Noto Sans) before Helvetica
324        let builtin_key = FontKey {
325            family: "Noto Sans".to_string(),
326            weight: snapped_weight,
327            italic: false,
328        };
329        if let Some(font) = self.fonts.get(&builtin_key) {
330            if font.has_char(ch) {
331                return (font, "Noto Sans".to_string());
332            }
333        }
334
335        // Final fallback: Helvetica
336        let key = FontKey {
337            family: "Helvetica".to_string(),
338            weight: snapped_weight,
339            italic,
340        };
341        let font = self.fonts.get(&key).unwrap_or_else(|| {
342            self.fonts
343                .get(&FontKey {
344                    family: "Helvetica".to_string(),
345                    weight: 400,
346                    italic: false,
347                })
348                .expect("Helvetica must be registered")
349        });
350        (font, "Helvetica".to_string())
351    }
352
353    /// Register a custom font.
354    pub fn register(&mut self, family: &str, weight: u32, italic: bool, data: Vec<u8>) {
355        let metrics = CustomFontMetrics::from_font_data(&data);
356        self.fonts.insert(
357            FontKey {
358                family: family.to_string(),
359                weight,
360                italic,
361            },
362            FontData::Custom {
363                data,
364                used_glyphs: Vec::new(),
365                metrics,
366            },
367        );
368    }
369
370    /// Iterate over all registered fonts.
371    pub fn iter(&self) -> impl Iterator<Item = (&FontKey, &FontData)> {
372        self.fonts.iter()
373    }
374}
375
376/// Shared font context used by layout and PDF serialization.
377/// Provides text measurement with real glyph metrics.
378pub struct FontContext {
379    registry: FontRegistry,
380}
381
382impl Default for FontContext {
383    fn default() -> Self {
384        Self::new()
385    }
386}
387
388impl FontContext {
389    pub fn new() -> Self {
390        Self {
391            registry: FontRegistry::new(),
392        }
393    }
394
395    /// Get the advance width of a single character in points.
396    ///
397    /// When `family` contains a comma (font fallback chain), resolves the
398    /// best font for this specific character before measuring.
399    pub fn char_width(
400        &self,
401        ch: char,
402        family: &str,
403        weight: u32,
404        italic: bool,
405        font_size: f64,
406    ) -> f64 {
407        // Fast path: single font family — try primary font first,
408        // fall back to per-char resolution only when the char isn't covered
409        let font_data = if !family.contains(',') {
410            let primary = self.registry.resolve(family, weight, italic);
411            if ch.is_whitespace() || primary.has_char(ch) {
412                primary
413            } else {
414                let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
415                data
416            }
417        } else {
418            let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
419            data
420        };
421        match font_data {
422            FontData::Standard(std_font) => std_font.metrics().char_width(ch, font_size),
423            FontData::Custom {
424                metrics: Some(m), ..
425            } => m.char_width(ch, font_size),
426            FontData::Custom { metrics: None, .. } => {
427                StandardFont::Helvetica.metrics().char_width(ch, font_size)
428            }
429        }
430    }
431
432    /// Measure the width of a string in points.
433    pub fn measure_string(
434        &self,
435        text: &str,
436        family: &str,
437        weight: u32,
438        italic: bool,
439        font_size: f64,
440        letter_spacing: f64,
441    ) -> f64 {
442        let font_data = self.registry.resolve(family, weight, italic);
443        match font_data {
444            FontData::Standard(std_font) => {
445                std_font
446                    .metrics()
447                    .measure_string(text, font_size, letter_spacing)
448            }
449            FontData::Custom {
450                metrics: Some(m), ..
451            } => {
452                let mut width = 0.0;
453                for ch in text.chars() {
454                    width += m.char_width(ch, font_size) + letter_spacing;
455                }
456                width
457            }
458            FontData::Custom { metrics: None, .. } => StandardFont::Helvetica
459                .metrics()
460                .measure_string(text, font_size, letter_spacing),
461        }
462    }
463
464    /// Resolve a font key to its font data.
465    pub fn resolve(&self, family: &str, weight: u32, italic: bool) -> &FontData {
466        self.registry.resolve(family, weight, italic)
467    }
468
469    /// Access the underlying font registry.
470    pub fn registry(&self) -> &FontRegistry {
471        &self.registry
472    }
473
474    /// Access the underlying font registry mutably.
475    pub fn registry_mut(&mut self) -> &mut FontRegistry {
476        &mut self.registry
477    }
478
479    /// Get the raw font data bytes for a custom font.
480    /// Returns `None` for standard fonts or if the font isn't found.
481    pub fn font_data(&self, family: &str, weight: u32, italic: bool) -> Option<&[u8]> {
482        let font_data = self.registry.resolve(family, weight, italic);
483        match font_data {
484            FontData::Custom { data, .. } => Some(data),
485            FontData::Standard(_) => None,
486        }
487    }
488
489    /// Get the units-per-em for a font. Returns 1000 for standard fonts.
490    pub fn units_per_em(&self, family: &str, weight: u32, italic: bool) -> u16 {
491        let font_data = self.registry.resolve(family, weight, italic);
492        match font_data {
493            FontData::Custom {
494                metrics: Some(m), ..
495            } => m.units_per_em,
496            FontData::Custom { metrics: None, .. } => 1000,
497            FontData::Standard(_) => 1000,
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_font_context_helvetica() {
508        let ctx = FontContext::new();
509        let w = ctx.char_width(' ', "Helvetica", 400, false, 12.0);
510        assert!((w - 3.336).abs() < 0.001);
511    }
512
513    #[test]
514    fn test_font_context_bold_wider() {
515        let ctx = FontContext::new();
516        let regular = ctx.char_width('A', "Helvetica", 400, false, 12.0);
517        let bold = ctx.char_width('A', "Helvetica", 700, false, 12.0);
518        assert!(bold > regular, "Bold A should be wider than regular A");
519    }
520
521    #[test]
522    fn test_font_context_measure_string() {
523        let ctx = FontContext::new();
524        let w = ctx.measure_string("Hello", "Helvetica", 400, false, 12.0, 0.0);
525        assert!(w > 0.0);
526    }
527
528    #[test]
529    fn test_font_context_fallback() {
530        let ctx = FontContext::new();
531        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
532        let w2 = ctx.char_width('A', "UnknownFont", 400, false, 12.0);
533        assert!((w1 - w2).abs() < 0.001);
534    }
535
536    #[test]
537    fn test_font_context_weight_resolution() {
538        let ctx = FontContext::new();
539        let w700 = ctx.char_width('A', "Helvetica", 700, false, 12.0);
540        let w800 = ctx.char_width('A', "Helvetica", 800, false, 12.0);
541        assert!((w700 - w800).abs() < 0.001);
542    }
543
544    #[test]
545    fn test_font_fallback_chain_first_match() {
546        let ctx = FontContext::new();
547        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
548        let w2 = ctx.char_width('A', "Times, Helvetica", 400, false, 12.0);
549        assert!((w1 - w2).abs() < 0.001, "Should use Times (first in chain)");
550    }
551
552    #[test]
553    fn test_font_fallback_chain_second_match() {
554        let ctx = FontContext::new();
555        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
556        let w2 = ctx.char_width('A', "Missing, Helvetica", 400, false, 12.0);
557        assert!((w1 - w2).abs() < 0.001, "Should fall back to Helvetica");
558    }
559
560    #[test]
561    fn test_font_fallback_chain_all_missing() {
562        let ctx = FontContext::new();
563        // When all specified families are missing, resolve_for_char tries
564        // builtin Noto Sans first, then Helvetica. 'A' is in Noto Sans,
565        // so we get Noto Sans metrics (not Helvetica).
566        let w = ctx.char_width('A', "Missing, AlsoMissing", 400, false, 12.0);
567        assert!(w > 0.0, "Should still produce a valid width from fallback");
568    }
569
570    #[test]
571    fn test_font_fallback_chain_quoted_families() {
572        let ctx = FontContext::new();
573        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
574        let w2 = ctx.char_width('A', "'Times', \"Helvetica\"", 400, false, 12.0);
575        assert!((w1 - w2).abs() < 0.001, "Should strip quotes and use Times");
576    }
577
578    #[test]
579    fn test_builtin_noto_sans_registered() {
580        let registry = FontRegistry::new();
581        let font = registry.resolve("Noto Sans", 400, false);
582        assert!(
583            matches!(font, FontData::Custom { .. }),
584            "Noto Sans should be registered as a custom font"
585        );
586        assert!(
587            font.has_char('\u{041F}'),
588            "Noto Sans should have Cyrillic П"
589        );
590        assert!(font.has_char('\u{03B1}'), "Noto Sans should have Greek α");
591    }
592
593    #[test]
594    fn test_builtin_noto_sans_fallback_for_cyrillic() {
595        let registry = FontRegistry::new();
596        let (font, family) = registry.resolve_for_char("Helvetica", '\u{041F}', 400, false);
597        assert_eq!(
598            family, "Noto Sans",
599            "Cyrillic should fall back to Noto Sans"
600        );
601        assert!(matches!(font, FontData::Custom { .. }));
602    }
603
604    #[test]
605    fn test_font_fallback_single_family_unchanged() {
606        let ctx = FontContext::new();
607        let w1 = ctx.char_width('A', "Courier", 400, false, 12.0);
608        let w2 = ctx.char_width('A', "Courier", 400, false, 12.0);
609        assert!(
610            (w1 - w2).abs() < 0.001,
611            "Single family should work as before"
612        );
613    }
614}