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    if !families.contains(',') {
42        let family = families.trim().trim_matches('"').trim_matches('\'');
43        let font = registry.resolve(family, weight, italic);
44        let all_covered = chars
45            .iter()
46            .all(|&ch| ch.is_whitespace() || font.has_char(ch));
47        if all_covered {
48            return vec![FontRun {
49                start: 0,
50                end: chars.len(),
51                family: family.to_string(),
52            }];
53        }
54        // Some chars not covered — fall through to per-char resolution
55        // which will try builtin Noto Sans via resolve_for_char()
56    }
57
58    // Slow path: per-character font resolution
59    let mut runs = Vec::new();
60    let (_, first_family) = registry.resolve_for_char(families, chars[0], weight, italic);
61    let mut current_family = first_family;
62    let mut run_start = 0;
63
64    for (i, &ch) in chars.iter().enumerate().skip(1) {
65        let (_, family) = registry.resolve_for_char(families, ch, weight, italic);
66        if family != current_family {
67            runs.push(FontRun {
68                start: run_start,
69                end: i,
70                family: current_family,
71            });
72            current_family = family;
73            run_start = i;
74        }
75    }
76
77    // Push final run
78    runs.push(FontRun {
79        start: run_start,
80        end: chars.len(),
81        family: current_family,
82    });
83
84    runs
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_single_font_fast_path() {
93        let registry = FontRegistry::new();
94        let chars: Vec<char> = "Hello world".chars().collect();
95        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
96        assert_eq!(runs.len(), 1);
97        assert_eq!(runs[0].family, "Helvetica");
98        assert_eq!(runs[0].start, 0);
99        assert_eq!(runs[0].end, 11);
100    }
101
102    #[test]
103    fn test_empty_input() {
104        let registry = FontRegistry::new();
105        let chars: Vec<char> = vec![];
106        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
107        assert!(runs.is_empty());
108    }
109
110    #[test]
111    fn test_single_font_builtin_fallback() {
112        let registry = FontRegistry::new();
113        // Cyrillic chars aren't in Helvetica, should fall back to Noto Sans
114        let chars: Vec<char> = "\u{041F}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442}"
115            .chars()
116            .collect();
117        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
118        assert!(runs.len() >= 1, "Should produce at least one run");
119        // All chars should be Noto Sans (since none are in Helvetica)
120        assert_eq!(runs[0].family, "Noto Sans", "Cyrillic should use Noto Sans");
121    }
122
123    #[test]
124    fn test_single_font_mixed_latin_cyrillic() {
125        let registry = FontRegistry::new();
126        // Mix of Latin (in Helvetica) and Cyrillic (not in Helvetica)
127        let chars: Vec<char> = "Hi \u{041F}".chars().collect();
128        let runs = segment_by_font(&chars, "Helvetica", 400, false, &registry);
129        assert!(
130            runs.len() >= 2,
131            "Should have at least 2 runs (Latin + Cyrillic), got {}",
132            runs.len()
133        );
134    }
135
136    #[test]
137    fn test_all_chars_same_font() {
138        let registry = FontRegistry::new();
139        let chars: Vec<char> = "ABC".chars().collect();
140        // Both Helvetica and Times have Latin chars, so first match wins
141        let runs = segment_by_font(&chars, "Helvetica, Times", 400, false, &registry);
142        assert_eq!(runs.len(), 1);
143        assert_eq!(runs[0].family, "Helvetica");
144    }
145}