Skip to main content

zenith_core/font/
provider.rs

1//! Font sourcing layer for Zenith.
2//!
3//! Provides a deterministic, system-font-free registry for resolving font bytes
4//! by family name, weight, and style. All ordering-sensitive collections use
5//! `BTreeMap` for determinism. No external crate dependencies — only `std`.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10/// The style variant of a font face.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum FontStyle {
13    Normal,
14    Italic,
15}
16
17/// Where a resolved font face came from, in resolution-priority order.
18///
19/// This is the provenance of a registered face. It drives the `font.local`
20/// advisory: a face resolved from [`FontSource::Local`] is sourced from the
21/// machine running the render, so the output is NOT guaranteed deterministic
22/// across machines. `Bundled` and `Project` faces travel with the engine /
23/// document and are portable.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FontSource {
26    /// Embedded in the engine binary (the bundled Noto faces). Fully portable.
27    Bundled,
28    /// Declared as a `font`-kind project asset and shipped with the document.
29    Project,
30    /// Discovered on the local/system font directories of the render machine.
31    /// Non-deterministic across machines — emits a `font.local` advisory.
32    Local,
33}
34
35/// Resolved font bytes ready for shaping or outlining. Cheap to clone (`Arc`).
36#[derive(Clone)]
37pub struct FontData {
38    /// Stable identifier, e.g. `"noto-sans-400-normal"`.
39    pub id: String,
40    /// Raw font file bytes.
41    pub bytes: Arc<[u8]>,
42    /// Face index within a font collection (0 for single-face fonts).
43    pub index: u32,
44    /// Provenance of this face (bundled / project / local-system).
45    pub source: FontSource,
46}
47
48impl std::fmt::Debug for FontData {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("FontData")
51            .field("id", &self.id)
52            .field("bytes_len", &self.bytes.len())
53            .field("index", &self.index)
54            .field("source", &self.source)
55            .finish()
56    }
57}
58
59/// Resolve font bytes by family + weight + style, or by stable id.
60///
61/// Implementations must never access system fonts.
62pub trait FontProvider {
63    /// Resolve by a priority-ordered family list, weight, and style.
64    ///
65    /// Iterates `families` in order. For each family:
66    /// 1. Tries exact `(family, weight, style)`.
67    /// 2. Falls back to the same family with any weight/style (first BTreeMap entry).
68    ///
69    /// Returns `None` only if no registered family matches any entry in `families`.
70    /// Family comparison is case-insensitive.
71    #[must_use]
72    fn resolve(&self, families: &[String], weight: u16, style: FontStyle) -> Option<FontData>;
73
74    /// Resolve by the stable id recorded on a shaped run.
75    #[must_use]
76    fn by_id(&self, id: &str) -> Option<FontData>;
77
78    /// All registered faces, in a deterministic order (for building an SVG fontdb, etc.).
79    #[must_use]
80    fn all_faces(&self) -> Vec<FontData>;
81}
82
83// Internal key type for the primary registry map.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
85struct FaceKey {
86    family_lower: String,
87    weight: u16,
88    style: FontStyle,
89}
90
91/// In-memory font registry. Register bundled and project fonts up front;
92/// this implementation never scans the system.
93///
94/// Two `BTreeMap`s are maintained:
95/// - `by_key`: `(family_lower, weight, style) -> FontData` for `resolve`.
96/// - `by_id`: `id -> FontData` for `by_id`.
97pub struct BytesFontProvider {
98    by_key: BTreeMap<FaceKey, FontData>,
99    by_id: BTreeMap<String, FontData>,
100}
101
102impl std::fmt::Debug for BytesFontProvider {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        let ids: Vec<&str> = self.by_id.keys().map(String::as_str).collect();
105        f.debug_struct("BytesFontProvider")
106            .field("registered_faces", &ids)
107            .finish()
108    }
109}
110
111impl BytesFontProvider {
112    /// Create an empty registry.
113    #[must_use]
114    pub fn new() -> Self {
115        Self {
116            by_key: BTreeMap::new(),
117            by_id: BTreeMap::new(),
118        }
119    }
120
121    /// Register a font face and return its stable id.
122    ///
123    /// The id is computed as `"{family_kebab_lower}-{weight}-{style_lower}"`,
124    /// e.g. `"noto-sans-400-normal"`. If the same face is registered more than
125    /// once, the most recent registration wins and reuses the original id.
126    /// Because kebab-casing can collapse distinct families (e.g. `"My Font"`
127    /// and `"my-font"`) onto the same base id, a numeric suffix is appended
128    /// when the base id is already taken by a *different* face, so every
129    /// registered face keeps a unique id. Returns the assigned stable id as a
130    /// convenience; callers may register purely for the side effect.
131    ///
132    /// `source` records the provenance of the face ([`FontSource`]); it is
133    /// carried on the resolved [`FontData`] so the compile stage can emit a
134    /// `font.local` advisory when a face resolves from the local system.
135    pub fn register(
136        &mut self,
137        family: &str,
138        weight: u16,
139        style: FontStyle,
140        bytes: Arc<[u8]>,
141        index: u32,
142        source: FontSource,
143    ) -> String {
144        let family_lower = family.to_lowercase();
145        let family_kebab = family_lower.replace(' ', "-");
146        let style_str = match style {
147            FontStyle::Normal => "normal",
148            FontStyle::Italic => "italic",
149        };
150        let base_id = format!("{family_kebab}-{weight}-{style_str}");
151
152        let key = FaceKey {
153            family_lower,
154            weight,
155            style,
156        };
157
158        // Re-registering the same face reuses its id; a new face whose base id
159        // collides with a different face gets a numeric suffix.
160        let id = match self.by_key.get(&key) {
161            Some(existing) => existing.id.clone(),
162            None => {
163                let mut candidate = base_id.clone();
164                let mut n = 2u32;
165                while self.by_id.contains_key(&candidate) {
166                    candidate = format!("{base_id}-{n}");
167                    n += 1;
168                }
169                candidate
170            }
171        };
172
173        let data = FontData {
174            id: id.clone(),
175            bytes,
176            index,
177            source,
178        };
179
180        self.by_key.insert(key, data.clone());
181        self.by_id.insert(id.clone(), data);
182
183        id
184    }
185
186    /// Return the lowercase family names of all registered faces (deduplicated, sorted).
187    #[must_use]
188    pub fn available_families(&self) -> Vec<String> {
189        let mut families: Vec<String> =
190            self.by_key.keys().map(|k| k.family_lower.clone()).collect();
191        families.dedup();
192        families
193    }
194}
195
196impl Default for BytesFontProvider {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl FontProvider for BytesFontProvider {
203    fn resolve(&self, families: &[String], weight: u16, style: FontStyle) -> Option<FontData> {
204        for family in families {
205            let family_lower = family.to_lowercase();
206
207            // 1. Exact match.
208            let exact_key = FaceKey {
209                family_lower: family_lower.clone(),
210                weight,
211                style,
212            };
213            if let Some(data) = self.by_key.get(&exact_key) {
214                return Some(data.clone());
215            }
216
217            // 2. Fallback: same family, any weight/style — deterministic first entry.
218            let fallback = self
219                .by_key
220                .range(
221                    FaceKey {
222                        family_lower: family_lower.clone(),
223                        weight: 0,
224                        style: FontStyle::Normal,
225                    }..,
226                )
227                .find(|(k, _)| k.family_lower == family_lower)
228                .map(|(_, v)| v.clone());
229
230            if fallback.is_some() {
231                return fallback;
232            }
233        }
234        None
235    }
236
237    fn by_id(&self, id: &str) -> Option<FontData> {
238        self.by_id.get(id).cloned()
239    }
240
241    fn all_faces(&self) -> Vec<FontData> {
242        self.by_id.values().cloned().collect()
243    }
244}
245
246/// Build a `BytesFontProvider` preloaded with the bundled default fonts.
247///
248/// Ten faces are embedded at compile time, all Apache-2.0:
249/// - Noto Sans Regular (`"Noto Sans"`, weight 400, Normal) — the proportional
250///   default for text nodes.
251/// - Noto Sans Bold (`"Noto Sans"`, weight 700, Normal) — resolved when a node
252///   requests `font-weight` 700.
253/// - Noto Sans Italic (`"Noto Sans"`, weight 400, Italic) — resolved when a
254///   span requests italic.
255/// - Noto Sans Bold Italic (`"Noto Sans"`, weight 700, Italic) — resolved for a
256///   span that is BOTH bold and italic (completes the weight×style matrix).
257/// - Noto Serif Regular (`"Noto Serif"`, weight 400, Normal) — the bundled,
258///   portable serif family.
259/// - Noto Serif Bold (`"Noto Serif"`, weight 700, Normal).
260/// - Noto Serif Italic (`"Noto Serif"`, weight 400, Italic).
261/// - Noto Serif Bold Italic (`"Noto Serif"`, weight 700, Italic) — completes the
262///   serif weight×style matrix.
263/// - Noto Sans Mono Regular (`"Noto Sans Mono"`, weight 400, Normal) — the
264///   monospace default for code nodes.
265/// - Noto Sans Mono Bold (`"Noto Sans Mono"`, weight 700, Normal) — resolved
266///   when a code node requests `font-weight` 700.
267#[must_use]
268pub fn default_provider() -> BytesFontProvider {
269    let sans: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_REGULAR);
270    let sans_bold: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_BOLD);
271    let sans_italic: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_ITALIC);
272    let sans_bold_italic: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_BOLD_ITALIC);
273    let serif: Arc<[u8]> = Arc::from(super::embedded::NOTO_SERIF_REGULAR);
274    let serif_bold: Arc<[u8]> = Arc::from(super::embedded::NOTO_SERIF_BOLD);
275    let serif_italic: Arc<[u8]> = Arc::from(super::embedded::NOTO_SERIF_ITALIC);
276    let serif_bold_italic: Arc<[u8]> = Arc::from(super::embedded::NOTO_SERIF_BOLD_ITALIC);
277    let mono: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_MONO_REGULAR);
278    let mono_bold: Arc<[u8]> = Arc::from(super::embedded::NOTO_SANS_MONO_BOLD);
279    let mut provider = BytesFontProvider::new();
280    let b = FontSource::Bundled;
281    provider.register("Noto Sans", 400, FontStyle::Normal, sans, 0, b);
282    provider.register("Noto Sans", 700, FontStyle::Normal, sans_bold, 0, b);
283    provider.register("Noto Sans", 400, FontStyle::Italic, sans_italic, 0, b);
284    provider.register("Noto Sans", 700, FontStyle::Italic, sans_bold_italic, 0, b);
285    provider.register("Noto Serif", 400, FontStyle::Normal, serif, 0, b);
286    provider.register("Noto Serif", 700, FontStyle::Normal, serif_bold, 0, b);
287    provider.register("Noto Serif", 400, FontStyle::Italic, serif_italic, 0, b);
288    provider.register(
289        "Noto Serif",
290        700,
291        FontStyle::Italic,
292        serif_bold_italic,
293        0,
294        b,
295    );
296    provider.register("Noto Sans Mono", 400, FontStyle::Normal, mono, 0, b);
297    provider.register("Noto Sans Mono", 700, FontStyle::Normal, mono_bold, 0, b);
298    provider
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// Helper: the four TrueType/OpenType magic bytes at offset 0.
306    fn is_valid_tt_header(bytes: &[u8]) -> bool {
307        bytes.len() > 1000 && bytes.starts_with(&[0x00, 0x01, 0x00, 0x00])
308    }
309
310    #[test]
311    fn default_provider_resolves_noto_sans() {
312        let p = default_provider();
313        let result = p.resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal);
314        assert!(result.is_some(), "expected Some for Noto Sans 400 Normal");
315        let data = result.unwrap();
316        assert!(
317            is_valid_tt_header(&data.bytes),
318            "expected TrueType header and len > 1000, got len={}",
319            data.bytes.len()
320        );
321    }
322
323    #[test]
324    fn default_provider_resolves_noto_serif_matrix() {
325        let p = default_provider();
326        for (weight, style) in [
327            (400, FontStyle::Normal),
328            (700, FontStyle::Normal),
329            (400, FontStyle::Italic),
330            (700, FontStyle::Italic),
331        ] {
332            let result = p.resolve(&["Noto Serif".to_string()], weight, style);
333            assert!(
334                result.is_some(),
335                "expected Some for Noto Serif {weight} {style:?}"
336            );
337            let data = result.unwrap();
338            assert_eq!(data.source, FontSource::Bundled, "serif must be bundled");
339            assert!(
340                is_valid_tt_header(&data.bytes),
341                "expected TrueType header for Noto Serif {weight} {style:?}"
342            );
343        }
344    }
345
346    #[test]
347    fn default_provider_resolves_noto_sans_mono() {
348        let p = default_provider();
349        let result = p.resolve(&["Noto Sans Mono".to_string()], 400, FontStyle::Normal);
350        assert!(
351            result.is_some(),
352            "expected Some for Noto Sans Mono 400 Normal"
353        );
354        let data = result.unwrap();
355        assert!(
356            is_valid_tt_header(&data.bytes),
357            "expected TrueType header and len > 1000, got len={}",
358            data.bytes.len()
359        );
360        assert!(
361            data.id.contains("noto-sans-mono"),
362            "id should contain noto-sans-mono, got {}",
363            data.id
364        );
365    }
366
367    #[test]
368    fn default_provider_distinguishes_sans_and_mono() {
369        // The two bundled faces must be independently resolvable with distinct
370        // bytes — a mono code node must not accidentally get the proportional face.
371        let p = default_provider();
372        let sans = p
373            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal)
374            .expect("sans resolves");
375        let mono = p
376            .resolve(&["Noto Sans Mono".to_string()], 400, FontStyle::Normal)
377            .expect("mono resolves");
378        assert_ne!(sans.id, mono.id, "sans and mono must have distinct ids");
379        assert_ne!(
380            sans.bytes.len(),
381            mono.bytes.len(),
382            "sans and mono must be different font files"
383        );
384    }
385
386    #[test]
387    fn case_insensitive_family_lookup() {
388        let p = default_provider();
389        let lower = p.resolve(&["noto sans".to_string()], 400, FontStyle::Normal);
390        let mixed = p.resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal);
391        assert!(lower.is_some(), "lowercase family should resolve");
392        assert!(mixed.is_some(), "mixed-case family should resolve");
393        assert_eq!(lower.unwrap().id, mixed.unwrap().id);
394    }
395
396    #[test]
397    fn weight_fallback_resolves_unregistered_weight() {
398        let p = default_provider();
399        // weight 900 is not registered — should fall back to a registered face.
400        let result = p.resolve(&["Noto Sans".to_string()], 900, FontStyle::Normal);
401        assert!(
402            result.is_some(),
403            "weight 900 should fall back to a registered face"
404        );
405        let data = result.unwrap();
406        assert!(data.id.contains("noto-sans"), "id should contain noto-sans");
407    }
408
409    #[test]
410    fn bold_italic_resolves_distinct_combined_face() {
411        // Weight 700 + Italic must resolve EXACTLY to the bold-italic face — not
412        // fall back to bold-upright or regular-italic.
413        let p = default_provider();
414        let bold = p
415            .resolve(&["Noto Sans".to_string()], 700, FontStyle::Normal)
416            .expect("bold resolves");
417        let italic = p
418            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Italic)
419            .expect("italic resolves");
420        let bold_italic = p
421            .resolve(&["Noto Sans".to_string()], 700, FontStyle::Italic)
422            .expect("bold-italic resolves");
423        assert!(
424            bold_italic.id.contains("700") && bold_italic.id.contains("italic"),
425            "bold-italic id should encode both 700 and italic, got {}",
426            bold_italic.id
427        );
428        assert_ne!(bold_italic.id, bold.id, "must differ from bold-upright");
429        assert_ne!(bold_italic.id, italic.id, "must differ from regular-italic");
430    }
431
432    #[test]
433    fn italic_style_resolves_distinct_italic_face() {
434        // The bundled italic face (Noto Sans 400 Italic) must resolve EXACTLY
435        // and be a different file than the regular Normal face.
436        let p = default_provider();
437        let normal = p
438            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal)
439            .expect("normal resolves");
440        let italic = p
441            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Italic)
442            .expect("italic resolves");
443        assert!(
444            italic.id.contains("italic"),
445            "italic id should encode the italic style, got {}",
446            italic.id
447        );
448        assert_ne!(
449            normal.id, italic.id,
450            "normal and italic must have distinct ids"
451        );
452        assert_ne!(
453            normal.bytes.len(),
454            italic.bytes.len(),
455            "normal and italic must be different font files"
456        );
457    }
458
459    #[test]
460    fn bold_weight_resolves_distinct_bold_face() {
461        // The bundled bold face (Noto Sans 700) must resolve EXACTLY and be a
462        // different file than the regular 400 face.
463        let p = default_provider();
464        let regular = p
465            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal)
466            .expect("regular resolves");
467        let bold = p
468            .resolve(&["Noto Sans".to_string()], 700, FontStyle::Normal)
469            .expect("bold resolves");
470        assert!(
471            bold.id.contains("noto-sans-700"),
472            "bold id should encode weight 700, got {}",
473            bold.id
474        );
475        assert_ne!(
476            regular.id, bold.id,
477            "regular and bold must have distinct ids"
478        );
479        assert_ne!(
480            regular.bytes.len(),
481            bold.bytes.len(),
482            "regular and bold must be different font files"
483        );
484    }
485
486    #[test]
487    fn mono_bold_weight_resolves_distinct_bold_face() {
488        // The bundled Noto Sans Mono Bold face (weight 700) must resolve EXACTLY
489        // and be a different file than the Mono Regular (weight 400) face.
490        let p = default_provider();
491        let mono_regular = p
492            .resolve(&["Noto Sans Mono".to_string()], 400, FontStyle::Normal)
493            .expect("mono regular resolves");
494        let mono_bold = p
495            .resolve(&["Noto Sans Mono".to_string()], 700, FontStyle::Normal)
496            .expect("mono bold resolves");
497        assert!(
498            mono_bold.id.contains("noto-sans-mono-700"),
499            "mono bold id should encode weight 700, got {}",
500            mono_bold.id
501        );
502        assert_ne!(
503            mono_regular.id, mono_bold.id,
504            "mono regular and mono bold must have distinct ids"
505        );
506        assert_ne!(
507            mono_regular.bytes.len(),
508            mono_bold.bytes.len(),
509            "mono regular and mono bold must be different font files"
510        );
511    }
512
513    #[test]
514    fn unknown_family_returns_none() {
515        let p = default_provider();
516        let result = p.resolve(&["Nonexistent".to_string()], 400, FontStyle::Normal);
517        assert!(result.is_none(), "unknown family must return None");
518    }
519
520    #[test]
521    fn by_id_roundtrip() {
522        let p = default_provider();
523        let resolved = p
524            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal)
525            .expect("should resolve");
526        let by_id = p
527            .by_id(&resolved.id)
528            .expect("by_id should return same face");
529        assert_eq!(resolved.id, by_id.id);
530        assert_eq!(resolved.bytes.len(), by_id.bytes.len());
531    }
532
533    #[test]
534    fn by_id_unknown_returns_none() {
535        let p = default_provider();
536        assert!(p.by_id("no-such-font-0-normal").is_none());
537    }
538
539    #[test]
540    fn manual_register_and_resolve() {
541        let mut p = BytesFontProvider::new();
542        let dummy_bytes: Arc<[u8]> = Arc::from(vec![0u8; 64].as_slice());
543        let id = p.register(
544            "Test Family",
545            400,
546            FontStyle::Normal,
547            dummy_bytes.clone(),
548            0,
549            FontSource::Project,
550        );
551        assert_eq!(id, "test-family-400-normal");
552
553        let resolved = p.resolve(&["Test Family".to_string()], 400, FontStyle::Normal);
554        assert!(resolved.is_some());
555        assert_eq!(resolved.unwrap().id, "test-family-400-normal");
556    }
557
558    #[test]
559    fn stable_id_format() {
560        let mut p = BytesFontProvider::new();
561        let bytes: Arc<[u8]> = Arc::from(vec![0u8; 4].as_slice());
562        let id = p.register(
563            "My Font",
564            700,
565            FontStyle::Italic,
566            bytes,
567            0,
568            FontSource::Local,
569        );
570        assert_eq!(id, "my-font-700-italic");
571    }
572
573    #[test]
574    fn re_registering_same_face_reuses_id() {
575        let mut p = BytesFontProvider::new();
576        let bytes: Arc<[u8]> = Arc::from(vec![1u8; 8].as_slice());
577        let id1 = p.register(
578            "Inter",
579            400,
580            FontStyle::Normal,
581            bytes.clone(),
582            0,
583            FontSource::Project,
584        );
585        let id2 = p.register(
586            "Inter",
587            400,
588            FontStyle::Normal,
589            bytes,
590            0,
591            FontSource::Project,
592        );
593        assert_eq!(id1, id2, "same face re-registration keeps a stable id");
594    }
595
596    #[test]
597    fn kebab_colliding_families_get_distinct_ids() {
598        // "My Font" and "my-font" both kebab to "my-font-400-normal"; the second
599        // must get a distinct id so it remains independently resolvable by id.
600        let mut p = BytesFontProvider::new();
601        let a: Arc<[u8]> = Arc::from(vec![0xAAu8; 4].as_slice());
602        let b: Arc<[u8]> = Arc::from(vec![0xBBu8; 4].as_slice());
603        let id_a = p.register("My Font", 400, FontStyle::Normal, a, 0, FontSource::Local);
604        let id_b = p.register("my-font", 400, FontStyle::Normal, b, 0, FontSource::Local);
605        assert_eq!(id_a, "my-font-400-normal");
606        assert_ne!(id_a, id_b, "colliding families must not share an id");
607        // Both remain resolvable by their distinct ids, with their own bytes.
608        assert_eq!(p.by_id(&id_a).unwrap().bytes[0], 0xAA);
609        assert_eq!(p.by_id(&id_b).unwrap().bytes[0], 0xBB);
610    }
611
612    #[test]
613    fn resolve_carries_registered_source() {
614        // A face registered as Local resolves with source == Local; a bundled
615        // face resolves with source == Bundled. This provenance drives the
616        // `font.local` advisory at compile time.
617        let mut p = BytesFontProvider::new();
618        let bytes: Arc<[u8]> = Arc::from(vec![0u8; 8].as_slice());
619        p.register(
620            "Local Face",
621            400,
622            FontStyle::Normal,
623            bytes,
624            0,
625            FontSource::Local,
626        );
627        let local = p
628            .resolve(&["Local Face".to_string()], 400, FontStyle::Normal)
629            .expect("local face resolves");
630        assert_eq!(local.source, FontSource::Local);
631
632        let bundled = default_provider()
633            .resolve(&["Noto Sans".to_string()], 400, FontStyle::Normal)
634            .expect("bundled face resolves");
635        assert_eq!(bundled.source, FontSource::Bundled);
636    }
637
638    #[test]
639    fn default_provider_faces_are_all_bundled() {
640        // Every face the default provider exposes must be Bundled — the
641        // byte-identical-when-bundled invariant relies on this.
642        let p = default_provider();
643        for face in p.all_faces() {
644            assert_eq!(
645                face.source,
646                FontSource::Bundled,
647                "default provider face {} must be Bundled",
648                face.id
649            );
650        }
651    }
652}