Skip to main content

forme/font/
fallback.rs

1//! # Per-Character Font Fallback
2//!
3//! Segments text into runs by font coverage. When a font family is a
4//! comma-separated chain like "Inter, NotoSansArabic, NotoSansSC", each
5//! character is resolved to the first font that has a glyph for it.
6//! Consecutive characters using the same font are coalesced into runs
7//! to minimize shaping calls.
8
9use super::FontRegistry;
10
11/// A contiguous run of characters that all resolve to the same font.
12#[derive(Debug, Clone)]
13pub struct FontRun {
14    /// Start index in the original char array (inclusive).
15    pub start: usize,
16    /// End index in the original char array (exclusive).
17    pub end: usize,
18    /// The resolved single font family name (e.g. "Inter", not "Inter, Noto").
19    pub family: String,
20}
21
22/// Segment characters into runs by font coverage.
23///
24/// **Fast path:** when `families` contains no comma, returns a single run
25/// covering all characters — zero overhead for single-font text.
26///
27/// **Slow path:** iterates characters, calling `resolve_for_char` per char,
28/// and coalesces consecutive same-font characters into runs.
29pub fn segment_by_font(
30    chars: &[char],
31    families: &str,
32    weight: u32,
33    italic: bool,
34    registry: &FontRegistry,
35) -> Vec<FontRun> {
36    if chars.is_empty() {
37        return vec![];
38    }
39
40    // Fast path: single font family — check if all chars are covered.
41    // Page-number sentinels are exempt like whitespace: they're replaced
42    // by digits at write time, so no font can (or needs to) cover them.
43    if !families.contains(',') {
44        let family = families.trim().trim_matches('"').trim_matches('\'');
45        let font = registry.resolve(family, weight, italic);
46        let all_covered = chars.iter().all(|&ch| {
47            ch.is_whitespace()
48                || ch == crate::layout::PAGE_NUMBER_SENTINEL
49                || ch == crate::layout::TOTAL_PAGES_SENTINEL
50                || font.has_char(ch)
51        });
52        if all_covered {
53            return vec![FontRun {
54                start: 0,
55                end: chars.len(),
56                family: family.to_string(),
57            }];
58        }
59        // Some chars not covered — fall through to per-char resolution
60        // which will try builtin Noto Sans via resolve_for_char()
61    }
62
63    // Slow path: per-character font resolution
64    let mut runs = Vec::new();
65    let (_, first_family) = registry.resolve_for_char(families, chars[0], weight, italic);
66    let mut current_family = first_family;
67    let mut run_start = 0;
68
69    for (i, &ch) in chars.iter().enumerate().skip(1) {
70        let (_, family) = registry.resolve_for_char(families, ch, weight, italic);
71        if family != current_family {
72            runs.push(FontRun {
73                start: run_start,
74                end: i,
75                family: current_family,
76            });
77            current_family = family;
78            run_start = i;
79        }
80    }
81
82    // Push final run
83    runs.push(FontRun {
84        start: run_start,
85        end: chars.len(),
86        family: current_family,
87    });
88
89    runs
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_single_font_fast_path() {
98        let registry = FontRegistry::new();
99        let chars: Vec<char> = "Hello world".chars().collect();
100        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
101        assert_eq!(runs.len(), 1);
102        assert_eq!(runs[0].family, "Helvetica");
103        assert_eq!(runs[0].start, 0);
104        assert_eq!(runs[0].end, 11);
105    }
106
107    #[test]
108    fn test_empty_input() {
109        let registry = FontRegistry::new();
110        let chars: Vec<char> = vec![];
111        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
112        assert!(runs.is_empty());
113    }
114
115    #[test]
116    fn test_single_font_builtin_fallback() {
117        let registry = FontRegistry::new();
118        // Cyrillic chars aren't in Helvetica, should fall back to Noto Sans
119        let chars: Vec<char> = "\u{041F}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442}"
120            .chars()
121            .collect();
122        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
123        assert!(!runs.is_empty(), "Should produce at least one run");
124        // All chars should be Noto Sans (since none are in Helvetica)
125        assert_eq!(runs[0].family, "Noto Sans", "Cyrillic should use Noto Sans");
126    }
127
128    #[test]
129    fn test_single_font_mixed_latin_cyrillic() {
130        let registry = FontRegistry::new();
131        // Mix of Latin (in Helvetica) and Cyrillic (not in Helvetica)
132        let chars: Vec<char> = "Hi \u{041F}".chars().collect();
133        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
134        assert!(
135            runs.len() >= 2,
136            "Should have at least 2 runs (Latin + Cyrillic), got {}",
137            runs.len()
138        );
139    }
140
141    #[test]
142    fn test_all_chars_same_font() {
143        let registry = FontRegistry::new();
144        let chars: Vec<char> = "ABC".chars().collect();
145        // Both Helvetica and Times have Latin chars, so first match wins
146        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
147        assert_eq!(runs.len(), 1);
148        assert_eq!(runs[0].family, "Helvetica");
149    }
150}