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, winansi_to_char, 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 Liberation family that is metric-compatible with this base-14 font,
141    /// used for PDF/UA + PDF/A embedding (the base-14 fonts are not embeddable;
142    /// Liberation is, and shares their metrics). Symbol and ZapfDingbats have
143    /// no metric-compatible substitute.
144    pub fn liberation_family(&self) -> Option<&'static str> {
145        match self {
146            Self::Helvetica
147            | Self::HelveticaBold
148            | Self::HelveticaOblique
149            | Self::HelveticaBoldOblique => Some("Liberation Sans"),
150            Self::TimesRoman | Self::TimesBold | Self::TimesItalic | Self::TimesBoldItalic => {
151                Some("Liberation Serif")
152            }
153            Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique => {
154                Some("Liberation Mono")
155            }
156            Self::Symbol | Self::ZapfDingbats => None,
157        }
158    }
159
160    /// PDF FontDescriptor `/Flags` for the metric-compatible substitute:
161    /// Nonsymbolic (32), plus Serif (2) or FixedPitch (1) as appropriate.
162    pub fn descriptor_flags(&self) -> u32 {
163        match self {
164            Self::TimesRoman | Self::TimesBold | Self::TimesItalic | Self::TimesBoldItalic => {
165                32 | 2
166            }
167            Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique => {
168                32 | 1
169            }
170            _ => 32,
171        }
172    }
173
174    /// The PDF name for this font.
175    pub fn pdf_name(&self) -> &'static str {
176        match self {
177            Self::Helvetica => "Helvetica",
178            Self::HelveticaBold => "Helvetica-Bold",
179            Self::HelveticaOblique => "Helvetica-Oblique",
180            Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
181            Self::TimesRoman => "Times-Roman",
182            Self::TimesBold => "Times-Bold",
183            Self::TimesItalic => "Times-Italic",
184            Self::TimesBoldItalic => "Times-BoldItalic",
185            Self::Courier => "Courier",
186            Self::CourierBold => "Courier-Bold",
187            Self::CourierOblique => "Courier-Oblique",
188            Self::CourierBoldOblique => "Courier-BoldOblique",
189            Self::Symbol => "Symbol",
190            Self::ZapfDingbats => "ZapfDingbats",
191        }
192    }
193}
194
195impl Default for FontRegistry {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl FontRegistry {
202    pub fn new() -> Self {
203        let mut fonts = HashMap::new();
204
205        let standard_mappings = vec![
206            (("Helvetica", 400, false), StandardFont::Helvetica),
207            (("Helvetica", 700, false), StandardFont::HelveticaBold),
208            (("Helvetica", 400, true), StandardFont::HelveticaOblique),
209            (("Helvetica", 700, true), StandardFont::HelveticaBoldOblique),
210            (("Times", 400, false), StandardFont::TimesRoman),
211            (("Times", 700, false), StandardFont::TimesBold),
212            (("Times", 400, true), StandardFont::TimesItalic),
213            (("Times", 700, true), StandardFont::TimesBoldItalic),
214            (("Courier", 400, false), StandardFont::Courier),
215            (("Courier", 700, false), StandardFont::CourierBold),
216            (("Courier", 400, true), StandardFont::CourierOblique),
217            (("Courier", 700, true), StandardFont::CourierBoldOblique),
218        ];
219
220        for ((family, weight, italic), font) in standard_mappings {
221            fonts.insert(
222                FontKey {
223                    family: family.to_string(),
224                    weight,
225                    italic,
226                },
227                FontData::Standard(font),
228            );
229        }
230
231        let mut registry = Self { fonts };
232        builtin::register_builtin_fonts(&mut registry);
233        registry
234    }
235
236    /// Look up a font by family name (or comma-separated fallback chain),
237    /// falling back to Helvetica if none match.
238    ///
239    /// Supports CSS-style font family lists: `"Inter, Helvetica"` tries Inter
240    /// first, then Helvetica. Quoted families are unquoted automatically.
241    pub fn resolve(&self, families: &str, weight: u32, italic: bool) -> &FontData {
242        let snapped_weight = if weight >= 600 { 700 } else { 400 };
243
244        for family in families.split(',') {
245            let family = family.trim().trim_matches('"').trim_matches('\'');
246            if family.is_empty() {
247                continue;
248            }
249
250            // Try exact weight
251            let key = FontKey {
252                family: family.to_string(),
253                weight,
254                italic,
255            };
256            if let Some(font) = self.fonts.get(&key) {
257                return font;
258            }
259
260            // Try with normalized weight (snap to 400 or 700)
261            let key = FontKey {
262                family: family.to_string(),
263                weight: snapped_weight,
264                italic,
265            };
266            if let Some(font) = self.fonts.get(&key) {
267                return font;
268            }
269
270            // Try opposite weight (400 if bold requested, 700 if regular requested)
271            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
272            let key = FontKey {
273                family: family.to_string(),
274                weight: opposite_weight,
275                italic,
276            };
277            if let Some(font) = self.fonts.get(&key) {
278                return font;
279            }
280        }
281
282        // Final fallback: Helvetica
283        let key = FontKey {
284            family: "Helvetica".to_string(),
285            weight: snapped_weight,
286            italic,
287        };
288        self.fonts.get(&key).unwrap_or_else(|| {
289            self.fonts
290                .get(&FontKey {
291                    family: "Helvetica".to_string(),
292                    weight: 400,
293                    italic: false,
294                })
295                .expect("Helvetica must be registered")
296        })
297    }
298
299    /// Resolve a font for a specific character from a comma-separated fallback chain.
300    ///
301    /// Walks the families in order, returning the first font that has a glyph for `ch`.
302    /// Falls back to Helvetica if no font covers the character.
303    /// Returns a tuple of (font_data, resolved_single_family_name).
304    pub fn resolve_for_char(
305        &self,
306        families: &str,
307        ch: char,
308        weight: u32,
309        italic: bool,
310    ) -> (&FontData, String) {
311        let snapped_weight = if weight >= 600 { 700 } else { 400 };
312
313        for family in families.split(',') {
314            let family = family.trim().trim_matches('"').trim_matches('\'');
315            if family.is_empty() {
316                continue;
317            }
318
319            // Try exact weight
320            let key = FontKey {
321                family: family.to_string(),
322                weight,
323                italic,
324            };
325            if let Some(font) = self.fonts.get(&key) {
326                if font.has_char(ch) {
327                    return (font, family.to_string());
328                }
329            }
330
331            // Try with normalized weight
332            let key = FontKey {
333                family: family.to_string(),
334                weight: snapped_weight,
335                italic,
336            };
337            if let Some(font) = self.fonts.get(&key) {
338                if font.has_char(ch) {
339                    return (font, family.to_string());
340                }
341            }
342
343            // Try opposite weight (400 if bold requested, 700 if regular requested)
344            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
345            let key = FontKey {
346                family: family.to_string(),
347                weight: opposite_weight,
348                italic,
349            };
350            if let Some(font) = self.fonts.get(&key) {
351                if font.has_char(ch) {
352                    return (font, family.to_string());
353                }
354            }
355        }
356
357        // Try builtin Unicode font (Noto Sans) before Helvetica
358        let builtin_key = FontKey {
359            family: "Noto Sans".to_string(),
360            weight: snapped_weight,
361            italic: false,
362        };
363        if let Some(font) = self.fonts.get(&builtin_key) {
364            if font.has_char(ch) {
365                return (font, "Noto Sans".to_string());
366            }
367        }
368
369        // Final fallback: Helvetica
370        let key = FontKey {
371            family: "Helvetica".to_string(),
372            weight: snapped_weight,
373            italic,
374        };
375        let font = self.fonts.get(&key).unwrap_or_else(|| {
376            self.fonts
377                .get(&FontKey {
378                    family: "Helvetica".to_string(),
379                    weight: 400,
380                    italic: false,
381                })
382                .expect("Helvetica must be registered")
383        });
384        (font, "Helvetica".to_string())
385    }
386
387    /// Register a custom font.
388    pub fn register(&mut self, family: &str, weight: u32, italic: bool, data: Vec<u8>) {
389        let metrics = CustomFontMetrics::from_font_data(&data);
390        self.fonts.insert(
391            FontKey {
392                family: family.to_string(),
393                weight,
394                italic,
395            },
396            FontData::Custom {
397                data,
398                used_glyphs: Vec::new(),
399                metrics,
400            },
401        );
402    }
403
404    /// Iterate over all registered fonts.
405    pub fn iter(&self) -> impl Iterator<Item = (&FontKey, &FontData)> {
406        self.fonts.iter()
407    }
408}
409
410/// Shared font context used by layout and PDF serialization.
411/// Provides text measurement with real glyph metrics.
412pub struct FontContext {
413    registry: FontRegistry,
414    /// Number of digits to use when measuring page number sentinel width.
415    /// Default 2 ("00"). Updated by the two-pass render loop after the
416    /// first layout reveals the actual page count.
417    sentinel_digit_count: u32,
418}
419
420impl Default for FontContext {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426impl FontContext {
427    pub fn new() -> Self {
428        Self {
429            registry: FontRegistry::new(),
430            sentinel_digit_count: 2,
431        }
432    }
433
434    /// Get the current sentinel digit count.
435    pub fn sentinel_digit_count(&self) -> u32 {
436        self.sentinel_digit_count
437    }
438
439    /// Set the number of digits used to measure page number sentinel width.
440    pub fn set_sentinel_digit_count(&mut self, count: u32) {
441        self.sentinel_digit_count = count;
442    }
443
444    /// Get the advance width of a single character in points.
445    ///
446    /// When `family` contains a comma (font fallback chain), resolves the
447    /// best font for this specific character before measuring.
448    pub fn char_width(
449        &self,
450        ch: char,
451        family: &str,
452        weight: u32,
453        italic: bool,
454        font_size: f64,
455    ) -> f64 {
456        // Page placeholder sentinels: measure as the width of N zeros
457        // where N = sentinel_digit_count (set by the two-pass render loop)
458        if ch == crate::layout::PAGE_NUMBER_SENTINEL || ch == crate::layout::TOTAL_PAGES_SENTINEL {
459            return self.char_width('0', family, weight, italic, font_size)
460                * self.sentinel_digit_count as f64;
461        }
462
463        // Fast path: single font family — try primary font first,
464        // fall back to per-char resolution only when the char isn't covered
465        let font_data = if !family.contains(',') {
466            let primary = self.registry.resolve(family, weight, italic);
467            if ch.is_whitespace() || primary.has_char(ch) {
468                primary
469            } else {
470                let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
471                data
472            }
473        } else {
474            let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
475            data
476        };
477        match font_data {
478            FontData::Standard(std_font) => std_font.metrics().char_width(ch, font_size),
479            FontData::Custom {
480                metrics: Some(m), ..
481            } => m.char_width(ch, font_size),
482            FontData::Custom { metrics: None, .. } => {
483                StandardFont::Helvetica.metrics().char_width(ch, font_size)
484            }
485        }
486    }
487
488    /// Measure the width of a string in points.
489    pub fn measure_string(
490        &self,
491        text: &str,
492        family: &str,
493        weight: u32,
494        italic: bool,
495        font_size: f64,
496        letter_spacing: f64,
497    ) -> f64 {
498        let mut width = 0.0;
499        for ch in text.chars() {
500            width += self.char_width(ch, family, weight, italic, font_size) + letter_spacing;
501        }
502        width
503    }
504
505    /// Resolve a font key to its font data.
506    pub fn resolve(&self, family: &str, weight: u32, italic: bool) -> &FontData {
507        self.registry.resolve(family, weight, italic)
508    }
509
510    /// Access the underlying font registry.
511    pub fn registry(&self) -> &FontRegistry {
512        &self.registry
513    }
514
515    /// Access the underlying font registry mutably.
516    pub fn registry_mut(&mut self) -> &mut FontRegistry {
517        &mut self.registry
518    }
519
520    /// Get the raw font data bytes for a custom font.
521    /// Returns `None` for standard fonts or if the font isn't found.
522    pub fn font_data(&self, family: &str, weight: u32, italic: bool) -> Option<&[u8]> {
523        let font_data = self.registry.resolve(family, weight, italic);
524        match font_data {
525            FontData::Custom { data, .. } => Some(data),
526            FontData::Standard(_) => None,
527        }
528    }
529
530    /// Get the units-per-em for a font. Returns 1000 for standard fonts.
531    pub fn units_per_em(&self, family: &str, weight: u32, italic: bool) -> u16 {
532        let font_data = self.registry.resolve(family, weight, italic);
533        match font_data {
534            FontData::Custom {
535                metrics: Some(m), ..
536            } => m.units_per_em,
537            FontData::Custom { metrics: None, .. } => 1000,
538            FontData::Standard(_) => 1000,
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_font_context_helvetica() {
549        let ctx = FontContext::new();
550        let w = ctx.char_width(' ', "Helvetica", 400, false, 12.0);
551        assert!((w - 3.336).abs() < 0.001);
552    }
553
554    #[test]
555    fn test_font_context_bold_wider() {
556        let ctx = FontContext::new();
557        let regular = ctx.char_width('A', "Helvetica", 400, false, 12.0);
558        let bold = ctx.char_width('A', "Helvetica", 700, false, 12.0);
559        assert!(bold > regular, "Bold A should be wider than regular A");
560    }
561
562    #[test]
563    fn test_font_context_measure_string() {
564        let ctx = FontContext::new();
565        let w = ctx.measure_string("Hello", "Helvetica", 400, false, 12.0, 0.0);
566        assert!(w > 0.0);
567    }
568
569    #[test]
570    fn test_font_context_fallback() {
571        let ctx = FontContext::new();
572        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
573        let w2 = ctx.char_width('A', "UnknownFont", 400, false, 12.0);
574        assert!((w1 - w2).abs() < 0.001);
575    }
576
577    #[test]
578    fn test_font_context_weight_resolution() {
579        let ctx = FontContext::new();
580        let w700 = ctx.char_width('A', "Helvetica", 700, false, 12.0);
581        let w800 = ctx.char_width('A', "Helvetica", 800, false, 12.0);
582        assert!((w700 - w800).abs() < 0.001);
583    }
584
585    #[test]
586    fn test_font_fallback_chain_first_match() {
587        let ctx = FontContext::new();
588        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
589        let w2 = ctx.char_width('A', "Times, Helvetica", 400, false, 12.0);
590        assert!((w1 - w2).abs() < 0.001, "Should use Times (first in chain)");
591    }
592
593    #[test]
594    fn test_font_fallback_chain_second_match() {
595        let ctx = FontContext::new();
596        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
597        let w2 = ctx.char_width('A', "Missing, Helvetica", 400, false, 12.0);
598        assert!((w1 - w2).abs() < 0.001, "Should fall back to Helvetica");
599    }
600
601    #[test]
602    fn test_font_fallback_chain_all_missing() {
603        let ctx = FontContext::new();
604        // When all specified families are missing, resolve_for_char tries
605        // builtin Noto Sans first, then Helvetica. 'A' is in Noto Sans,
606        // so we get Noto Sans metrics (not Helvetica).
607        let w = ctx.char_width('A', "Missing, AlsoMissing", 400, false, 12.0);
608        assert!(w > 0.0, "Should still produce a valid width from fallback");
609    }
610
611    #[test]
612    fn test_font_fallback_chain_quoted_families() {
613        let ctx = FontContext::new();
614        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
615        let w2 = ctx.char_width('A', "'Times', \"Helvetica\"", 400, false, 12.0);
616        assert!((w1 - w2).abs() < 0.001, "Should strip quotes and use Times");
617    }
618
619    #[test]
620    fn test_builtin_noto_sans_registered() {
621        let registry = FontRegistry::new();
622        let font = registry.resolve("Noto Sans", 400, false);
623        assert!(
624            matches!(font, FontData::Custom { .. }),
625            "Noto Sans should be registered as a custom font"
626        );
627        assert!(
628            font.has_char('\u{041F}'),
629            "Noto Sans should have Cyrillic П"
630        );
631        assert!(font.has_char('\u{03B1}'), "Noto Sans should have Greek α");
632    }
633
634    #[test]
635    fn test_builtin_noto_sans_fallback_for_cyrillic() {
636        let registry = FontRegistry::new();
637        let (font, family) = registry.resolve_for_char("Helvetica", '\u{041F}', 400, false);
638        assert_eq!(
639            family, "Noto Sans",
640            "Cyrillic should fall back to Noto Sans"
641        );
642        assert!(matches!(font, FontData::Custom { .. }));
643    }
644
645    #[test]
646    fn test_font_fallback_single_family_unchanged() {
647        let ctx = FontContext::new();
648        let w1 = ctx.char_width('A', "Courier", 400, false, 12.0);
649        let w2 = ctx.char_width('A', "Courier", 400, false, 12.0);
650        assert!(
651            (w1 - w2).abs() < 0.001,
652            "Single family should work as before"
653        );
654    }
655}