Skip to main content

rust_fontconfig/
fallback.rs

1//! Precomputed font fallback chains for resolving font stacks.
2//!
3//! A [`FontFallbackChain`] is a self-contained snapshot that maps a CSS font stack
4//! to the exact fonts that will be used for any character, without requiring
5//! further metadata lookups during layout.
6//!
7//! Resolution order:
8//!
9//! 1. The CSS stack in order.
10//! 2. The script fallback group whose block contains the character.
11//! 3. The configured last resort, without a coverage check.
12
13use alloc::collections::BTreeSet;
14use alloc::string::{String, ToString};
15use alloc::vec::Vec;
16
17use crate::config::{FcFallbackConfig, GenericFamily};
18use crate::{
19    FcFontCache, FcFontCacheInner, FcPattern, FcWeight, FontId, FontMatch, FontMatchNoFallback,
20    PatternMatch, ResolvedFontRun, TraceMsg, UnicodeRange, DEFAULT_UNICODE_FALLBACK_SCRIPTS,
21};
22
23/// How many faces of one family a chain keeps (Regular, Bold, Italic, …
24/// ranked by closeness to the requested style).
25pub const MAX_FACES_PER_FAMILY: usize = 5;
26
27/// How many coverage-ranked fonts a script fallback group keeps beyond the
28/// configured preferences.
29pub const MAX_AUTO_FALLBACKS_PER_SCRIPT: usize = 4;
30
31/// `css_source` reported by [`FontFallbackChain::resolve_char`] for a font
32/// taken from [`FontFallbackChain::unicode_fallbacks`].
33pub const UNICODE_FALLBACK_SOURCE: &str = "(unicode-fallback)";
34
35/// `css_source` reported by [`FontFallbackChain::resolve_char`] for the
36/// configured last resort.
37pub const LAST_RESORT_SOURCE: &str = "(last-resort)";
38
39/// Fonts to try for characters inside one Unicode block, best first.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ScriptFallbackGroup {
42    /// The block this group serves. Groups of one chain do not overlap.
43    pub range: UnicodeRange,
44    /// Candidates in order. A font may appear in several groups — one per
45    /// block it covers.
46    pub fonts: Vec<FontMatch>,
47}
48
49/// The fonts one entry of the CSS font stack resolved to.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CssFallbackGroup {
52    /// The CSS font-family entry as the caller wrote it.
53    pub css_name: String,
54    /// Base candidates, best style match first.
55    pub fonts: Vec<FontMatch>,
56    /// Per-script preferred fonts (used by generic families).
57    pub script_fonts: Vec<ScriptFallbackGroup>,
58}
59
60/// A resolved font fallback chain for one CSS font stack and style.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct FontFallbackChain {
63    /// The CSS stack, entry by entry.
64    pub css_fallbacks: Vec<CssFallbackGroup>,
65    /// One group per requested script block that has any candidate. Fonts
66    /// already reachable through `css_fallbacks` are not repeated here.
67    pub unicode_fallbacks: Vec<ScriptFallbackGroup>,
68    /// [`FcFallbackConfig::last_resort`], resolved. The first entry is used
69    /// for any character nothing above covers.
70    pub last_resort: Vec<FontMatch>,
71    /// The stack as requested, unexpanded.
72    pub original_stack: Vec<String>,
73}
74
75impl FontFallbackChain {
76    /// A chain that resolves nothing, for `stack`.
77    pub fn empty(stack: &[String]) -> Self {
78        Self {
79            css_fallbacks: Vec::new(),
80            unicode_fallbacks: Vec::new(),
81            last_resort: Vec::new(),
82            original_stack: stack.to_vec(),
83        }
84    }
85
86    /// Returns the resolved font for a given codepoint and the CSS fallback tier it came from.
87    pub fn resolve_codepoint(&self, cp: u32) -> Option<(FontId, &str)> {
88        for group in &self.css_fallbacks {
89            for script in &group.script_fonts {
90                if script.range.start <= cp && cp <= script.range.end {
91                    if let Some(m) = script.fonts.iter().find(|m| covers(&m.unicode_ranges, cp)) {
92                        return Some((m.id, group.css_name.as_str()));
93                    }
94                }
95            }
96            if let Some(m) = group.fonts.iter().find(|m| covers(&m.unicode_ranges, cp)) {
97                return Some((m.id, group.css_name.as_str()));
98            }
99        }
100        for script in &self.unicode_fallbacks {
101            if script.range.start <= cp && cp <= script.range.end {
102                if let Some(m) = script.fonts.iter().find(|m| covers(&m.unicode_ranges, cp)) {
103                    return Some((m.id, UNICODE_FALLBACK_SOURCE));
104                }
105            }
106        }
107        self.last_resort.first().map(|m| (m.id, LAST_RESORT_SOURCE))
108    }
109
110    /// Similar to `resolve_codepoint`, but takes a `char` and unused cache reference
111    /// for backward compatibility with 4.x.
112    pub fn resolve_char(&self, _cache: &FcFontCache, ch: char) -> Option<(FontId, String)> {
113        self.resolve_codepoint(ch as u32)
114            .map(|(id, source)| (id, source.to_string()))
115    }
116
117    /// Per-character resolution of `text`.
118    pub fn resolve_text(
119        &self,
120        cache: &FcFontCache,
121        text: &str,
122    ) -> Vec<(char, Option<(FontId, String)>)> {
123        text.chars()
124            .map(|ch| (ch, self.resolve_char(cache, ch)))
125            .collect()
126    }
127
128    /// Split `text` into runs of consecutive characters that resolve to the
129    /// same font. This is the shaping entry point: shape each run with its
130    /// font. A run whose `font_id` is `None` has no font in the chain.
131    pub fn query_for_text(&self, _cache: &FcFontCache, text: &str) -> Vec<ResolvedFontRun> {
132        let mut runs: Vec<ResolvedFontRun> = Vec::new();
133        let mut current: Option<(Option<FontId>, &str)> = None;
134        let mut run_start = 0usize;
135
136        for (byte_idx, ch) in text.char_indices() {
137            let resolved = match self.resolve_codepoint(ch as u32) {
138                Some((id, source)) => (Some(id), source),
139                None => (None, ""),
140            };
141            match current {
142                Some((font, _)) if font == resolved.0 => {}
143                Some((font, source)) => {
144                    runs.push(ResolvedFontRun {
145                        text: text[run_start..byte_idx].to_string(),
146                        start_byte: run_start,
147                        end_byte: byte_idx,
148                        font_id: font,
149                        css_source: source.to_string(),
150                    });
151                    run_start = byte_idx;
152                    current = Some(resolved);
153                }
154                None => current = Some(resolved),
155            }
156        }
157
158        if let Some((font, source)) = current {
159            if run_start < text.len() {
160                runs.push(ResolvedFontRun {
161                    text: text[run_start..].to_string(),
162                    start_byte: run_start,
163                    end_byte: text.len(),
164                    font_id: font,
165                    css_source: source.to_string(),
166                });
167            }
168        }
169
170        runs
171    }
172
173    /// Every font in the chain, in resolution order, each once.
174    pub fn fonts(&self) -> impl Iterator<Item = &FontMatch> {
175        let mut seen = BTreeSet::new();
176        self.css_fallbacks
177            .iter()
178            .flat_map(|g| {
179                g.script_fonts
180                    .iter()
181                    .flat_map(|s| s.fonts.iter())
182                    .chain(g.fonts.iter())
183            })
184            .chain(self.unicode_fallbacks.iter().flat_map(|s| s.fonts.iter()))
185            .chain(self.last_resort.iter())
186            .filter(move |m| seen.insert(m.id))
187    }
188}
189
190/// Memo key for resolved chains. The scripts are hashed in canonical form,
191/// so `None` and an explicit default set share a slot and order does not
192/// matter.
193#[derive(Debug, Clone, PartialEq, Eq, Hash)]
194pub(crate) struct FontChainCacheKey {
195    pub(crate) font_families: Vec<String>,
196    pub(crate) weight: FcWeight,
197    pub(crate) italic: PatternMatch,
198    pub(crate) oblique: PatternMatch,
199    pub(crate) scripts_hash: u64,
200}
201
202fn hash_scripts(ranges: &[UnicodeRange]) -> u64 {
203    let mut sorted: Vec<UnicodeRange> = ranges.to_vec();
204    sorted.sort();
205    sorted.dedup();
206    let mut buf = Vec::with_capacity(sorted.len() * 8);
207    for r in &sorted {
208        buf.extend_from_slice(&r.start.to_le_bytes());
209        buf.extend_from_slice(&r.end.to_le_bytes());
210    }
211    crate::utils::content_hash_u64(&buf)
212}
213
214/// The ordering used wherever font candidates compete. Smaller is better.
215#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
216pub struct RankKey {
217    /// Number of requested codepoints the font does not cover.
218    pub deficit: u32,
219    /// Style score against the request (smaller is closer).
220    pub style: i32,
221    /// 0 for upright, 1 for italic (prefers upright on ties).
222    pub italic: u8,
223    /// How much of the font's coverage lies outside the requested block (prefers dedicated fonts).
224    pub dedication_inv: u64,
225    /// Total codepoint coverage (prefers narrower fonts on ties).
226    pub breadth: u64,
227    /// Font name, for deterministic tie-breaking.
228    pub name: String,
229}
230
231impl RankKey {
232    /// Rank `candidate` for characters of `block` under `style`. `None` when
233    /// the candidate covers nothing of the block.
234    pub fn for_block(
235        style: &FcPattern,
236        candidate: &FcPattern,
237        block: &UnicodeRange,
238    ) -> Option<Self> {
239        let overlap = overlap_size(&candidate.unicode_ranges, block);
240        if overlap == 0 {
241            return None;
242        }
243        let breadth = breadth(&candidate.unicode_ranges);
244        Some(Self {
245            deficit: range_width(block).saturating_sub(overlap),
246            style: FcFontCache::calculate_style_score(style, candidate),
247            italic: (candidate.italic == PatternMatch::True) as u8,
248            dedication_inv: breadth.saturating_mul(1024) / overlap as u64,
249            breadth,
250            name: candidate.name.clone().unwrap_or_default(),
251        })
252    }
253
254    /// Rank `candidate` based only on style. Narrower fonts win ties.
255    pub fn for_style(style: &FcPattern, candidate: &FcPattern) -> Self {
256        Self {
257            deficit: 0,
258            style: FcFontCache::calculate_style_score(style, candidate),
259            italic: (candidate.italic == PatternMatch::True) as u8,
260            dedication_inv: 0,
261            breadth: breadth(&candidate.unicode_ranges),
262            name: candidate.name.clone().unwrap_or_default(),
263        }
264    }
265
266    /// Rank `candidate` against a requested range set by style and missing coverage.
267    pub fn for_request(
268        style: &FcPattern,
269        candidate: &FcPattern,
270        requested: &[UnicodeRange],
271    ) -> Self {
272        let (requested_width, overlap) = requested.iter().fold((0u32, 0u32), |(w, o), r| {
273            (
274                w.saturating_add(range_width(r)),
275                o.saturating_add(overlap_size(&candidate.unicode_ranges, r)),
276            )
277        });
278        Self {
279            deficit: 0,
280            style: FcFontCache::calculate_style_score(style, candidate),
281            italic: (candidate.italic == PatternMatch::True) as u8,
282            dedication_inv: requested_width.saturating_sub(overlap) as u64,
283            breadth: breadth(&candidate.unicode_ranges),
284            name: candidate.name.clone().unwrap_or_default(),
285        }
286    }
287}
288
289/// Returns `true` if `ranges` contains `cp`. `ranges` must be sorted and disjoint.
290pub fn covers(ranges: &[UnicodeRange], cp: u32) -> bool {
291    let i = ranges.partition_point(|r| r.end < cp);
292    ranges.get(i).is_some_and(|r| r.start <= cp)
293}
294
295/// Width of a range in codepoints.
296pub fn range_width(r: &UnicodeRange) -> u32 {
297    r.end.saturating_sub(r.start).saturating_add(1)
298}
299
300/// Number of codepoints in `block` that `ranges` cover, capped at the block's width.
301pub fn overlap_size(ranges: &[UnicodeRange], block: &UnicodeRange) -> u32 {
302    let mut total = 0u32;
303    let first = ranges.partition_point(|r| r.end < block.start);
304    for r in &ranges[first..] {
305        if r.start > block.end {
306            break;
307        }
308        let start = r.start.max(block.start);
309        let end = r.end.min(block.end);
310        if start <= end {
311            total = total.saturating_add(end - start + 1);
312        }
313    }
314    total.min(range_width(block))
315}
316
317/// Total coverage in codepoints.
318pub fn breadth(ranges: &[UnicodeRange]) -> u64 {
319    ranges.iter().map(|r| range_width(r) as u64).sum()
320}
321
322fn style_request(weight: FcWeight, italic: PatternMatch, oblique: PatternMatch) -> FcPattern {
323    FcPattern {
324        weight,
325        italic,
326        oblique,
327        ..Default::default()
328    }
329}
330
331fn font_match(id: FontId, meta: &FcPattern) -> FontMatch {
332    FontMatch {
333        id,
334        unicode_ranges: meta.unicode_ranges.clone(),
335        fallbacks: Vec::new(),
336    }
337}
338
339fn ids(fonts: &[FontMatch]) -> impl Iterator<Item = FontId> + '_ {
340    fonts.iter().map(|m| m.id)
341}
342
343/// The installed faces of `family` (normalized name equality), best style
344/// first, skipping `exclude`.
345fn faces_for_family(
346    state: &FcFontCacheInner,
347    family: &str,
348    style: &FcPattern,
349    exclude: &BTreeSet<FontId>,
350) -> Vec<FontMatch> {
351    let key = crate::utils::normalize_family_name(family);
352    if key.is_empty() {
353        return Vec::new();
354    }
355    let Some(ids) = state.family_index.get(&key) else {
356        return Vec::new();
357    };
358    let mut ranked: Vec<(RankKey, FontId, &FcPattern)> = ids
359        .iter()
360        .filter(|id| !exclude.contains(id))
361        .filter_map(|id| {
362            let meta = state.metadata.get(id)?;
363            Some((RankKey::for_style(style, meta), *id, meta))
364        })
365        .collect();
366    ranked.sort();
367    ranked.truncate(MAX_FACES_PER_FAMILY);
368    ranked
369        .into_iter()
370        .map(|(_, id, meta)| font_match(id, meta))
371        .collect()
372}
373
374/// The faces of every family in `families`, in that order, each font once,
375/// skipping `exclude`.
376fn faces_for_families(
377    state: &FcFontCacheInner,
378    families: &[String],
379    style: &FcPattern,
380    exclude: &BTreeSet<FontId>,
381) -> Vec<FontMatch> {
382    let mut out: Vec<FontMatch> = Vec::new();
383    let mut seen = exclude.clone();
384    for family in families {
385        for m in faces_for_family(state, family, style, &seen) {
386            seen.insert(m.id);
387            out.push(m);
388        }
389    }
390    out
391}
392
393/// Finds the best faces matching the requested style globally, ignoring names.
394fn faces_unconfigured(state: &FcFontCacheInner, style: &FcPattern) -> Vec<FontMatch> {
395    let mut ranked: Vec<(RankKey, FontId, &FcPattern)> = state
396        .metadata
397        .iter()
398        .map(|(id, meta)| (RankKey::for_style(style, meta), *id, meta))
399        .collect();
400    ranked.sort();
401    ranked.truncate(MAX_FACES_PER_FAMILY);
402    ranked
403        .into_iter()
404        .map(|(_, id, meta)| font_match(id, meta))
405        .collect()
406}
407
408/// Every registered font that covers any of `block`, best first by
409/// [`RankKey::for_block`], at most `limit`, skipping `exclude`.
410fn ranked_coverage_candidates(
411    state: &FcFontCacheInner,
412    block: &UnicodeRange,
413    style: &FcPattern,
414    exclude: &BTreeSet<FontId>,
415    limit: usize,
416) -> Vec<FontMatch> {
417    let mut ranked: Vec<(RankKey, FontId, &FcPattern)> = state
418        .metadata
419        .iter()
420        .filter(|(id, _)| !exclude.contains(id))
421        .filter_map(|(id, meta)| Some((RankKey::for_block(style, meta, block)?, *id, meta)))
422        .collect();
423    ranked.sort();
424    ranked.truncate(limit);
425    ranked
426        .into_iter()
427        .map(|(_, id, meta)| font_match(id, meta))
428        .collect()
429}
430
431/// Build the chain for `stack` from the cache state and `config`. Pure over
432/// its inputs; the caller holds the read guard.
433pub(crate) fn build_chain(
434    state: &FcFontCacheInner,
435    config: &FcFallbackConfig,
436    stack: &[String],
437    style: &FcPattern,
438    scripts: &[UnicodeRange],
439) -> FontFallbackChain {
440    let mut css_base: BTreeSet<FontId> = BTreeSet::new();
441    let mut css_all: BTreeSet<FontId> = BTreeSet::new();
442    let mut generics_in_stack: Vec<GenericFamily> = Vec::new();
443    let mut css_fallbacks: Vec<CssFallbackGroup> = Vec::with_capacity(stack.len());
444
445    for family in stack {
446        match GenericFamily::from_css(family) {
447            Some(generic) => {
448                if !generics_in_stack.contains(&generic) {
449                    generics_in_stack.push(generic);
450                }
451                let fonts =
452                    faces_for_families(state, config.generic_candidates(generic), style, &css_base);
453                css_base.extend(ids(&fonts));
454                css_all.extend(ids(&fonts));
455
456                let mut script_fonts = Vec::new();
457                for block in scripts {
458                    let names = config.script_candidates(Some(generic), block);
459                    let fonts = faces_for_families(state, &names, style, &css_base);
460                    if !fonts.is_empty() {
461                        css_all.extend(ids(&fonts));
462                        script_fonts.push(ScriptFallbackGroup {
463                            range: *block,
464                            fonts,
465                        });
466                    }
467                }
468                css_fallbacks.push(CssFallbackGroup {
469                    css_name: family.clone(),
470                    fonts,
471                    script_fonts,
472                });
473            }
474            None => {
475                let mut fonts = faces_for_family(state, family, style, &css_base);
476                if fonts.is_empty() {
477                    fonts = faces_for_families(
478                        state,
479                        config.substitutions_for(family),
480                        style,
481                        &css_base,
482                    );
483                }
484                css_base.extend(ids(&fonts));
485                css_all.extend(ids(&fonts));
486                css_fallbacks.push(CssFallbackGroup {
487                    css_name: family.clone(),
488                    fonts,
489                    script_fonts: Vec::new(),
490                });
491            }
492        }
493    }
494
495    // If the stack matched nothing, fallback to best unconfigured fonts.
496    if css_all.is_empty() {
497        for group in &mut css_fallbacks {
498            if GenericFamily::from_css(&group.css_name).is_some() {
499                group.fonts = faces_unconfigured(state, style);
500                css_all.extend(ids(&group.fonts));
501            }
502        }
503    }
504
505    // Script tier: Uses preferences from stack generics or the default generic.
506    let preference_generics: Vec<GenericFamily> = if generics_in_stack.is_empty() {
507        alloc::vec![config.default_generic]
508    } else {
509        generics_in_stack
510    };
511    let mut unicode_fallbacks: Vec<ScriptFallbackGroup> = Vec::new();
512    for block in scripts {
513        let mut fonts: Vec<FontMatch> = Vec::new();
514        let mut seen = css_all.clone();
515        let mut take = |found: Vec<FontMatch>, seen: &mut BTreeSet<FontId>| {
516            for m in found {
517                seen.insert(m.id);
518                fonts.push(m);
519            }
520        };
521        for generic in &preference_generics {
522            let names = config.script_candidates(Some(*generic), block);
523            take(faces_for_families(state, &names, style, &seen), &mut seen);
524        }
525        let names = config.script_candidates(None, block);
526        take(faces_for_families(state, &names, style, &seen), &mut seen);
527        take(
528            ranked_coverage_candidates(state, block, style, &seen, MAX_AUTO_FALLBACKS_PER_SCRIPT),
529            &mut seen,
530        );
531        if !fonts.is_empty() {
532            unicode_fallbacks.push(ScriptFallbackGroup {
533                range: *block,
534                fonts,
535            });
536        }
537    }
538
539    let last_resort = faces_for_families(state, &config.last_resort, style, &BTreeSet::new());
540
541    FontFallbackChain {
542        css_fallbacks,
543        unicode_fallbacks,
544        last_resort,
545        original_stack: stack.to_vec(),
546    }
547}
548
549impl FcFontCache {
550    /// Resolve a fallback chain for a CSS font stack with the default script set.
551    pub fn resolve_font_chain(
552        &self,
553        font_families: &[String],
554        weight: FcWeight,
555        italic: PatternMatch,
556        oblique: PatternMatch,
557        trace: &mut Vec<TraceMsg>,
558    ) -> FontFallbackChain {
559        self.resolve_font_chain_with_scripts(font_families, weight, italic, oblique, None, trace)
560    }
561
562    /// Resolve a fallback chain, building script fallback groups for the requested unicode blocks.
563    pub fn resolve_font_chain_with_scripts(
564        &self,
565        font_families: &[String],
566        weight: FcWeight,
567        italic: PatternMatch,
568        oblique: PatternMatch,
569        scripts_hint: Option<&[UnicodeRange]>,
570        _trace: &mut Vec<TraceMsg>,
571    ) -> FontFallbackChain {
572        let scripts = scripts_hint.unwrap_or(DEFAULT_UNICODE_FALLBACK_SCRIPTS);
573        let key = FontChainCacheKey {
574            font_families: font_families.to_vec(),
575            weight,
576            italic,
577            oblique,
578            scripts_hash: hash_scripts(scripts),
579        };
580
581        {
582            let memo = match self.shared.chain_cache.lock() {
583                Ok(m) => m,
584                Err(e) => match e {},
585            };
586            if let Some(chain) = memo.get(&key) {
587                return chain.clone();
588            }
589        }
590
591        let chain = {
592            let state = self.state_read();
593            build_chain(
594                &state,
595                &state.fallback_config,
596                font_families,
597                &style_request(weight, italic, oblique),
598                scripts,
599            )
600        };
601
602        let mut memo = match self.shared.chain_cache.lock() {
603            Ok(m) => m,
604            Err(e) => match e {},
605        };
606        memo.insert(key, chain.clone());
607        chain
608    }
609
610    /// Finds all registered fonts covering part of `font_id`'s coverage, ranked by closeness.
611    pub fn compute_fallbacks(
612        &self,
613        font_id: &FontId,
614        _trace: &mut Vec<TraceMsg>,
615    ) -> Vec<FontMatchNoFallback> {
616        let state = self.state_read();
617        let Some(pattern) = state.metadata.get(font_id) else {
618            return Vec::new();
619        };
620        let requested: &[UnicodeRange] = if pattern.unicode_ranges.is_empty() {
621            DEFAULT_UNICODE_FALLBACK_SCRIPTS
622        } else {
623            &pattern.unicode_ranges
624        };
625        let mut ranked: Vec<(RankKey, FontId, &FcPattern)> = state
626            .metadata
627            .iter()
628            .filter(|(id, meta)| {
629                *id != font_id
630                    && requested
631                        .iter()
632                        .any(|r| overlap_size(&meta.unicode_ranges, r) > 0)
633            })
634            .map(|(id, meta)| (RankKey::for_request(pattern, meta, requested), *id, meta))
635            .collect();
636        ranked.sort();
637        ranked
638            .into_iter()
639            .map(|(_, id, meta)| FontMatchNoFallback {
640                id,
641                unicode_ranges: meta.unicode_ranges.clone(),
642            })
643            .collect()
644    }
645}