Skip to main content

cosmic_text/font/
system.rs

1use crate::{Attrs, Font, FontMatchAttrs, HashMap, ShapeBuffer};
2use alloc::boxed::Box;
3use alloc::collections::BTreeSet;
4use alloc::string::String;
5use alloc::sync::Arc;
6use alloc::vec::Vec;
7use core::fmt;
8use core::ops::{Deref, DerefMut};
9use fontdb::{FaceInfo, Query, Style};
10use skrifa::raw::{ReadError, TableProvider as _};
11use skrifa::MetadataProvider;
12
13// re-export fontdb and harfrust
14pub use fontdb;
15pub use harfrust;
16
17use super::fallback::{Fallback, Fallbacks, MonospaceFallbackInfo, PlatformFallback};
18
19// The fields are used in the derived Ord implementation for sorting fallback candidates.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub struct FontMatchKey {
22    pub(crate) not_emoji: bool,
23    pub(crate) font_weight_diff: u16,
24    pub(crate) font_stretch_diff: u16,
25    pub(crate) font_style_diff: u8,
26    pub(crate) font_weight: u16,
27    pub(crate) font_stretch: u16,
28    pub(crate) id: fontdb::ID,
29    pub(crate) variable_weight_match: bool,
30}
31
32impl FontMatchKey {
33    fn new(attrs: &Attrs, face: &FaceInfo, db: &fontdb::Database) -> FontMatchKey {
34        // TODO: smarter way of detecting emoji
35        let not_emoji = !face.post_script_name.contains("Emoji");
36        let font_weight_diff = attrs.weight.0.abs_diff(face.weight.0);
37
38        let variable_weight_match = font_weight_diff != 0
39            && db.with_face_data(face.id, |font_data, face_index| {
40                let font_ref = skrifa::FontRef::from_index(font_data, face_index).ok()?;
41                let axis = font_ref.axes().get_by_tag(skrifa::Tag::new(b"wght"))?;
42                let w = attrs.weight.0 as f32;
43                Some(w >= axis.min_value() && w <= axis.max_value())
44            }) == Some(Some(true));
45        let font_weight = face.weight.0;
46        let font_stretch_diff = attrs.stretch.to_number().abs_diff(face.stretch.to_number());
47        let font_stretch = face.stretch.to_number();
48        let font_style_diff = match (attrs.style, face.style) {
49            (Style::Normal, Style::Normal)
50            | (Style::Italic, Style::Italic)
51            | (Style::Oblique, Style::Oblique) => 0,
52            (Style::Italic, Style::Oblique) | (Style::Oblique, Style::Italic) => 1,
53            (Style::Normal, Style::Italic)
54            | (Style::Normal, Style::Oblique)
55            | (Style::Italic, Style::Normal)
56            | (Style::Oblique, Style::Normal) => 2,
57        };
58        let id = face.id;
59        FontMatchKey {
60            not_emoji,
61            font_weight_diff,
62            font_stretch_diff,
63            font_style_diff,
64            font_weight,
65            font_stretch,
66            id,
67            variable_weight_match,
68        }
69    }
70}
71
72struct FontCachedCodepointSupportInfo {
73    supported: Vec<u32>,
74    not_supported: Vec<u32>,
75}
76
77impl FontCachedCodepointSupportInfo {
78    const SUPPORTED_MAX_SZ: usize = 512;
79    const NOT_SUPPORTED_MAX_SZ: usize = 1024;
80
81    fn new() -> Self {
82        Self {
83            supported: Vec::with_capacity(Self::SUPPORTED_MAX_SZ),
84            not_supported: Vec::with_capacity(Self::NOT_SUPPORTED_MAX_SZ),
85        }
86    }
87
88    #[inline(always)]
89    fn unknown_has_codepoint(
90        &mut self,
91        font_codepoints: &[u32],
92        codepoint: u32,
93        supported_insert_pos: usize,
94        not_supported_insert_pos: usize,
95    ) -> bool {
96        let ret = font_codepoints.contains(&codepoint);
97        if ret {
98            // don't bother inserting if we are going to truncate the entry away
99            if supported_insert_pos != Self::SUPPORTED_MAX_SZ {
100                self.supported.insert(supported_insert_pos, codepoint);
101                self.supported.truncate(Self::SUPPORTED_MAX_SZ);
102            }
103        } else {
104            // don't bother inserting if we are going to truncate the entry away
105            if not_supported_insert_pos != Self::NOT_SUPPORTED_MAX_SZ {
106                self.not_supported
107                    .insert(not_supported_insert_pos, codepoint);
108                self.not_supported.truncate(Self::NOT_SUPPORTED_MAX_SZ);
109            }
110        }
111        ret
112    }
113
114    #[inline(always)]
115    fn has_codepoint(&mut self, font_codepoints: &[u32], codepoint: u32) -> bool {
116        match self.supported.binary_search(&codepoint) {
117            Ok(_) => true,
118            Err(supported_insert_pos) => match self.not_supported.binary_search(&codepoint) {
119                Ok(_) => false,
120                Err(not_supported_insert_pos) => self.unknown_has_codepoint(
121                    font_codepoints,
122                    codepoint,
123                    supported_insert_pos,
124                    not_supported_insert_pos,
125                ),
126            },
127        }
128    }
129}
130
131/// Access to the system fonts.
132pub struct FontSystem {
133    /// The locale of the system.
134    locale: String,
135
136    /// The underlying font database.
137    db: fontdb::Database,
138
139    /// Cache for loaded fonts from the database.
140    font_cache: HashMap<(fontdb::ID, fontdb::Weight), Option<Arc<Font>>>,
141
142    /// Sorted unique ID's of all Monospace fonts in DB
143    monospace_font_ids: Vec<fontdb::ID>,
144
145    /// Sorted unique ID's of all Monospace fonts in DB per script.
146    /// A font may support multiple scripts of course, so the same ID
147    /// may appear in multiple map value vecs.
148    per_script_monospace_font_ids: HashMap<[u8; 4], Vec<fontdb::ID>>,
149
150    /// Cache for font codepoint support info
151    font_codepoint_support_info_cache: HashMap<fontdb::ID, FontCachedCodepointSupportInfo>,
152
153    /// Cache for font matches.
154    font_matches_cache: HashMap<FontMatchAttrs, Arc<Vec<FontMatchKey>>>,
155
156    /// Scratch buffer for shaping and laying out.
157    pub(crate) shape_buffer: ShapeBuffer,
158
159    /// Buffer for use in `FontFallbackIter`.
160    pub(crate) monospace_fallbacks_buffer: BTreeSet<MonospaceFallbackInfo>,
161
162    /// Cache for shaped runs
163    #[cfg(feature = "shape-run-cache")]
164    pub shape_run_cache: crate::ShapeRunCache,
165
166    /// List of fallbacks
167    pub(crate) dyn_fallback: Box<dyn Fallback>,
168
169    /// List of fallbacks
170    pub(crate) fallbacks: Fallbacks,
171}
172
173impl fmt::Debug for FontSystem {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.debug_struct("FontSystem")
176            .field("locale", &self.locale)
177            .field("db", &self.db)
178            .finish_non_exhaustive()
179    }
180}
181
182impl FontSystem {
183    const FONT_MATCHES_CACHE_SIZE_LIMIT: usize = 256;
184    /// Create a new [`FontSystem`], that allows access to any installed system fonts
185    ///
186    /// # Timing
187    ///
188    /// This function takes some time to run. On the release build, it can take up to a second,
189    /// while debug builds can take up to ten times longer. For this reason, it should only be
190    /// called once, and the resulting [`FontSystem`] should be shared.
191    pub fn new() -> Self {
192        Self::new_with_fonts(core::iter::empty())
193    }
194
195    /// Create a new [`FontSystem`] with a pre-specified set of fonts.
196    pub fn new_with_fonts(fonts: impl IntoIterator<Item = fontdb::Source>) -> Self {
197        let mut db = fontdb::Database::new();
198        Self::load_fonts(&mut db, fonts.into_iter());
199        Self::finish_with_db(db)
200    }
201
202    /// Create a new [`FontSystem`] backed by a persistent on-disk system-font index cache
203    /// at the platform's conventional cache location
204    /// (e.g. `$XDG_CACHE_HOME/cosmic-text/fonts.cache`).
205    ///
206    /// Falls back to a normal, uncached scan if no cache directory can be determined.
207    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
208    pub fn new_cached() -> Self {
209        Self::new_with_fonts_and_cache(core::iter::empty())
210    }
211
212    /// Returns the default system-font cache file path used by [`FontSystem::new_cached`]
213    /// and [`FontSystem::new_with_fonts_and_cache`]
214    /// (`<cache-dir>/cosmic-text/fonts.cache`, following the platform's conventional cache
215    /// directory).
216    ///
217    /// Returns `None` if no cache directory can be determined from the environment.
218    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
219    pub fn default_cache_path() -> Option<std::path::PathBuf> {
220        super::cache::default_cache_path()
221    }
222
223    /// Like [`FontSystem::new_with_fonts`], but reads from and writes to a persistent
224    /// system-font index cache at the default platform cache location (see
225    /// [`FontSystem::new_cached`]).
226    ///
227    /// The cache path is resolved automatically; if no cache directory can be determined
228    /// this falls back to a normal, uncached scan.
229    /// The user-provided `fonts` are always loaded fresh and
230    /// are never persisted to the cache
231    ///
232    /// To cache at an explicit location instead, use
233    /// [`FontSystem::new_with_fonts_and_cache_path`].
234    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
235    pub fn new_with_fonts_and_cache(fonts: impl IntoIterator<Item = fontdb::Source>) -> Self {
236        Self::new_with_fonts_and_cache_inner(fonts, Self::default_cache_path())
237    }
238
239    /// Like [`FontSystem::new_with_fonts_and_cache`], but uses the persistent system-font
240    /// index cache at the explicitly provided `cache_path` rather than the default
241    /// location.
242    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
243    pub fn new_with_fonts_and_cache_path(
244        fonts: impl IntoIterator<Item = fontdb::Source>,
245        cache_path: std::path::PathBuf,
246    ) -> Self {
247        Self::new_with_fonts_and_cache_inner(fonts, Some(cache_path))
248    }
249
250    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
251    fn new_with_fonts_and_cache_inner(
252        fonts: impl IntoIterator<Item = fontdb::Source>,
253        cache_path: Option<std::path::PathBuf>,
254    ) -> Self {
255        let mut db = fontdb::Database::new();
256
257        let now = std::time::Instant::now();
258
259        match cache_path {
260            Some(cache_path) => super::cache::load_system_fonts_cached(&mut db, &cache_path),
261            None => db.load_system_fonts(),
262        }
263        for source in fonts {
264            db.load_font_source(source);
265        }
266
267        log::debug!(
268            "Loaded {} font faces in {}ms.",
269            db.len(),
270            now.elapsed().as_millis()
271        );
272
273        Self::finish_with_db(db)
274    }
275
276    /// Apply the default font families and finish constructing the [`FontSystem`] from a
277    /// loaded font database.
278    fn finish_with_db(mut db: fontdb::Database) -> Self {
279        let locale = Self::get_locale();
280        log::debug!("Locale: {locale}");
281
282        //TODO: configurable default fonts
283        db.set_monospace_family("Noto Sans Mono");
284        db.set_sans_serif_family("Open Sans");
285        db.set_serif_family("DejaVu Serif");
286
287        Self::new_with_locale_and_db_and_fallback(locale, db, PlatformFallback)
288    }
289
290    /// Create a new [`FontSystem`] with a pre-specified locale, font database and font fallback list.
291    pub fn new_with_locale_and_db_and_fallback(
292        locale: String,
293        db: fontdb::Database,
294        impl_fallback: impl Fallback + 'static,
295    ) -> Self {
296        let mut monospace_font_ids = db
297            .faces()
298            .filter(|face_info| {
299                face_info.monospaced && !face_info.post_script_name.contains("Emoji")
300            })
301            .map(|face_info| face_info.id)
302            .collect::<Vec<_>>();
303        monospace_font_ids.sort();
304
305        let mut per_script_monospace_font_ids: HashMap<[u8; 4], BTreeSet<fontdb::ID>> =
306            HashMap::default();
307
308        if cfg!(feature = "monospace_fallback") {
309            for &id in &monospace_font_ids {
310                db.with_face_data(id, |font_data, face_index| {
311                    let face = skrifa::FontRef::from_index(font_data, face_index)?;
312                    for script in face
313                        .gpos()?
314                        .script_list()?
315                        .script_records()
316                        .iter()
317                        .chain(face.gsub()?.script_list()?.script_records().iter())
318                    {
319                        per_script_monospace_font_ids
320                            .entry(script.script_tag().into_bytes())
321                            .or_default()
322                            .insert(id);
323                    }
324                    Ok::<_, ReadError>(())
325                });
326            }
327        }
328
329        let per_script_monospace_font_ids = per_script_monospace_font_ids
330            .into_iter()
331            .map(|(k, v)| (k, Vec::from_iter(v)))
332            .collect();
333
334        let fallbacks = Fallbacks::new(&impl_fallback, &[], &locale);
335
336        Self {
337            locale,
338            db,
339            monospace_font_ids,
340            per_script_monospace_font_ids,
341            font_cache: HashMap::default(),
342            font_matches_cache: HashMap::default(),
343            font_codepoint_support_info_cache: HashMap::default(),
344            monospace_fallbacks_buffer: BTreeSet::default(),
345            #[cfg(feature = "shape-run-cache")]
346            shape_run_cache: crate::ShapeRunCache::default(),
347            shape_buffer: ShapeBuffer::default(),
348            dyn_fallback: Box::new(impl_fallback),
349            fallbacks,
350        }
351    }
352
353    /// Create a new [`FontSystem`] with a pre-specified locale and font database.
354    pub fn new_with_locale_and_db(locale: String, db: fontdb::Database) -> Self {
355        Self::new_with_locale_and_db_and_fallback(locale, db, PlatformFallback)
356    }
357
358    /// Get the locale.
359    pub fn locale(&self) -> &str {
360        &self.locale
361    }
362
363    /// Get the database.
364    pub const fn db(&self) -> &fontdb::Database {
365        &self.db
366    }
367
368    /// Get a mutable reference to the database.
369    pub fn db_mut(&mut self) -> &mut fontdb::Database {
370        self.font_matches_cache.clear();
371        &mut self.db
372    }
373
374    /// Consume this [`FontSystem`] and return the locale and database.
375    pub fn into_locale_and_db(self) -> (String, fontdb::Database) {
376        (self.locale, self.db)
377    }
378
379    /// Get a font by its ID and weight.
380    pub fn get_font(&mut self, id: fontdb::ID, weight: fontdb::Weight) -> Option<Arc<Font>> {
381        self.font_cache
382            .entry((id, weight))
383            .or_insert_with(|| {
384                #[cfg(feature = "std")]
385                unsafe {
386                    self.db.make_shared_face_data(id);
387                }
388                if let Some(font) = Font::new(&self.db, id, weight) {
389                    Some(Arc::new(font))
390                } else {
391                    log::warn!(
392                        "failed to load font '{}'",
393                        self.db.face(id)?.post_script_name
394                    );
395                    None
396                }
397            })
398            .clone()
399    }
400
401    pub fn is_monospace(&self, id: fontdb::ID) -> bool {
402        self.monospace_font_ids.binary_search(&id).is_ok()
403    }
404
405    pub fn get_monospace_ids_for_scripts(
406        &self,
407        scripts: impl Iterator<Item = [u8; 4]>,
408    ) -> Vec<fontdb::ID> {
409        let mut ret = scripts
410            .filter_map(|script| self.per_script_monospace_font_ids.get(&script))
411            .flat_map(|ids| ids.iter().copied())
412            .collect::<Vec<_>>();
413        ret.sort();
414        ret.dedup();
415        ret
416    }
417
418    #[inline(always)]
419    pub fn get_font_supported_codepoints_in_word(
420        &mut self,
421        id: fontdb::ID,
422        weight: fontdb::Weight,
423        word: &str,
424    ) -> Option<usize> {
425        self.get_font(id, weight).map(|font| {
426            let code_points = font.unicode_codepoints();
427            let cache = self
428                .font_codepoint_support_info_cache
429                .entry(id)
430                .or_insert_with(FontCachedCodepointSupportInfo::new);
431            word.chars()
432                .filter(|ch| cache.has_codepoint(code_points, u32::from(*ch)))
433                .count()
434        })
435    }
436
437    pub fn get_font_matches(&mut self, attrs: &Attrs<'_>) -> Arc<Vec<FontMatchKey>> {
438        // Clear the cache first if it reached the size limit
439        if self.font_matches_cache.len() >= Self::FONT_MATCHES_CACHE_SIZE_LIMIT {
440            log::trace!("clear font mache cache");
441            self.font_matches_cache.clear();
442        }
443
444        self.font_matches_cache
445            //TODO: do not create AttrsOwned unless entry does not already exist
446            .entry(attrs.into())
447            .or_insert_with(|| {
448                #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
449                let now = std::time::Instant::now();
450
451                let mut font_match_keys = self
452                    .db
453                    .faces()
454                    .map(|face| FontMatchKey::new(attrs, face, &self.db))
455                    .collect::<Vec<_>>();
456
457                // Sort so we get the keys with weight_offset=0 first
458                font_match_keys.sort();
459
460                // db.query is better than above, but returns just one font
461                let query = Query {
462                    families: &[attrs.family],
463                    weight: attrs.weight,
464                    stretch: attrs.stretch,
465                    style: attrs.style,
466                };
467
468                if let Some(id) = self.db.query(&query) {
469                    if let Some(i) = font_match_keys
470                        .iter()
471                        .enumerate()
472                        .find(|(_i, key)| key.id == id)
473                        .map(|(i, _)| i)
474                    {
475                        // if exists move to front
476                        let match_key = font_match_keys.remove(i);
477                        font_match_keys.insert(0, match_key);
478                    } else if let Some(face) = self.db.face(id) {
479                        // else insert in front
480                        let match_key = FontMatchKey::new(attrs, face, &self.db);
481                        font_match_keys.insert(0, match_key);
482                    } else {
483                        log::error!("Could not get face from db, that should've been there.");
484                    }
485                }
486
487                #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
488                {
489                    let elapsed = now.elapsed();
490                    log::debug!("font matches for {attrs:?} in {elapsed:?}");
491                }
492
493                Arc::new(font_match_keys)
494            })
495            .clone()
496    }
497
498    #[cfg(feature = "std")]
499    fn get_locale() -> String {
500        sys_locale::get_locale().unwrap_or_else(|| {
501            log::warn!("failed to get system locale, falling back to en-US");
502            String::from("en-US")
503        })
504    }
505
506    #[cfg(not(feature = "std"))]
507    fn get_locale() -> String {
508        String::from("en-US")
509    }
510
511    #[cfg(feature = "std")]
512    fn load_fonts(db: &mut fontdb::Database, fonts: impl Iterator<Item = fontdb::Source>) {
513        #[cfg(not(target_arch = "wasm32"))]
514        let now = std::time::Instant::now();
515
516        db.load_system_fonts();
517
518        for source in fonts {
519            db.load_font_source(source);
520        }
521
522        #[cfg(not(target_arch = "wasm32"))]
523        log::debug!(
524            "Parsed {} font faces in {}ms.",
525            db.len(),
526            now.elapsed().as_millis()
527        );
528    }
529
530    #[cfg(not(feature = "std"))]
531    fn load_fonts(db: &mut fontdb::Database, fonts: impl Iterator<Item = fontdb::Source>) {
532        for source in fonts {
533            db.load_font_source(source);
534        }
535    }
536}
537
538/// A value borrowed together with an [`FontSystem`]
539#[derive(Debug)]
540pub struct BorrowedWithFontSystem<'a, T> {
541    pub(crate) inner: &'a mut T,
542    pub(crate) font_system: &'a mut FontSystem,
543}
544
545impl<T> Deref for BorrowedWithFontSystem<'_, T> {
546    type Target = T;
547
548    fn deref(&self) -> &Self::Target {
549        self.inner
550    }
551}
552
553impl<T> DerefMut for BorrowedWithFontSystem<'_, T> {
554    fn deref_mut(&mut self) -> &mut Self::Target {
555        self.inner
556    }
557}