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