Skip to main content

motion_content/
font.rs

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