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/// Resolve controls and combining marks with the preceding base font so
23/// fallback does not split an OpenType shaping cluster.
24pub(crate) fn resolve_family(
25    ch: char,
26    families: &str,
27    weight: u32,
28    italic: bool,
29    previous: Option<&str>,
30    registry: &FontRegistry,
31) -> String {
32    if let Some(family) = previous {
33        let shaping_control = matches!(ch, '\u{200c}' | '\u{200d}' | '\u{fe00}'..='\u{fe0f}' | '\u{e0100}'..='\u{e01ef}');
34        let combining_mark = unicode_bidi::bidi_class(ch) == unicode_bidi::BidiClass::NSM;
35        if shaping_control
36            || (combining_mark && registry.resolve(family, weight, italic).has_char(ch))
37        {
38            return family.to_string();
39        }
40    }
41    registry.resolve_for_char(families, ch, weight, italic).1
42}
43
44/// Segment characters into runs by font coverage.
45///
46/// **Fast path:** when `families` contains no comma, returns a single run
47/// covering all characters — zero overhead for single-font text.
48///
49/// **Slow path:** iterates characters, calling `resolve_for_char` per char,
50/// and coalesces consecutive same-font characters into runs.
51pub fn segment_by_font(
52    chars: &[char],
53    families: &str,
54    weight: u32,
55    italic: bool,
56    registry: &FontRegistry,
57) -> Vec<FontRun> {
58    if chars.is_empty() {
59        return vec![];
60    }
61
62    // Fast path: single font family — check if all chars are covered.
63    // Controls are exempt: no font draws newlines or tabs, and the
64    // page-number sentinels are replaced by digits at write time. Other
65    // whitespace is not — U+202F has no WinAnsi byte, so it must fall
66    // back exactly as it does in styled runs.
67    if !families.contains(',') {
68        let family = families.trim().trim_matches('"').trim_matches('\'');
69        let font = registry.resolve(family, weight, italic);
70        let all_covered = chars.iter().all(|&ch| ch.is_control() || font.has_char(ch));
71        if all_covered {
72            return vec![FontRun {
73                start: 0,
74                end: chars.len(),
75                family: family.to_string(),
76            }];
77        }
78        // Some chars not covered — fall through to per-char resolution
79        // which will try builtin Noto Sans via resolve_for_char()
80    }
81
82    // Slow path: per-character font resolution
83    let mut runs = Vec::new();
84    let first_family = resolve_family(chars[0], families, weight, italic, None, registry);
85    let mut current_family = first_family;
86    let mut run_start = 0;
87
88    for (i, &ch) in chars.iter().enumerate().skip(1) {
89        let family = resolve_family(
90            ch,
91            families,
92            weight,
93            italic,
94            Some(&current_family),
95            registry,
96        );
97        if family != current_family {
98            runs.push(FontRun {
99                start: run_start,
100                end: i,
101                family: current_family,
102            });
103            current_family = family;
104            run_start = i;
105        }
106    }
107
108    // Push final run
109    runs.push(FontRun {
110        start: run_start,
111        end: chars.len(),
112        family: current_family,
113    });
114
115    runs
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_single_font_fast_path() {
124        let registry = FontRegistry::new();
125        let chars: Vec<char> = "Hello world".chars().collect();
126        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
127        assert_eq!(runs.len(), 1);
128        assert_eq!(runs[0].family, "Helvetica");
129        assert_eq!(runs[0].start, 0);
130        assert_eq!(runs[0].end, 11);
131    }
132
133    #[test]
134    fn test_empty_input() {
135        let registry = FontRegistry::new();
136        let chars: Vec<char> = vec![];
137        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
138        assert!(runs.is_empty());
139    }
140
141    #[test]
142    fn test_single_font_builtin_fallback() {
143        let registry = FontRegistry::new();
144        // Cyrillic chars aren't in Helvetica, should fall back to Noto Sans
145        let chars: Vec<char> = "\u{041F}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442}"
146            .chars()
147            .collect();
148        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
149        assert!(!runs.is_empty(), "Should produce at least one run");
150        // All chars should be Noto Sans (since none are in Helvetica)
151        assert_eq!(runs[0].family, "Noto Sans", "Cyrillic should use Noto Sans");
152    }
153
154    #[test]
155    fn test_single_font_mixed_latin_cyrillic() {
156        let registry = FontRegistry::new();
157        // Mix of Latin (in Helvetica) and Cyrillic (not in Helvetica)
158        let chars: Vec<char> = "Hi \u{041F}".chars().collect();
159        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
160        assert!(
161            runs.len() >= 2,
162            "Should have at least 2 runs (Latin + Cyrillic), got {}",
163            runs.len()
164        );
165    }
166
167    #[test]
168    fn test_all_chars_same_font() {
169        let registry = FontRegistry::new();
170        let chars: Vec<char> = "ABC".chars().collect();
171        // Both Helvetica and Times have Latin chars, so first match wins
172        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
173        assert_eq!(runs.len(), 1);
174        assert_eq!(runs[0].family, "Helvetica");
175    }
176}