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;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17/// A font registry that maps font family + weight + style to font data.
18pub struct FontRegistry {
19    fonts: HashMap<FontKey, FontData>,
20}
21
22#[derive(Debug, Clone, Hash, PartialEq, Eq)]
23pub struct FontKey {
24    pub family: String,
25    pub weight: u32,
26    pub italic: bool,
27}
28
29#[derive(Debug, Clone)]
30pub enum FontData {
31    /// One of the 14 standard PDF fonts. No embedding needed.
32    Standard(StandardFont),
33    /// A TrueType/OpenType font that needs to be embedded.
34    Custom {
35        data: Vec<u8>,
36        /// Glyph IDs that are actually used (for subsetting).
37        used_glyphs: Vec<u16>,
38        /// Parsed metrics from ttf-parser, if available.
39        metrics: Option<CustomFontMetrics>,
40    },
41}
42
43/// Parsed metrics from a TrueType/OpenType font via ttf-parser.
44#[derive(Debug, Clone)]
45pub struct CustomFontMetrics {
46    pub units_per_em: u16,
47    pub advance_widths: HashMap<char, u16>,
48    pub default_advance: u16,
49    pub ascender: i16,
50    pub descender: i16,
51    /// Maps characters to their glyph IDs in the original font.
52    pub glyph_ids: HashMap<char, u16>,
53}
54
55impl CustomFontMetrics {
56    /// Get the advance width of a character in points.
57    pub fn char_width(&self, ch: char, font_size: f64) -> f64 {
58        let w = self
59            .advance_widths
60            .get(&ch)
61            .copied()
62            .unwrap_or(self.default_advance);
63        (w as f64 / self.units_per_em as f64) * font_size
64    }
65
66    /// Parse metrics from font data using ttf-parser.
67    pub fn from_font_data(data: &[u8]) -> Option<Self> {
68        let face = ttf_parser::Face::parse(data, 0).ok()?;
69        let units_per_em = face.units_per_em();
70        let ascender = face.ascender();
71        let descender = face.descender();
72
73        let mut advance_widths = HashMap::new();
74        let mut glyph_ids = HashMap::new();
75        let mut default_advance = 0u16;
76
77        // Sample common characters to build width and glyph ID maps
78        for code in 32u32..=0xFFFF {
79            if let Some(ch) = char::from_u32(code) {
80                if let Some(glyph_id) = face.glyph_index(ch) {
81                    let advance = face.glyph_hor_advance(glyph_id).unwrap_or(0);
82                    advance_widths.insert(ch, advance);
83                    glyph_ids.insert(ch, glyph_id.0);
84                    if ch == ' ' {
85                        default_advance = advance;
86                    }
87                }
88            }
89        }
90
91        if default_advance == 0 {
92            default_advance = units_per_em / 2;
93        }
94
95        Some(CustomFontMetrics {
96            units_per_em,
97            advance_widths,
98            default_advance,
99            ascender,
100            descender,
101            glyph_ids,
102        })
103    }
104}
105
106/// The 14 standard PDF fonts.
107#[derive(Debug, Clone, Copy)]
108pub enum StandardFont {
109    Helvetica,
110    HelveticaBold,
111    HelveticaOblique,
112    HelveticaBoldOblique,
113    TimesRoman,
114    TimesBold,
115    TimesItalic,
116    TimesBoldItalic,
117    Courier,
118    CourierBold,
119    CourierOblique,
120    CourierBoldOblique,
121    Symbol,
122    ZapfDingbats,
123}
124
125impl FontData {
126    /// Check whether this font has a glyph for the given character.
127    pub fn has_char(&self, ch: char) -> bool {
128        match self {
129            FontData::Custom {
130                metrics: Some(m), ..
131            } => m.glyph_ids.contains_key(&ch),
132            FontData::Custom { metrics: None, .. } => false,
133            FontData::Standard(_) => {
134                unicode_to_winansi(ch).is_some() || (ch as u32) >= 32 && (ch as u32) <= 255
135            }
136        }
137    }
138}
139
140impl StandardFont {
141    /// The Liberation family that is metric-compatible with this base-14 font,
142    /// used for PDF/UA + PDF/A embedding (the base-14 fonts are not embeddable;
143    /// Liberation is, and shares their metrics). Symbol and ZapfDingbats have
144    /// no metric-compatible substitute.
145    pub fn liberation_family(&self) -> Option<&'static str> {
146        match self {
147            Self::Helvetica
148            | Self::HelveticaBold
149            | Self::HelveticaOblique
150            | Self::HelveticaBoldOblique => Some("Liberation Sans"),
151            Self::TimesRoman | Self::TimesBold | Self::TimesItalic | Self::TimesBoldItalic => {
152                Some("Liberation Serif")
153            }
154            Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique => {
155                Some("Liberation Mono")
156            }
157            Self::Symbol | Self::ZapfDingbats => None,
158        }
159    }
160
161    /// PDF FontDescriptor `/Flags` for the metric-compatible substitute:
162    /// Nonsymbolic (32), plus Serif (2) or FixedPitch (1) as appropriate.
163    pub fn descriptor_flags(&self) -> u32 {
164        match self {
165            Self::TimesRoman | Self::TimesBold | Self::TimesItalic | Self::TimesBoldItalic => {
166                32 | 2
167            }
168            Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique => {
169                32 | 1
170            }
171            _ => 32,
172        }
173    }
174
175    /// The PDF name for this font.
176    pub fn pdf_name(&self) -> &'static str {
177        match self {
178            Self::Helvetica => "Helvetica",
179            Self::HelveticaBold => "Helvetica-Bold",
180            Self::HelveticaOblique => "Helvetica-Oblique",
181            Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
182            Self::TimesRoman => "Times-Roman",
183            Self::TimesBold => "Times-Bold",
184            Self::TimesItalic => "Times-Italic",
185            Self::TimesBoldItalic => "Times-BoldItalic",
186            Self::Courier => "Courier",
187            Self::CourierBold => "Courier-Bold",
188            Self::CourierOblique => "Courier-Oblique",
189            Self::CourierBoldOblique => "Courier-BoldOblique",
190            Self::Symbol => "Symbol",
191            Self::ZapfDingbats => "ZapfDingbats",
192        }
193    }
194}
195
196impl Default for FontRegistry {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl FontRegistry {
203    pub fn new() -> Self {
204        let mut fonts = HashMap::new();
205
206        let standard_mappings = vec![
207            (("Helvetica", 400, false), StandardFont::Helvetica),
208            (("Helvetica", 700, false), StandardFont::HelveticaBold),
209            (("Helvetica", 400, true), StandardFont::HelveticaOblique),
210            (("Helvetica", 700, true), StandardFont::HelveticaBoldOblique),
211            (("Times", 400, false), StandardFont::TimesRoman),
212            (("Times", 700, false), StandardFont::TimesBold),
213            (("Times", 400, true), StandardFont::TimesItalic),
214            (("Times", 700, true), StandardFont::TimesBoldItalic),
215            (("Courier", 400, false), StandardFont::Courier),
216            (("Courier", 700, false), StandardFont::CourierBold),
217            (("Courier", 400, true), StandardFont::CourierOblique),
218            (("Courier", 700, true), StandardFont::CourierBoldOblique),
219        ];
220
221        for ((family, weight, italic), font) in standard_mappings {
222            fonts.insert(
223                FontKey {
224                    family: family.to_string(),
225                    weight,
226                    italic,
227                },
228                FontData::Standard(font),
229            );
230        }
231
232        let mut registry = Self { fonts };
233        builtin::register_builtin_fonts(&mut registry);
234        registry
235    }
236
237    /// Look up a font by family name (or comma-separated fallback chain),
238    /// falling back to Helvetica if none match.
239    ///
240    /// Supports CSS-style font family lists: `"Inter, Helvetica"` tries Inter
241    /// first, then Helvetica. Quoted families are unquoted automatically.
242    pub fn resolve(&self, families: &str, weight: u32, italic: bool) -> &FontData {
243        let snapped_weight = if weight >= 600 { 700 } else { 400 };
244
245        for family in families.split(',') {
246            let family = family.trim().trim_matches('"').trim_matches('\'');
247            if family.is_empty() {
248                continue;
249            }
250
251            // Try exact weight
252            let key = FontKey {
253                family: family.to_string(),
254                weight,
255                italic,
256            };
257            if let Some(font) = self.fonts.get(&key) {
258                return font;
259            }
260
261            // Try with normalized weight (snap to 400 or 700)
262            let key = FontKey {
263                family: family.to_string(),
264                weight: snapped_weight,
265                italic,
266            };
267            if let Some(font) = self.fonts.get(&key) {
268                return font;
269            }
270
271            // Try opposite weight (400 if bold requested, 700 if regular requested)
272            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
273            let key = FontKey {
274                family: family.to_string(),
275                weight: opposite_weight,
276                italic,
277            };
278            if let Some(font) = self.fonts.get(&key) {
279                return font;
280            }
281        }
282
283        // Final fallback: Helvetica
284        let key = FontKey {
285            family: "Helvetica".to_string(),
286            weight: snapped_weight,
287            italic,
288        };
289        self.fonts.get(&key).unwrap_or_else(|| {
290            self.fonts
291                .get(&FontKey {
292                    family: "Helvetica".to_string(),
293                    weight: 400,
294                    italic: false,
295                })
296                .expect("Helvetica must be registered")
297        })
298    }
299
300    /// Resolve a font for a specific character from a comma-separated fallback chain.
301    ///
302    /// Walks the families in order, returning the first font that has a glyph for `ch`.
303    /// Falls back to Helvetica if no font covers the character.
304    /// Returns a tuple of (font_data, resolved_single_family_name).
305    pub fn resolve_for_char(
306        &self,
307        families: &str,
308        ch: char,
309        weight: u32,
310        italic: bool,
311    ) -> (&FontData, String) {
312        let snapped_weight = if weight >= 600 { 700 } else { 400 };
313
314        // Page-number sentinels (U+0002/U+0003) are pseudo-characters
315        // replaced by DIGITS at write time — no real font has a glyph for
316        // them, so a coverage walk would always shunt them into the final
317        // Helvetica fallback, splitting the surrounding text's font (and
318        // breaking PDF/A when that font is a registered custom one). They
319        // are font-neutral: take the first REGISTERED family in the chain,
320        // ignoring coverage. Known boundary: a registered font genuinely
321        // lacking digit glyphs still renders .notdef page numbers, like
322        // any other missing glyph.
323        if ch == crate::layout::PAGE_NUMBER_SENTINEL || ch == crate::layout::TOTAL_PAGES_SENTINEL {
324            for family in families.split(',') {
325                let family = family.trim().trim_matches('"').trim_matches('\'');
326                if family.is_empty() {
327                    continue;
328                }
329                for w in [
330                    weight,
331                    snapped_weight,
332                    if snapped_weight == 700 { 400 } else { 700 },
333                ] {
334                    let key = FontKey {
335                        family: family.to_string(),
336                        weight: w,
337                        italic,
338                    };
339                    if let Some(font) = self.fonts.get(&key) {
340                        return (font, family.to_string());
341                    }
342                }
343            }
344            // No family in the chain is registered: same terminal
345            // fallback as the coverage walk below.
346        }
347
348        for family in families.split(',') {
349            let family = family.trim().trim_matches('"').trim_matches('\'');
350            if family.is_empty() {
351                continue;
352            }
353
354            // Try exact weight
355            let key = FontKey {
356                family: family.to_string(),
357                weight,
358                italic,
359            };
360            if let Some(font) = self.fonts.get(&key) {
361                if font.has_char(ch) {
362                    return (font, family.to_string());
363                }
364            }
365
366            // Try with normalized weight
367            let key = FontKey {
368                family: family.to_string(),
369                weight: snapped_weight,
370                italic,
371            };
372            if let Some(font) = self.fonts.get(&key) {
373                if font.has_char(ch) {
374                    return (font, family.to_string());
375                }
376            }
377
378            // Try opposite weight (400 if bold requested, 700 if regular requested)
379            let opposite_weight = if snapped_weight == 700 { 400 } else { 700 };
380            let key = FontKey {
381                family: family.to_string(),
382                weight: opposite_weight,
383                italic,
384            };
385            if let Some(font) = self.fonts.get(&key) {
386                if font.has_char(ch) {
387                    return (font, family.to_string());
388                }
389            }
390        }
391
392        // Try builtin Unicode font (Noto Sans) before Helvetica
393        let builtin_key = FontKey {
394            family: "Noto Sans".to_string(),
395            weight: snapped_weight,
396            italic: false,
397        };
398        if let Some(font) = self.fonts.get(&builtin_key) {
399            if font.has_char(ch) {
400                return (font, "Noto Sans".to_string());
401            }
402        }
403
404        // Final fallback: Helvetica
405        let key = FontKey {
406            family: "Helvetica".to_string(),
407            weight: snapped_weight,
408            italic,
409        };
410        let font = self.fonts.get(&key).unwrap_or_else(|| {
411            self.fonts
412                .get(&FontKey {
413                    family: "Helvetica".to_string(),
414                    weight: 400,
415                    italic: false,
416                })
417                .expect("Helvetica must be registered")
418        });
419        (font, "Helvetica".to_string())
420    }
421
422    /// Register a custom font.
423    pub fn register(&mut self, family: &str, weight: u32, italic: bool, data: Vec<u8>) {
424        let metrics = CustomFontMetrics::from_font_data(&data);
425        self.fonts.insert(
426            FontKey {
427                family: family.to_string(),
428                weight,
429                italic,
430            },
431            FontData::Custom {
432                data,
433                used_glyphs: Vec::new(),
434                metrics,
435            },
436        );
437    }
438
439    /// Iterate over all registered fonts.
440    pub fn iter(&self) -> impl Iterator<Item = (&FontKey, &FontData)> {
441        self.fonts.iter()
442    }
443}
444
445/// Shared font context used by layout and PDF serialization.
446/// Provides text measurement with real glyph metrics.
447pub struct FontContext {
448    registry: FontRegistry,
449    /// Number of digits to use when measuring page number sentinel width.
450    /// Default 2 ("00"). Updated by the two-pass render loop after the
451    /// first layout reveals the actual page count.
452    sentinel_digit_count: u32,
453    /// Set the first time a page-number sentinel is measured in a layout pass.
454    /// The sentinel re-layout loop only needs to run for documents that
455    /// actually place a `{{pageNumber}}`/`{{totalPages}}` sentinel — this is
456    /// the exhaustive detection point (every sentinel glyph is measured here,
457    /// from any source: HTML `counter()`, margin boxes, JSX literals).
458    saw_page_sentinel: AtomicBool,
459}
460
461impl Default for FontContext {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467impl FontContext {
468    pub fn new() -> Self {
469        Self {
470            registry: FontRegistry::new(),
471            sentinel_digit_count: 2,
472            saw_page_sentinel: AtomicBool::new(false),
473        }
474    }
475
476    /// Get the current sentinel digit count.
477    pub fn sentinel_digit_count(&self) -> u32 {
478        self.sentinel_digit_count
479    }
480
481    /// Set the number of digits used to measure page number sentinel width.
482    pub fn set_sentinel_digit_count(&mut self, count: u32) {
483        self.sentinel_digit_count = count;
484    }
485
486    /// Whether a page-number sentinel was measured since the last reset.
487    /// Used to decide if the sentinel re-layout loop is needed at all.
488    pub fn saw_page_sentinel(&self) -> bool {
489        self.saw_page_sentinel.load(Ordering::Relaxed)
490    }
491
492    /// Clear the sentinel-seen flag before a layout pass.
493    pub fn reset_page_sentinel(&self) {
494        self.saw_page_sentinel.store(false, Ordering::Relaxed);
495    }
496
497    /// Get the advance width of a single character in points.
498    ///
499    /// When `family` contains a comma (font fallback chain), resolves the
500    /// best font for this specific character before measuring.
501    pub fn char_width(
502        &self,
503        ch: char,
504        family: &str,
505        weight: u32,
506        italic: bool,
507        font_size: f64,
508    ) -> f64 {
509        // Page placeholder sentinels: measure as the width of N zeros
510        // where N = sentinel_digit_count (set by the two-pass render loop)
511        if ch == crate::layout::PAGE_NUMBER_SENTINEL || ch == crate::layout::TOTAL_PAGES_SENTINEL {
512            self.saw_page_sentinel.store(true, Ordering::Relaxed);
513            return self.char_width('0', family, weight, italic, font_size)
514                * self.sentinel_digit_count as f64;
515        }
516
517        // Fast path: single font family — try primary font first,
518        // fall back to per-char resolution only when the char isn't covered
519        let font_data = if !family.contains(',') {
520            let primary = self.registry.resolve(family, weight, italic);
521            if ch.is_whitespace() || primary.has_char(ch) {
522                primary
523            } else {
524                let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
525                data
526            }
527        } else {
528            let (data, _) = self.registry.resolve_for_char(family, ch, weight, italic);
529            data
530        };
531        match font_data {
532            FontData::Standard(std_font) => std_font.metrics().char_width(ch, font_size),
533            FontData::Custom {
534                metrics: Some(m), ..
535            } => m.char_width(ch, font_size),
536            FontData::Custom { metrics: None, .. } => {
537                StandardFont::Helvetica.metrics().char_width(ch, font_size)
538            }
539        }
540    }
541
542    /// Measure the width of a string in points.
543    pub fn measure_string(
544        &self,
545        text: &str,
546        family: &str,
547        weight: u32,
548        italic: bool,
549        font_size: f64,
550        letter_spacing: f64,
551    ) -> f64 {
552        let mut width = 0.0;
553        for ch in text.chars() {
554            width += self.char_width(ch, family, weight, italic, font_size) + letter_spacing;
555        }
556        width
557    }
558
559    /// Resolve a font key to its font data.
560    pub fn resolve(&self, family: &str, weight: u32, italic: bool) -> &FontData {
561        self.registry.resolve(family, weight, italic)
562    }
563
564    /// Access the underlying font registry.
565    pub fn registry(&self) -> &FontRegistry {
566        &self.registry
567    }
568
569    /// Access the underlying font registry mutably.
570    pub fn registry_mut(&mut self) -> &mut FontRegistry {
571        &mut self.registry
572    }
573
574    /// Get the raw font data bytes for a custom font.
575    /// Returns `None` for standard fonts or if the font isn't found.
576    pub fn font_data(&self, family: &str, weight: u32, italic: bool) -> Option<&[u8]> {
577        let font_data = self.registry.resolve(family, weight, italic);
578        match font_data {
579            FontData::Custom { data, .. } => Some(data),
580            FontData::Standard(_) => None,
581        }
582    }
583
584    /// Typographic ascent and descent for a font, as fractions of an em —
585    /// the glyph block the CSS line box model centers inside a line
586    /// (content area = (ascent + descent) * font_size; half-leading is
587    /// what remains, split evenly; the baseline sits ascent below the
588    /// content-area top). Custom fonts report their hhea values (already
589    /// parsed into CustomFontMetrics, previously unused for baselines);
590    /// standard fonts use the typographic metrics of their metric-
591    /// compatible faces (Arial for Helvetica, Liberation Serif for
592    /// Times, Liberation Mono for Courier), which is what browsers use
593    /// when these families render. Descent is returned POSITIVE.
594    pub fn baseline_metrics(&self, family: &str, weight: u32, italic: bool) -> (f64, f64) {
595        let font_data = self.registry.resolve(family, weight, italic);
596        match font_data {
597            FontData::Custom {
598                metrics: Some(m), ..
599            } if m.units_per_em > 0 && m.ascender > 0 => (
600                m.ascender as f64 / m.units_per_em as f64,
601                (-(m.descender as f64)).max(0.0) / m.units_per_em as f64,
602            ),
603            FontData::Standard(sf) => standard_baseline_metrics(sf),
604            _ => (0.9053, 0.2119), // Arial-class fallback
605        }
606    }
607
608    /// Get the units-per-em for a font. Returns 1000 for standard fonts.
609    pub fn units_per_em(&self, family: &str, weight: u32, italic: bool) -> u16 {
610        let font_data = self.registry.resolve(family, weight, italic);
611        match font_data {
612            FontData::Custom {
613                metrics: Some(m), ..
614            } => m.units_per_em,
615            FontData::Custom { metrics: None, .. } => 1000,
616            FontData::Standard(_) => 1000,
617        }
618    }
619}
620
621/// hhea ascent/descent (em fractions, descent positive) of the metric-
622/// compatible substitutes for the base-14 families — Arial 1854/434 of
623/// 2048, Liberation Serif 1825/443, Liberation Mono 1705/615. Symbol and
624/// ZapfDingbats have no text substitute; Arial-class values keep their
625/// (rare, decorative) lines stable.
626fn standard_baseline_metrics(sf: &StandardFont) -> (f64, f64) {
627    match sf {
628        StandardFont::Helvetica
629        | StandardFont::HelveticaBold
630        | StandardFont::HelveticaOblique
631        | StandardFont::HelveticaBoldOblique => (1854.0 / 2048.0, 434.0 / 2048.0),
632        StandardFont::TimesRoman
633        | StandardFont::TimesBold
634        | StandardFont::TimesItalic
635        | StandardFont::TimesBoldItalic => (1825.0 / 2048.0, 443.0 / 2048.0),
636        StandardFont::Courier
637        | StandardFont::CourierBold
638        | StandardFont::CourierOblique
639        | StandardFont::CourierBoldOblique => (1705.0 / 2048.0, 615.0 / 2048.0),
640        _ => (1854.0 / 2048.0, 434.0 / 2048.0),
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn test_font_context_helvetica() {
650        let ctx = FontContext::new();
651        let w = ctx.char_width(' ', "Helvetica", 400, false, 12.0);
652        assert!((w - 3.336).abs() < 0.001);
653    }
654
655    #[test]
656    fn test_font_context_bold_wider() {
657        let ctx = FontContext::new();
658        let regular = ctx.char_width('A', "Helvetica", 400, false, 12.0);
659        let bold = ctx.char_width('A', "Helvetica", 700, false, 12.0);
660        assert!(bold > regular, "Bold A should be wider than regular A");
661    }
662
663    #[test]
664    fn test_font_context_measure_string() {
665        let ctx = FontContext::new();
666        let w = ctx.measure_string("Hello", "Helvetica", 400, false, 12.0, 0.0);
667        assert!(w > 0.0);
668    }
669
670    #[test]
671    fn test_font_context_fallback() {
672        let ctx = FontContext::new();
673        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
674        let w2 = ctx.char_width('A', "UnknownFont", 400, false, 12.0);
675        assert!((w1 - w2).abs() < 0.001);
676    }
677
678    #[test]
679    fn test_font_context_weight_resolution() {
680        let ctx = FontContext::new();
681        let w700 = ctx.char_width('A', "Helvetica", 700, false, 12.0);
682        let w800 = ctx.char_width('A', "Helvetica", 800, false, 12.0);
683        assert!((w700 - w800).abs() < 0.001);
684    }
685
686    #[test]
687    fn test_font_fallback_chain_first_match() {
688        let ctx = FontContext::new();
689        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
690        let w2 = ctx.char_width('A', "Times, Helvetica", 400, false, 12.0);
691        assert!((w1 - w2).abs() < 0.001, "Should use Times (first in chain)");
692    }
693
694    #[test]
695    fn test_font_fallback_chain_second_match() {
696        let ctx = FontContext::new();
697        let w1 = ctx.char_width('A', "Helvetica", 400, false, 12.0);
698        let w2 = ctx.char_width('A', "Missing, Helvetica", 400, false, 12.0);
699        assert!((w1 - w2).abs() < 0.001, "Should fall back to Helvetica");
700    }
701
702    #[test]
703    fn test_font_fallback_chain_all_missing() {
704        let ctx = FontContext::new();
705        // When all specified families are missing, resolve_for_char tries
706        // builtin Noto Sans first, then Helvetica. 'A' is in Noto Sans,
707        // so we get Noto Sans metrics (not Helvetica).
708        let w = ctx.char_width('A', "Missing, AlsoMissing", 400, false, 12.0);
709        assert!(w > 0.0, "Should still produce a valid width from fallback");
710    }
711
712    #[test]
713    fn test_font_fallback_chain_quoted_families() {
714        let ctx = FontContext::new();
715        let w1 = ctx.char_width('A', "Times", 400, false, 12.0);
716        let w2 = ctx.char_width('A', "'Times', \"Helvetica\"", 400, false, 12.0);
717        assert!((w1 - w2).abs() < 0.001, "Should strip quotes and use Times");
718    }
719
720    #[test]
721    fn test_builtin_noto_sans_registered() {
722        let registry = FontRegistry::new();
723        let font = registry.resolve("Noto Sans", 400, false);
724        assert!(
725            matches!(font, FontData::Custom { .. }),
726            "Noto Sans should be registered as a custom font"
727        );
728        assert!(
729            font.has_char('\u{041F}'),
730            "Noto Sans should have Cyrillic П"
731        );
732        assert!(font.has_char('\u{03B1}'), "Noto Sans should have Greek α");
733    }
734
735    #[test]
736    fn test_builtin_noto_sans_fallback_for_cyrillic() {
737        let registry = FontRegistry::new();
738        let (font, family) = registry.resolve_for_char("Helvetica", '\u{041F}', 400, false);
739        assert_eq!(
740            family, "Noto Sans",
741            "Cyrillic should fall back to Noto Sans"
742        );
743        assert!(matches!(font, FontData::Custom { .. }));
744    }
745
746    #[test]
747    fn test_font_fallback_single_family_unchanged() {
748        let ctx = FontContext::new();
749        let w1 = ctx.char_width('A', "Courier", 400, false, 12.0);
750        let w2 = ctx.char_width('A', "Courier", 400, false, 12.0);
751        assert!(
752            (w1 - w2).abs() < 0.001,
753            "Single family should work as before"
754        );
755    }
756}