Skip to main content

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