Skip to main content

oxml_layout/
font.rs

1//! Font loading, resolution, shaping, and metrics.
2//!
3//! Uses fontdb for system font discovery, ttf-parser for metrics,
4//! and HarfRust for text shaping.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use crate::error::{LayoutError, Result};
10use crate::output::FontId;
11
12/// Font data provided by the user or extracted from an OOXML file.
13#[derive(Debug, Clone)]
14pub struct FontFile {
15    /// Font family name (e.g., "Calibri", "Arial").
16    pub family: String,
17    /// Raw font file bytes (TTF/OTF).
18    pub data: Vec<u8>,
19}
20
21/// Key for caching resolved fonts.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23struct FontKey {
24    family: String,
25    bold: bool,
26    italic: bool,
27}
28
29/// Metrics for a font at a given size.
30#[derive(Debug, Clone, Copy)]
31pub struct FontMetrics {
32    /// Ascent in points (positive, above baseline).
33    pub ascent: f64,
34    /// Descent in points (positive, below baseline).
35    pub descent: f64,
36    /// Line gap in points.
37    pub line_gap: f64,
38    /// Units per em.
39    pub units_per_em: u16,
40}
41
42/// Result of shaping a text string.
43#[derive(Debug, Clone)]
44pub struct ShapedText {
45    /// Glyph IDs from shaping.
46    pub glyph_ids: Vec<u16>,
47    /// Per-glyph advances in points.
48    pub advances: Vec<f64>,
49    /// Total width in points.
50    pub width: f64,
51}
52
53/// Internal record for a loaded font face.
54struct LoadedFont {
55    id: FontId,
56    family: String,
57    bold: bool,
58    italic: bool,
59    data: Arc<Vec<u8>>,
60    face_index: u32,
61    units_per_em: u16,
62    /// Vertical metrics in design units, read once when the face is loaded.
63    ascender: i16,
64    descender: i16,
65    line_gap: i16,
66    /// HarfRust's per-face shaping caches. Building these is the expensive
67    /// part of shaping, so it happens once per face instead of once per run.
68    shaper_data: harfrust::ShaperData,
69}
70
71/// Manages font discovery, loading, shaping, and metrics.
72pub struct FontManager {
73    db: fontdb::Database,
74    /// Map from FontKey to loaded font info.
75    cache: HashMap<FontKey, usize>,
76    /// All loaded fonts.
77    fonts: Vec<LoadedFont>,
78    /// Next font ID counter.
79    next_id: u32,
80    /// Fonts already discovered as covering something the requested family
81    /// could not, keyed by (bold, italic).
82    ///
83    /// Finding a font that covers a character means loading and inspecting
84    /// faces, which is far too slow to repeat per character. Once a CJK face
85    /// has been found for one character it almost always covers the rest of
86    /// the run, so it is tried first next time.
87    coverage_fallbacks: HashMap<(bool, bool), Vec<usize>>,
88    /// Characters already searched for and not found in any available font, so
89    /// the scan is not repeated for every occurrence.
90    coverage_misses: HashSet<char>,
91}
92
93/// Families with broad non-Latin coverage, tried before scanning everything.
94///
95/// Ordered roughly by how likely each is to be installed. This is only a fast
96/// path: if none of them is present the full font database is still searched.
97const BROAD_COVERAGE_FAMILIES: &[&str] = &[
98    // Bundled with or shipped alongside many Linux distributions
99    "Noto Sans CJK SC",
100    "Noto Sans CJK JP",
101    "Noto Sans CJK KR",
102    "Noto Sans CJK TC",
103    "Noto Serif CJK SC",
104    "Source Han Sans SC",
105    "WenQuanYi Zen Hei",
106    "WenQuanYi Micro Hei",
107    // macOS
108    "PingFang SC",
109    "PingFang TC",
110    "Hiragino Sans",
111    "Hiragino Kaku Gothic ProN",
112    "Apple SD Gothic Neo",
113    "Songti SC",
114    "STHeiti",
115    // Windows
116    "Microsoft YaHei",
117    "Microsoft JhengHei",
118    "SimSun",
119    "SimHei",
120    "NSimSun",
121    "Yu Gothic",
122    "MS Gothic",
123    "Meiryo",
124    "Malgun Gothic",
125    // Wide-coverage generalists
126    "Arial Unicode MS",
127    "DejaVu Sans",
128];
129
130impl Default for FontManager {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl FontManager {
137    /// Create a new FontManager and load system fonts.
138    ///
139    /// Bundled fonts (Carlito, Caladea, Liberation) are loaded as fallbacks.
140    /// System fonts are discovered when the `system-fonts` feature is enabled.
141    pub fn new() -> Self {
142        let mut db = fontdb::Database::new();
143
144        // Load bundled fonts first (lowest priority fallbacks)
145        for (_family, data) in crate::bundled_fonts::bundled_font_data() {
146            db.load_font_data(data.to_vec());
147        }
148
149        // Then load system fonts when the local feature enables discovery.
150        #[cfg(feature = "system-fonts")]
151        db.load_system_fonts();
152
153        FontManager {
154            db,
155            cache: HashMap::new(),
156            fonts: Vec::new(),
157            next_id: 0,
158            coverage_fallbacks: HashMap::new(),
159            coverage_misses: HashSet::new(),
160        }
161    }
162
163    /// Create a font manager that loads bundled fonts without discovering
164    /// system fonts.
165    ///
166    /// This mode makes font resolution reproducible across machines.
167    pub fn new_deterministic() -> Result<Self> {
168        let mut db = fontdb::Database::new();
169        for (_family, data) in crate::bundled_fonts::bundled_font_data() {
170            db.load_font_data(data.to_vec());
171        }
172
173        Ok(FontManager {
174            db,
175            cache: HashMap::new(),
176            fonts: Vec::new(),
177            next_id: 0,
178            coverage_fallbacks: HashMap::new(),
179            coverage_misses: HashSet::new(),
180        })
181    }
182
183    /// Load additional font files (user-provided or extracted from DOCX).
184    ///
185    /// These fonts are loaded AFTER system fonts, so they take the highest
186    /// priority in font resolution (fontdb returns the last-loaded match).
187    pub fn load_additional_fonts(&mut self, font_files: &[FontFile]) {
188        for font_file in font_files {
189            self.db.load_font_data(font_file.data.clone());
190        }
191        // Clear the cache since new fonts may affect resolution
192        self.cache.clear();
193    }
194
195    /// Create a FontManager with user-provided fonts (no system font loading).
196    ///
197    /// Each entry is `(family_name, font_bytes)`. This is useful in environments
198    /// where system fonts are not available, such as WASM.
199    pub fn new_with_fonts(fonts: Vec<(String, Vec<u8>)>) -> Self {
200        let mut db = fontdb::Database::new();
201        for (_name, data) in &fonts {
202            db.load_font_data(data.clone());
203        }
204        FontManager {
205            db,
206            cache: HashMap::new(),
207            fonts: Vec::new(),
208            next_id: 0,
209            coverage_fallbacks: HashMap::new(),
210            coverage_misses: HashSet::new(),
211        }
212    }
213
214    /// Resolve a font for `text`, falling back on glyph coverage.
215    ///
216    /// `resolve_font` picks by family name alone. That is enough for Latin
217    /// text, but a run asking for a Chinese family on a machine without it
218    /// falls down the name chain and lands on a Latin font, which has no CJK
219    /// glyphs, so every character renders as a missing-glyph box. Name
220    /// matching cannot detect that, because the font it chose exists and is
221    /// perfectly valid, it simply cannot draw this text.
222    ///
223    /// So the resolved font is checked against the text, and when a character
224    /// is missing another font that can draw it is looked for.
225    ///
226    /// This is per run rather than per character: the font that covers the
227    /// first missing character is used for the whole run. Text that mixes
228    /// scripts inside one run is therefore still imperfect, but it is a large
229    /// improvement on drawing boxes.
230    pub fn resolve_font_for_text(
231        &mut self,
232        family: Option<&str>,
233        bold: bool,
234        italic: bool,
235        text: &str,
236    ) -> Result<FontId> {
237        let primary = self.resolve_font(family, bold, italic)?;
238
239        let Some(idx) = self.index_of(primary) else {
240            return Ok(primary);
241        };
242        let missing = self.uncovered(idx, text);
243        if missing.is_empty() {
244            return Ok(primary);
245        }
246
247        match self.font_covering(&missing, bold, italic) {
248            // Nothing installed can draw it. Keep the original font so the
249            // text still occupies the right space.
250            None => Ok(primary),
251            Some(id) => Ok(id),
252        }
253    }
254
255    /// The characters in `text` that the font at `idx` cannot draw.
256    ///
257    /// Whitespace and control characters are skipped: a font without a glyph
258    /// for a space is not a reason to go looking for another one.
259    fn uncovered(&self, idx: usize, text: &str) -> Vec<char> {
260        let font = &self.fonts[idx];
261        let Ok(face) = ttf_parser::Face::parse(&font.data, font.face_index) else {
262            return Vec::new();
263        };
264        let mut seen = HashSet::new();
265        text.chars()
266            .filter(|&ch| !ch.is_whitespace() && !ch.is_control())
267            .filter(|&ch| face.glyph_index(ch).is_none())
268            .filter(|&ch| seen.insert(ch))
269            .collect()
270    }
271
272    /// Whether the font at `idx` has a glyph for `ch`.
273    fn covers(&self, idx: usize, ch: char) -> bool {
274        let font = &self.fonts[idx];
275        ttf_parser::Face::parse(&font.data, font.face_index)
276            .map(|face| face.glyph_index(ch).is_some())
277            .unwrap_or(false)
278    }
279
280    /// Find a font that can draw `missing`.
281    ///
282    /// A font covering every missing character wins. Failing that the one
283    /// covering the most is used, because a single run gets a single font and
284    /// partial coverage still beats a row of boxes. Picking on the first
285    /// missing character alone is not enough: a Japanese face may have the
286    /// characters shared with Chinese and not the simplified-only ones, so it
287    /// would look like a fix and still leave gaps.
288    fn font_covering(&mut self, missing: &[char], bold: bool, italic: bool) -> Option<FontId> {
289        if missing.iter().all(|ch| self.coverage_misses.contains(ch)) {
290            return None;
291        }
292
293        let mut best: Option<(usize, usize)> = None; // (covered count, font index)
294        let consider = |this: &Self, idx: usize, best: &mut Option<(usize, usize)>| -> bool {
295            let covered = missing.iter().filter(|&&ch| this.covers(idx, ch)).count();
296            if covered == 0 {
297                return false;
298            }
299            if best.map(|(n, _)| covered > n).unwrap_or(true) {
300                *best = Some((covered, idx));
301            }
302            covered == missing.len()
303        };
304
305        // Fonts that already rescued an earlier run, which for a document in
306        // one script is almost always the answer again.
307        if let Some(known) = self.coverage_fallbacks.get(&(bold, italic)).cloned() {
308            for idx in known {
309                if consider(self, idx, &mut best) {
310                    return Some(self.fonts[idx].id);
311                }
312            }
313        }
314
315        // Families with broad coverage, then everything else the database
316        // knows about. Both go through resolve_font so loading and caching
317        // stay in one place.
318        let candidates: Vec<String> = BROAD_COVERAGE_FAMILIES
319            .iter()
320            .map(|s| s.to_string())
321            .chain(
322                self.db
323                    .faces()
324                    .filter_map(|f| f.families.first().map(|(name, _)| name.clone())),
325            )
326            .collect();
327
328        for name in candidates {
329            let Ok(id) = self.resolve_font(Some(&name), bold, italic) else {
330                continue;
331            };
332            let Some(idx) = self.index_of(id) else {
333                continue;
334            };
335            let complete = consider(self, idx, &mut best);
336            if complete {
337                self.coverage_fallbacks
338                    .entry((bold, italic))
339                    .or_default()
340                    .push(idx);
341                return Some(id);
342            }
343        }
344
345        match best {
346            Some((_, idx)) => {
347                self.coverage_fallbacks
348                    .entry((bold, italic))
349                    .or_default()
350                    .push(idx);
351                Some(self.fonts[idx].id)
352            }
353            None => {
354                for &ch in missing {
355                    self.coverage_misses.insert(ch);
356                }
357                None
358            }
359        }
360    }
361
362    /// Index into `fonts` for a FontId.
363    fn index_of(&self, id: FontId) -> Option<usize> {
364        self.fonts.iter().position(|f| f.id == id)
365    }
366
367    /// Resolve a font by family name, bold, and italic flags.
368    /// Returns a FontId. Uses fallback chain if the requested font is not found.
369    pub fn resolve_font(
370        &mut self,
371        family: Option<&str>,
372        bold: bool,
373        italic: bool,
374    ) -> Result<FontId> {
375        let family_name = family.unwrap_or("Arial");
376
377        let key = FontKey {
378            family: family_name.to_string(),
379            bold,
380            italic,
381        };
382
383        if let Some(&idx) = self.cache.get(&key) {
384            return Ok(self.fonts[idx].id);
385        }
386
387        // Map common Word font names to metric-compatible alternatives
388        let mapped = map_font_name(family_name);
389
390        // Try the requested font, mapped alternatives, then generic fallbacks
391        let mut fallbacks: Vec<&str> = Vec::with_capacity(10);
392        fallbacks.push(family_name);
393        for alt in mapped {
394            if *alt != family_name {
395                fallbacks.push(alt);
396            }
397        }
398        for generic in &[
399            "Carlito",
400            "Arial",
401            "Liberation Sans",
402            "Helvetica",
403            "DejaVu Sans",
404            "Noto Sans",
405        ] {
406            if !fallbacks.contains(generic) {
407                fallbacks.push(generic);
408            }
409        }
410
411        let style = if italic {
412            fontdb::Style::Italic
413        } else {
414            fontdb::Style::Normal
415        };
416        let weight = if bold {
417            fontdb::Weight::BOLD
418        } else {
419            fontdb::Weight::NORMAL
420        };
421
422        let mut found_id = None;
423        for fallback in &fallbacks {
424            let query = fontdb::Query {
425                families: &[fontdb::Family::Name(fallback)],
426                weight,
427                style,
428                stretch: fontdb::Stretch::Normal,
429            };
430
431            if let Some(id) = self.db.query(&query) {
432                found_id = Some(id);
433                break;
434            }
435        }
436
437        // Last resort: try generic families
438        if found_id.is_none() {
439            for generic_family in &[
440                fontdb::Family::SansSerif,
441                fontdb::Family::Serif,
442                fontdb::Family::Monospace,
443            ] {
444                let query = fontdb::Query {
445                    families: &[*generic_family],
446                    weight,
447                    style,
448                    stretch: fontdb::Stretch::Normal,
449                };
450                if let Some(id) = self.db.query(&query) {
451                    found_id = Some(id);
452                    break;
453                }
454            }
455        }
456
457        let db_id = found_id.ok_or_else(|| {
458            LayoutError::FontNotFound(format!("No font found for family '{family_name}'"))
459        })?;
460
461        let font_id = FontId(self.next_id);
462        self.next_id += 1;
463
464        // Load the font data
465        let (data, face_index) = self
466            .db
467            .with_face_data(db_id, |data, idx| (Arc::new(data.to_vec()), idx))
468            .ok_or_else(|| LayoutError::FontParse("Failed to load font data".into()))?;
469
470        let (units_per_em, ascender, descender, line_gap) = {
471            let face = ttf_parser::Face::parse(&data, face_index)
472                .map_err(|e| LayoutError::FontParse(format!("ttf-parser error: {e}")))?;
473            (
474                face.units_per_em(),
475                face.ascender(),
476                face.descender(),
477                face.line_gap(),
478            )
479        };
480
481        // Every metric and advance is scaled by size/upem, so a zero here would
482        // turn the whole layout into infinities.
483        if units_per_em == 0 {
484            return Err(LayoutError::FontParse(format!(
485                "font '{family_name}' declares zero units per em"
486            )));
487        }
488
489        let shaper_data = {
490            let face = harfrust::FontRef::from_index(&data, face_index)
491                .map_err(|e| LayoutError::FontParse(format!("failed to read font face: {e}")))?;
492            harfrust::ShaperData::new(&face)
493        };
494
495        let actual_family = self
496            .db
497            .face(db_id)
498            .map(|f| {
499                f.families
500                    .first()
501                    .map(|(name, _)| name.clone())
502                    .unwrap_or_else(|| family_name.to_string())
503            })
504            .unwrap_or_else(|| family_name.to_string());
505
506        let idx = self.fonts.len();
507        self.fonts.push(LoadedFont {
508            id: font_id,
509            family: actual_family,
510            bold,
511            italic,
512            data,
513            face_index,
514            units_per_em,
515            ascender,
516            descender,
517            line_gap,
518            shaper_data,
519        });
520        self.cache.insert(key, idx);
521
522        Ok(font_id)
523    }
524
525    /// Get font metrics at a given size in points.
526    pub fn metrics(&self, font_id: FontId, size_pt: f64) -> Result<FontMetrics> {
527        let font = self.get_font(font_id)?;
528        let scale = size_pt / font.units_per_em as f64;
529
530        Ok(FontMetrics {
531            ascent: font.ascender as f64 * scale,
532            descent: -(font.descender as f64) * scale, // make positive
533            line_gap: font.line_gap as f64 * scale,
534            units_per_em: font.units_per_em,
535        })
536    }
537
538    /// Shape a text string using HarfRust. Returns glyph IDs and advances.
539    pub fn shape_text(&self, font_id: FontId, text: &str, size_pt: f64) -> Result<ShapedText> {
540        // HarfRust cannot derive segment properties from an empty buffer, and
541        // there is nothing to shape anyway.
542        if text.is_empty() {
543            return Ok(ShapedText {
544                glyph_ids: Vec::new(),
545                advances: Vec::new(),
546                width: 0.0,
547            });
548        }
549
550        let font = self.get_font(font_id)?;
551
552        let face = harfrust::FontRef::from_index(&font.data, font.face_index)
553            .map_err(|e| LayoutError::Shaping(format!("failed to read font face: {e}")))?;
554
555        let shaper = font.shaper_data.shaper(&face).build();
556
557        let mut buffer = harfrust::UnicodeBuffer::new();
558        buffer.push_str(text);
559        // Infer direction, script and language from the text. Unlike rustybuzz,
560        // HarfRust does not do this implicitly and panics on an unset direction.
561        buffer.guess_segment_properties();
562
563        let output = shaper.shape(buffer, harfrust::ShapeOptions::default());
564        let infos = output.glyph_infos();
565        let positions = output.glyph_positions();
566
567        let upem = font.units_per_em as f64;
568        let scale = size_pt / upem;
569
570        let mut glyph_ids = Vec::with_capacity(infos.len());
571        let mut advances = Vec::with_capacity(positions.len());
572        let mut total_width = 0.0;
573
574        for (info, pos) in infos.iter().zip(positions.iter()) {
575            glyph_ids.push(info.glyph_id as u16);
576            let advance = pos.x_advance as f64 * scale;
577            advances.push(advance);
578            total_width += advance;
579        }
580
581        Ok(ShapedText {
582            glyph_ids,
583            advances,
584            width: total_width,
585        })
586    }
587
588    /// Get font data for PDF embedding.
589    pub fn font_data(&self, font_id: FontId) -> Result<crate::output::FontData> {
590        let font = self.get_font(font_id)?;
591        Ok(crate::output::FontData {
592            id: font.id,
593            family: font.family.clone(),
594            data: (*font.data).clone(),
595            face_index: font.face_index,
596            bold: font.bold,
597            italic: font.italic,
598        })
599    }
600
601    /// Get all used font data.
602    pub fn all_font_data(&self) -> Vec<crate::output::FontData> {
603        self.fonts
604            .iter()
605            .map(|f| crate::output::FontData {
606                id: f.id,
607                family: f.family.clone(),
608                data: (*f.data).clone(),
609                face_index: f.face_index,
610                bold: f.bold,
611                italic: f.italic,
612            })
613            .collect()
614    }
615
616    fn get_font(&self, font_id: FontId) -> Result<&LoadedFont> {
617        self.fonts
618            .iter()
619            .find(|f| f.id == font_id)
620            .ok_or_else(|| LayoutError::FontNotFound(format!("FontId({}) not loaded", font_id.0)))
621    }
622}
623
624/// Map common Word font names to metric-compatible alternatives.
625/// Returns a list of candidate names to try (including the original).
626///
627/// Priority: original font → metric-compatible open-source clone → generic fallback.
628/// Carlito is metric-compatible with Calibri, Caladea with Cambria,
629/// Liberation Sans/Serif/Mono with Arial/Times New Roman/Courier New.
630fn map_font_name(name: &str) -> &[&str] {
631    match name {
632        "Calibri" => &["Calibri", "Carlito"],
633        "Calibri Light" => &["Calibri Light", "Carlito"],
634        "Cambria" => &["Cambria", "Caladea"],
635        "Cambria Math" => &["Cambria Math", "Cambria", "Caladea"],
636        "Arial" => &["Arial", "Liberation Sans", "Helvetica"],
637        "Times New Roman" => &["Times New Roman", "Liberation Serif", "Times"],
638        "Courier New" => &["Courier New", "Liberation Mono", "Courier"],
639        "Consolas" => &["Consolas", "Liberation Mono", "DejaVu Sans Mono"],
640        "Segoe UI" => &["Segoe UI", "Carlito", "Liberation Sans"],
641        "Tahoma" => &["Tahoma", "Liberation Sans", "Helvetica"],
642        "Verdana" => &["Verdana", "Liberation Sans", "DejaVu Sans"],
643        "Georgia" => &["Georgia", "Caladea", "Liberation Serif"],
644        "Palatino Linotype" => &["Palatino Linotype", "Palatino", "Liberation Serif"],
645        "Book Antiqua" => &["Book Antiqua", "Palatino", "Liberation Serif"],
646        "Garamond" => &["Garamond", "Caladea", "Liberation Serif"],
647        "Trebuchet MS" => &["Trebuchet MS", "Liberation Sans", "DejaVu Sans"],
648        "Impact" => &["Impact", "Liberation Sans", "Arial"],
649        "Comic Sans MS" => &["Comic Sans MS", "Liberation Sans", "DejaVu Sans"],
650        "Symbol" => &["Symbol", "DejaVu Sans"],
651        "Wingdings" => &["Wingdings", "Symbol"],
652        _ => &[],
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::bundled_fonts::bundled_font_data;
660
661    #[test]
662    fn deterministic_font_manager_uses_only_bundled_fonts() {
663        let mut fm = FontManager::new_deterministic().expect("bundled fonts should load");
664
665        assert_eq!(fm.db.faces().count(), bundled_font_data().len());
666        assert!(fm.resolve_font(Some("Arial"), false, false).is_ok());
667    }
668
669    #[cfg(not(feature = "system-fonts"))]
670    #[test]
671    fn no_default_features_omits_system_font_discovery() {
672        let fm = FontManager::new();
673        assert_eq!(fm.db.faces().count(), bundled_font_data().len());
674    }
675
676    #[test]
677    fn font_manager_with_no_fonts_returns_an_error() {
678        let mut fm = FontManager::new_with_fonts(Vec::new());
679        assert!(matches!(
680            fm.resolve_font(None, false, false),
681            Err(LayoutError::FontNotFound(_))
682        ));
683    }
684
685    #[test]
686    fn load_system_font() {
687        let mut fm = FontManager::new();
688        // Should be able to resolve at least one font via fallback
689        let result = fm.resolve_font(None, false, false);
690        // On CI or systems without fonts this might fail, so we just check it doesn't panic
691        if let Ok(id) = result {
692            assert_eq!(id.0, 0);
693        }
694    }
695
696    #[test]
697    fn font_metrics_positive() {
698        let mut fm = FontManager::new();
699        if let Ok(id) = fm.resolve_font(None, false, false) {
700            let metrics = fm.metrics(id, 12.0).unwrap();
701            assert!(metrics.ascent > 0.0);
702            assert!(metrics.descent > 0.0);
703            assert!(metrics.units_per_em > 0);
704        }
705    }
706
707    #[test]
708    fn shape_hello_world() {
709        let mut fm = FontManager::new();
710        if let Ok(id) = fm.resolve_font(None, false, false) {
711            let shaped = fm.shape_text(id, "Hello World", 12.0).unwrap();
712            assert!(!shaped.glyph_ids.is_empty());
713            assert_eq!(shaped.glyph_ids.len(), shaped.advances.len());
714            assert!(shaped.width > 0.0);
715        }
716    }
717
718    #[test]
719    fn font_caching() {
720        let mut fm = FontManager::new();
721        if let Ok(id1) = fm.resolve_font(Some("Arial"), false, false) {
722            let id2 = fm.resolve_font(Some("Arial"), false, false).unwrap();
723            assert_eq!(id1, id2);
724        }
725    }
726
727    #[test]
728    fn bold_italic_variants() {
729        let mut fm = FontManager::new();
730        let regular = fm.resolve_font(None, false, false);
731        let bold = fm.resolve_font(None, true, false);
732        if let (Ok(r), Ok(b)) = (regular, bold) {
733            // Bold should get a different font ID (different variant)
734            assert_ne!(r, b);
735        }
736    }
737
738    /// Latin text must resolve exactly as it did before, so the coverage check
739    /// cannot disturb the overwhelmingly common case.
740    #[test]
741    fn latin_text_resolves_the_same_as_by_name() {
742        let mut fm = FontManager::new();
743        let Ok(by_name) = fm.resolve_font(Some("Arial"), false, false) else {
744            return;
745        };
746        let for_text = fm
747            .resolve_font_for_text(Some("Arial"), false, false, "Hello world")
748            .unwrap();
749        assert_eq!(by_name, for_text);
750    }
751
752    /// Text nothing can draw must keep the requested font rather than failing.
753    ///
754    /// The bundled fonts have no CJK coverage, so in deterministic mode the
755    /// search is guaranteed to come up empty. The text still needs a font so
756    /// it occupies the right space.
757    #[test]
758    fn text_no_font_can_draw_keeps_the_requested_font() {
759        let mut fm = FontManager::new_deterministic().expect("bundled fonts should load");
760        let primary = fm.resolve_font(Some("Carlito"), false, false).unwrap();
761        let resolved = fm
762            .resolve_font_for_text(Some("Carlito"), false, false, "这是中文")
763            .unwrap();
764        assert_eq!(
765            primary, resolved,
766            "with no covering font available the original must be kept"
767        );
768    }
769
770    /// Whitespace absent from a font is not a reason to go hunting for another.
771    #[test]
772    fn whitespace_does_not_trigger_a_fallback() {
773        let mut fm = FontManager::new_deterministic().expect("bundled fonts should load");
774        let by_name = fm.resolve_font(Some("Carlito"), false, false).unwrap();
775        let idx = fm.index_of(by_name).unwrap();
776        // A non-breaking space and a tab, neither of which every face carries.
777        assert!(
778            fm.uncovered(idx, "a\u{00a0}b\tc")
779                .iter()
780                .all(|c| *c != '\t'),
781            "control and whitespace characters must be ignored"
782        );
783    }
784
785    /// When the machine does have a CJK font, CJK text must not keep a Latin
786    /// font that cannot draw it.
787    ///
788    /// Skipped where no such font is installed, which is why it asserts
789    /// nothing about which font is chosen.
790    #[test]
791    fn cjk_text_moves_off_a_latin_font_when_possible() {
792        let mut fm = FontManager::new();
793        let Ok(latin) = fm.resolve_font(Some("Liberation Serif"), false, false) else {
794            return;
795        };
796        let Some(idx) = fm.index_of(latin) else {
797            return;
798        };
799        if fm.uncovered(idx, "这是中文").is_empty() {
800            return; // that font somehow covers it, nothing to prove
801        }
802        let resolved = fm
803            .resolve_font_for_text(Some("Liberation Serif"), false, false, "这是中文")
804            .unwrap();
805        if resolved == latin {
806            return; // no covering font installed on this machine
807        }
808        let new_idx = fm.index_of(resolved).unwrap();
809        assert!(
810            fm.uncovered(new_idx, "这是中文").len() < fm.uncovered(idx, "这是中文").len(),
811            "the replacement must cover more of the text than the original"
812        );
813    }
814}