Skip to main content

motion_content/
font.rs

1//! Explicit immutable font snapshots shared by text and SVG parsing.
2
3use std::sync::{
4    Arc, Mutex, MutexGuard, OnceLock,
5    atomic::{AtomicU64, Ordering},
6};
7
8#[cfg(test)]
9#[path = "font/tests.rs"]
10mod explicit_face_tests;
11
12/// Maximum source bytes accepted by one explicitly loaded font.
13pub const MAX_FONT_FACE_BYTES: usize = 32 * 1024 * 1024;
14
15/// A face identifier within one immutable font-library snapshot.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct FontIdentifier(pub(crate) fontdb::ID);
18
19/// A copy-on-write font database. Construction never discovers system fonts.
20/// Registered source bytes are retained; cloned libraries share snapshots.
21#[derive(Clone)]
22pub struct FontLibrary {
23    pub(crate) database: Arc<fontdb::Database>,
24    bytes: usize,
25    snapshot_id: u64,
26    font_system: Arc<OnceLock<Mutex<cosmic_text::FontSystem>>>,
27    pub(crate) glyph_cache: Arc<motion_core::SceneCache>,
28    pub(crate) layout_cache: crate::text::SharedLayouts,
29    used_fonts: Arc<
30        Mutex<
31            std::collections::BTreeMap<cosmic_text::fontdb::ID, (motion_core::FontIdentity, usize)>,
32        >,
33    >,
34}
35
36impl FontLibrary {
37    /// Creates an empty library with no filesystem access or discovery.
38    pub fn new() -> Self {
39        Self {
40            database: Arc::new(fontdb::Database::new()),
41            bytes: 0,
42            snapshot_id: identity(),
43            font_system: Arc::new(OnceLock::new()),
44            glyph_cache: Arc::new(motion_core::SceneCache::with_limits(
45                motion_core::CacheLimits {
46                    entries: 2048,
47                    bytes: 16 * 1024 * 1024,
48                },
49            )),
50            layout_cache: Default::default(),
51            used_fonts: Arc::default(),
52        }
53    }
54
55    /// Loads one explicit font file into immutable memory, never a lazy file source.
56    pub fn load_file(
57        &mut self,
58        path: impl AsRef<std::path::Path>,
59    ) -> motion_core::Result<Vec<FontIdentifier>> {
60        use std::io::Read;
61        let path = path.as_ref();
62        let metadata = std::fs::metadata(path)?;
63        if !metadata.is_file() || metadata.len() > 256 * 1024 * 1024 {
64            return Err(invalid("Font file must be regular and at most 256 MiB"));
65        }
66        let mut bytes = Vec::new();
67        std::fs::File::open(path)?
68            .take(256 * 1024 * 1024 + 1)
69            .read_to_end(&mut bytes)?;
70        self.load_data(bytes.into())
71            .map_err(|error| motion_core::Error::asset("font", path.display().to_string(), error))
72    }
73
74    /// Registers bounded in-memory OpenType/TrueType data atomically, including
75    /// collections and color fonts. At most 4096 faces / 256 MiB are admitted.
76    pub fn load_data(&mut self, data: Arc<[u8]>) -> motion_core::Result<Vec<FontIdentifier>> {
77        let bytes = self
78            .bytes
79            .checked_add(data.len())
80            .filter(|bytes| *bytes <= 256 * 1024 * 1024)
81            .ok_or_else(|| invalid("Font-library source payload exceeds 256 MiB"))?;
82        let mut database = self.database.as_ref().clone();
83        let source: Arc<dyn AsRef<[u8]> + Send + Sync> = Arc::new(data);
84        let ids = database.load_font_source(fontdb::Source::Binary(source));
85        if ids.is_empty() {
86            return Err(invalid("No valid font faces found"));
87        }
88        if database.faces().count() > 4096 {
89            return Err(invalid("Font library exceeds 4096 faces"));
90        }
91        if self.is_empty() {
92            let family = database
93                .faces()
94                .next()
95                .and_then(|face| face.families.first())
96                .map(|family| family.0.clone());
97            if let Some(family) = family {
98                database.set_sans_serif_family(family.clone());
99                database.set_serif_family(family.clone());
100                database.set_monospace_family(family);
101            }
102        }
103        self.database = Arc::new(database);
104        self.bytes = bytes;
105        self.snapshot_id = identity();
106        self.font_system = Arc::new(OnceLock::new());
107        self.glyph_cache = Arc::new(motion_core::SceneCache::with_limits(
108            motion_core::CacheLimits {
109                entries: 2048,
110                bytes: 16 * 1024 * 1024,
111            },
112        ));
113        self.layout_cache = Default::default();
114        self.used_fonts = Arc::default();
115        Ok(ids.into_iter().map(FontIdentifier).collect())
116    }
117
118    /// Loads a sorted project directory tree atomically; symlink directories are
119    /// not followed. At most 4096 entries are visited, independent of face count.
120    pub fn load_dir(
121        &mut self,
122        directory: impl AsRef<std::path::Path>,
123    ) -> motion_core::Result<usize> {
124        let mut pending = vec![directory.as_ref().to_path_buf()];
125        let mut files = Vec::new();
126        let mut visited = 0;
127        while let Some(directory) = pending.pop() {
128            let mut entries = std::fs::read_dir(directory)?
129                .take(4097 - visited)
130                .collect::<Result<Vec<_>, _>>()?;
131            entries.sort_by_key(|entry| entry.path());
132            for entry in entries {
133                visited += 1;
134                if visited > 4096 {
135                    return Err(invalid("Font directory exceeds 4096 visited entries"));
136                }
137                let kind = entry.file_type()?;
138                if kind.is_dir() {
139                    pending.push(entry.path());
140                } else if kind.is_file()
141                    && entry
142                        .path()
143                        .extension()
144                        .and_then(|value| value.to_str())
145                        .is_some_and(|value| {
146                            matches!(
147                                value.to_ascii_lowercase().as_str(),
148                                "ttf" | "otf" | "ttc" | "otc"
149                            )
150                        })
151                {
152                    files.push(entry.path());
153                }
154            }
155        }
156        files.sort();
157        let mut updated = self.clone();
158        let before = updated.len();
159        for file in files {
160            updated.load_file(file)?;
161        }
162        let added = updated.len() - before;
163        *self = updated;
164        Ok(added)
165    }
166
167    /// Explicitly indexes installed font metadata without retaining every file.
168    /// Font discovery still performs I/O; bytes are loaded on first use.
169    pub fn load_system_fonts(&mut self) -> motion_core::Result<usize> {
170        let mut discovered = fontdb::Database::new();
171        discovered.load_system_fonts();
172        self.index_faces(discovered.faces().cloned())
173    }
174
175    /// Explicitly discovers installed fonts, indexing only the requested family.
176    pub fn load_system_family(&mut self, family: &str) -> motion_core::Result<usize> {
177        if family.is_empty() || family.len() > 1024 {
178            return Err(invalid("System font family must have 1..=1024 UTF-8 bytes"));
179        }
180        let mut discovered = fontdb::Database::new();
181        discovered.load_system_fonts();
182        let faces = discovered
183            .faces()
184            .filter(|face| {
185                face.families
186                    .iter()
187                    .any(|(name, _)| name.eq_ignore_ascii_case(family))
188            })
189            .cloned()
190            .collect::<Vec<_>>();
191        if faces.is_empty() {
192            return Err(invalid("Requested installed font family was not found"));
193        }
194        self.index_faces(faces)
195    }
196
197    /// Sets generic family aliases in a new snapshot, without font discovery.
198    pub fn default_family(mut self, family: impl Into<String>) -> Self {
199        let family = family.into();
200        let mut database = self.database.as_ref().clone();
201        database.set_sans_serif_family(family.clone());
202        database.set_serif_family(family.clone());
203        database.set_monospace_family(family);
204        self.database = Arc::new(database);
205        self.snapshot_id = identity();
206        self.font_system = Arc::new(OnceLock::new());
207        self.glyph_cache = Arc::new(motion_core::SceneCache::with_limits(
208            motion_core::CacheLimits {
209                entries: 2048,
210                bytes: 16 * 1024 * 1024,
211            },
212        ));
213        self.layout_cache = Default::default();
214        self.used_fonts = Arc::default();
215        self
216    }
217
218    /// Borrows the frozen parser database for SVG/formula import adapters.
219    /// Mutation still requires a new FontLibrary snapshot.
220    pub fn database(&self) -> &fontdb::Database {
221        self.database.as_ref()
222    }
223
224    /// Identity of this immutable environment for resource/layout cache keys.
225    pub fn snapshot_id(&self) -> u64 {
226        self.snapshot_id
227    }
228
229    /// Registered face count, including collection faces.
230    pub fn len(&self) -> usize {
231        self.database.faces().count()
232    }
233
234    /// Whether no fonts have been registered.
235    pub fn is_empty(&self) -> bool {
236        self.len() == 0
237    }
238
239    fn index_faces(
240        &mut self,
241        faces: impl IntoIterator<Item = fontdb::FaceInfo>,
242    ) -> motion_core::Result<usize> {
243        let mut database = self.database.as_ref().clone();
244        let before = self.len();
245        for mut face in faces {
246            if let fontdb::Source::SharedFile(path, _) = face.source {
247                face.source = fontdb::Source::File(path);
248            }
249            if database.faces().count() >= 65_536 {
250                return Err(invalid("Font metadata exceeds 65536 faces"));
251            }
252            database.push_face_info(face);
253        }
254        let added = database.faces().count() - before;
255        self.database = Arc::new(database);
256        self.snapshot_id = identity();
257        self.font_system = Arc::new(OnceLock::new());
258        self.layout_cache = Default::default();
259        self.used_fonts = Arc::default();
260        Ok(added)
261    }
262
263    pub(crate) fn with_render_cache(&self, cache: &motion_core::RenderCache) -> Self {
264        let mut fonts = self.clone();
265        fonts.glyph_cache = cache.partition(motion_core::CachePartition::Glyph).clone();
266        fonts
267    }
268
269    /// Locks the lazily built shaping engine; the mutex itself is not exposed.
270    pub(crate) fn lock_font_system(
271        &self,
272    ) -> Result<MutexGuard<'_, cosmic_text::FontSystem>, motion_core::ValidationError> {
273        self.font_system_cell().lock().map_err(|_| {
274            motion_core::ValidationError::invalid("fonts", "library", "Font engine is poisoned")
275        })
276    }
277
278    fn font_system_cell(&self) -> &Mutex<cosmic_text::FontSystem> {
279        self.font_system.get_or_init(|| {
280            let mut database = cosmic_text::fontdb::Database::new();
281            for face in self.database.faces() {
282                let source = match &face.source {
283                    fontdb::Source::Binary(data) => {
284                        cosmic_text::fontdb::Source::Binary(data.clone())
285                    }
286                    fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
287                        cosmic_text::fontdb::Source::File(path.clone())
288                    }
289                };
290                database.push_face_info(cosmic_text::fontdb::FaceInfo {
291                    id: cosmic_text::fontdb::ID::dummy(),
292                    source,
293                    index: face.index,
294                    families: face
295                        .families
296                        .iter()
297                        .map(|(name, language)| {
298                            let language = if language.primary_language() == "English"
299                                && language.region() == "United States"
300                            {
301                                cosmic_text::fontdb::Language::English_UnitedStates
302                            } else {
303                                cosmic_text::fontdb::Language::Unknown
304                            };
305                            (name.clone(), language)
306                        })
307                        .collect(),
308                    post_script_name: face.post_script_name.clone(),
309                    style: match face.style {
310                        fontdb::Style::Normal => cosmic_text::fontdb::Style::Normal,
311                        fontdb::Style::Italic => cosmic_text::fontdb::Style::Italic,
312                        fontdb::Style::Oblique => cosmic_text::fontdb::Style::Oblique,
313                    },
314                    weight: cosmic_text::fontdb::Weight(face.weight.0),
315                    stretch: match face.stretch.to_number() {
316                        1 => cosmic_text::fontdb::Stretch::UltraCondensed,
317                        2 => cosmic_text::fontdb::Stretch::ExtraCondensed,
318                        3 => cosmic_text::fontdb::Stretch::Condensed,
319                        4 => cosmic_text::fontdb::Stretch::SemiCondensed,
320                        6 => cosmic_text::fontdb::Stretch::SemiExpanded,
321                        7 => cosmic_text::fontdb::Stretch::Expanded,
322                        8 => cosmic_text::fontdb::Stretch::ExtraExpanded,
323                        9 => cosmic_text::fontdb::Stretch::UltraExpanded,
324                        _ => cosmic_text::fontdb::Stretch::Normal,
325                    },
326                    monospaced: face.monospaced,
327                });
328            }
329            let families = [
330                fontdb::Family::SansSerif,
331                fontdb::Family::Serif,
332                fontdb::Family::Monospace,
333            ]
334            .map(|family| {
335                self.database
336                    .query(&fontdb::Query {
337                        families: &[family],
338                        ..fontdb::Query::default()
339                    })
340                    .and_then(|id| self.database.face(id))
341                    .and_then(|face| face.families.first())
342                    .map(|family| family.0.clone())
343            });
344            if let Some(family) = &families[0] {
345                database.set_sans_serif_family(family.clone());
346            }
347            if let Some(family) = &families[1] {
348                database.set_serif_family(family.clone());
349            }
350            if let Some(family) = &families[2] {
351                database.set_monospace_family(family.clone());
352            }
353            Mutex::new(cosmic_text::FontSystem::new_with_locale_and_db(
354                "en-US".to_owned(),
355                database,
356            ))
357        })
358    }
359
360    /// Logical retained source bytes, excluding parser and platform overhead.
361    pub fn retained_bytes(&self) -> usize {
362        let used = self
363            .used_fonts
364            .lock()
365            .unwrap_or_else(|error| error.into_inner());
366        let sources = used
367            .values()
368            .map(|(font, bytes)| (font.file_hash, *bytes))
369            .collect::<std::collections::BTreeMap<_, _>>();
370        self.bytes + sources.values().sum::<usize>()
371    }
372
373    pub(crate) fn record_font(
374        &self,
375        system: &cosmic_text::FontSystem,
376        id: cosmic_text::fontdb::ID,
377        bytes: &[u8],
378    ) -> motion_core::FontIdentity {
379        use std::hash::{Hash, Hasher};
380
381        let mut used = self
382            .used_fonts
383            .lock()
384            .unwrap_or_else(|error| error.into_inner());
385        used.entry(id)
386            .or_insert_with(|| {
387                let mut hash = std::collections::hash_map::DefaultHasher::new();
388                bytes.hash(&mut hash);
389                let face = system.db().face(id);
390                let retained = face
391                    .filter(|face| !matches!(face.source, cosmic_text::fontdb::Source::Binary(_)))
392                    .map_or(0, |_| bytes.len());
393                (
394                    motion_core::FontIdentity {
395                        name: face.map(|face| face.post_script_name.clone()),
396                        file_hash: hash.finish(),
397                    },
398                    retained,
399                )
400            })
401            .0
402            .clone()
403    }
404
405    /// Actual selected faces and non-cryptographic source hashes, in stable order.
406    pub fn used_fonts(&self) -> Vec<motion_core::FontIdentity> {
407        let used = self
408            .used_fonts
409            .lock()
410            .unwrap_or_else(|error| error.into_inner());
411        let mut fonts = used
412            .values()
413            .map(|(font, _)| font.clone())
414            .collect::<Vec<_>>();
415        fonts.sort_by(|left, right| {
416            (&left.name, left.file_hash).cmp(&(&right.name, right.file_hash))
417        });
418        fonts
419    }
420}
421
422impl Default for FontLibrary {
423    fn default() -> Self {
424        Self::new()
425    }
426}
427
428impl std::panic::RefUnwindSafe for FontLibrary {}
429
430impl From<FontLibrary> for motion_core::asset::environment::FontEnvironment {
431    fn from(fonts: FontLibrary) -> Self {
432        let identity = fonts.snapshot_id();
433        let usage = fonts.clone();
434        Self::new(fonts, identity).with_usage(move || usage.used_fonts())
435    }
436}
437
438impl From<&FontLibrary> for motion_core::asset::environment::FontEnvironment {
439    fn from(fonts: &FontLibrary) -> Self {
440        fonts.clone().into()
441    }
442}
443
444impl std::panic::UnwindSafe for FontLibrary {}
445
446impl std::fmt::Debug for FontLibrary {
447    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        formatter
449            .debug_struct("FontLibrary")
450            .field("faces", &self.len())
451            .field("bytes", &self.bytes)
452            .field("snapshot", &self.snapshot_id)
453            .finish()
454    }
455}
456
457/// Immutable font bytes and collection face index, shared across scene snapshots.
458///
459/// Loading never performs font discovery or I/O. Layout reparses the retained
460/// bytes; parser allocations and caller input are not a hard RSS budget.
461#[derive(Clone, Debug)]
462pub struct FontFace {
463    bytes: Arc<[u8]>,
464    index: u32,
465}
466
467impl FontFace {
468    /// Validates an OpenType/TrueType face before retaining its bytes.
469    /// Invalid data or face indices fail without constructing a resource.
470    pub fn from_bytes(
471        bytes: Vec<u8>,
472        face_index: u32,
473    ) -> Result<Self, motion_core::ValidationError> {
474        if bytes.len() > MAX_FONT_FACE_BYTES {
475            return Err(motion_core::ValidationError::limit(
476                "font",
477                "font_bytes",
478                "font exceeds 32 MiB",
479            ));
480        }
481        skrifa::FontRef::from_index(&bytes, face_index).map_err(|_| {
482            crate::text::invalid("font", "Invalid font data or collection face index")
483        })?;
484        Ok(Self {
485            bytes: bytes.into(),
486            index: face_index,
487        })
488    }
489
490    /// Retained source payload, excluding allocation overhead and parser scratch.
491    pub fn byte_len(&self) -> usize {
492        self.bytes.len()
493    }
494
495    /// Index of the retained face in its source collection.
496    pub fn index(&self) -> u32 {
497        self.index
498    }
499
500    /// Creates an explicit paragraph font snapshot without system discovery.
501    pub fn library(&self) -> Result<FontLibrary, motion_core::ValidationError> {
502        let mut fonts = FontLibrary::new();
503        fonts
504            .load_data(self.bytes.clone())
505            .map_err(|error| crate::text::invalid("font", &error.to_string()))?;
506        let family = fonts
507            .database()
508            .faces()
509            .find(|face| face.index == self.index)
510            .and_then(|face| face.families.first().map(|family| family.0.clone()))
511            .ok_or_else(|| crate::text::invalid("font", "Explicit font face has no family"))?;
512        Ok(fonts.default_family(family))
513    }
514}
515
516fn identity() -> u64 {
517    static NEXT: AtomicU64 = AtomicU64::new(1);
518    NEXT.fetch_add(1, Ordering::Relaxed)
519}
520
521fn invalid(message: &str) -> motion_core::Error {
522    motion_core::Error::asset(
523        "font",
524        "<library>",
525        std::io::Error::new(std::io::ErrorKind::InvalidData, message),
526    )
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn indexed_file_sources_remain_lazy_until_layout() -> motion_core::Result<()> {
535        let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
536            .join("../../tests/fixtures/fonts/NotoSans-Regular.ttf");
537        let mut discovered = fontdb::Database::new();
538        discovered.load_font_file(fixture)?;
539        let mut fonts = FontLibrary::new();
540        let empty = fonts.clone();
541        assert!(fonts.index_faces(discovered.faces().cloned())? > 0);
542        assert!(empty.is_empty());
543        assert_eq!(fonts.retained_bytes(), 0);
544        assert!(
545            fonts
546                .lock_font_system()?
547                .db()
548                .faces()
549                .all(|face| matches!(face.source, cosmic_text::fontdb::Source::File(_)))
550        );
551        let paragraph = crate::Text::new("Lazy font")
552            .family("Noto Sans")
553            .layout(&fonts)?;
554        assert!(paragraph.bounds().width() > 0.0);
555        assert!(!paragraph.glyphs().is_empty());
556        assert!(fonts.retained_bytes() > 0);
557        assert_eq!(fonts.used_fonts().len(), 1);
558        assert_eq!(
559            motion_core::FontEnvironment::from(&fonts).used_fonts(),
560            fonts.used_fonts()
561        );
562        Ok(())
563    }
564
565    #[test]
566    fn empty_font_libraries_are_explicit_and_invalid_registration_is_atomic() {
567        let mut fonts = FontLibrary::new();
568        assert!(fonts.is_empty());
569        assert_eq!(fonts.retained_bytes(), 0);
570        let snapshot = fonts.clone();
571        assert!(Arc::ptr_eq(&fonts.database, &snapshot.database));
572        assert!(fonts.load_data(Arc::from(&b"invalid font"[..])).is_err());
573        assert!(Arc::ptr_eq(&fonts.database, &snapshot.database));
574        assert_eq!(fonts.snapshot_id(), snapshot.snapshot_id());
575        assert_eq!(fonts.len(), 0);
576        assert!(fonts.load_system_family("").is_err());
577        assert_eq!(fonts.snapshot_id(), snapshot.snapshot_id());
578    }
579
580    #[test]
581    #[ignore = "explicitly discovers installed fonts; requires MOTION_TEST_SYSTEM_FAMILY"]
582    fn explicitly_requested_installed_family_is_frozen_and_usable() {
583        let mut fonts = FontLibrary::new();
584        let empty = fonts.clone();
585        let family = std::env::var("MOTION_TEST_SYSTEM_FAMILY").expect("explicit installed family");
586        let added = fonts.load_system_family(&family).unwrap();
587        assert!(added > 0);
588        assert_eq!(fonts.retained_bytes(), 0);
589        assert!(empty.is_empty());
590        let paragraph = crate::Text::new("Installed family")
591            .family(family)
592            .layout(&fonts)
593            .unwrap();
594        assert!(paragraph.bounds().width() > 0.0);
595        println!(
596            "explicit installed family: faces={added} immutable_bytes={} shape=pass",
597            fonts.retained_bytes()
598        );
599    }
600}