Skip to main content

appcore_filemaker/
font.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: font.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded font contracts and behavior for this crate.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15
16use sha2::{Digest, Sha256};
17use skrifa::{instance::Size, FontRef, MetadataProvider};
18
19use crate::{ErrorCode, FileMakerError, Result};
20
21/// Explicit immutable font bytes.
22#[derive(Clone, Debug)]
23pub struct FontAsset {
24    /// Stable logical family/name used by templates.
25    pub name: String,
26    /// TrueType/OpenType bytes.
27    pub bytes: Arc<[u8]>,
28    /// Face index for collections.
29    pub face_index: u32,
30    /// SHA-256 digest used by fingerprints.
31    pub digest: [u8; 32],
32}
33
34impl FontAsset {
35    /// Validates font bytes and creates an asset.
36    pub fn new(name: impl Into<String>, bytes: Vec<u8>, face_index: u32) -> Result<Self> {
37        let name = name.into();
38        if name.is_empty() || name.len() > 128 {
39            return Err(font_error("font name is empty or too long"));
40        }
41        FontRef::from_index(&bytes, face_index)
42            .map_err(|_| font_error("font bytes or face index are invalid"))?;
43        let digest = Sha256::digest(&bytes).into();
44        Ok(Self {
45            name,
46            bytes: bytes.into(),
47            face_index,
48            digest,
49        })
50    }
51
52    /// Returns whether this font maps every scalar in a grapheme cluster.
53    #[must_use]
54    pub fn covers(&self, text: &str) -> bool {
55        let Ok(face) = FontRef::from_index(&self.bytes, self.face_index) else {
56            return false;
57        };
58        let charmap = face.charmap();
59        text.chars()
60            .all(|character| character.is_control() || charmap.map(character).is_some())
61    }
62
63    /// Returns units per em.
64    pub fn units_per_em(&self) -> Result<u16> {
65        let face = FontRef::from_index(&self.bytes, self.face_index)
66            .map_err(|_| font_error("registered font became invalid"))?;
67        let units_per_em = face
68            .metrics(Size::unscaled(), skrifa::instance::LocationRef::default())
69            .units_per_em;
70        if units_per_em == 0 {
71            return Err(font_error("registered font has no units-per-em"));
72        }
73        Ok(units_per_em)
74    }
75}
76
77/// Explicit font resolver; implementations must not discover OS fonts.
78pub trait FontResolver: Send + Sync {
79    /// Resolves one exact logical font name under a byte cap.
80    fn resolve_font(&self, name: &str, max_bytes: usize) -> Result<FontAsset>;
81}
82
83/// Deterministic font registry and fallback order.
84#[derive(Clone, Debug, Default)]
85pub struct FontManager {
86    fonts: BTreeMap<String, FontAsset>,
87    fallback: Vec<String>,
88}
89
90impl FontManager {
91    /// Resolves and registers one exact font under a caller-supplied byte cap.
92    pub fn register_from(
93        &mut self,
94        resolver: &dyn FontResolver,
95        name: &str,
96        max_bytes: usize,
97    ) -> Result<()> {
98        self.register(resolver.resolve_font(name, max_bytes)?)
99    }
100
101    /// Registers a font without replacing an existing name.
102    pub fn register(&mut self, font: FontAsset) -> Result<()> {
103        if self.fonts.contains_key(&font.name) {
104            return Err(font_error(format!(
105                "font `{}` is already registered",
106                font.name
107            )));
108        }
109        self.fonts.insert(font.name.clone(), font);
110        Ok(())
111    }
112
113    /// Replaces the global fallback list after validating every exact name.
114    pub fn set_fallback(&mut self, fallback: Vec<String>) -> Result<()> {
115        if fallback.len() > 64 || fallback.iter().any(|name| !self.fonts.contains_key(name)) {
116            return Err(font_error(
117                "fallback contains too many fonts or an unknown name",
118            ));
119        }
120        self.fallback = fallback;
121        Ok(())
122    }
123
124    /// Resolves exact name.
125    pub fn get(&self, name: &str) -> Result<&FontAsset> {
126        self.fonts
127            .get(name)
128            .ok_or_else(|| font_error(format!("font `{name}` is not registered")))
129    }
130
131    /// Selects the first explicit primary/fallback font covering a grapheme.
132    pub fn select_for_grapheme<'a>(
133        &'a self,
134        primary: &str,
135        grapheme: &str,
136    ) -> Result<&'a FontAsset> {
137        let primary = self.get(primary)?;
138        if primary.covers(grapheme) {
139            return Ok(primary);
140        }
141        for name in &self.fallback {
142            let font = self.get(name)?;
143            if font.covers(grapheme) {
144                return Ok(font);
145            }
146        }
147        let scalar = grapheme
148            .chars()
149            .find(|value| !value.is_control())
150            .map_or(0, u32::from);
151        Err(font_error(format!(
152            "no explicit font contains glyph U+{scalar:04X}"
153        )))
154    }
155
156    /// Returns stable font digests in lexical name order.
157    pub fn digests(&self) -> impl Iterator<Item = (&str, &[u8; 32])> {
158        self.fonts
159            .iter()
160            .map(|(name, font)| (name.as_str(), &font.digest))
161    }
162
163    /// Returns the explicit fallback order used during shaping.
164    pub fn fallback_names(&self) -> impl Iterator<Item = &str> {
165        self.fallback.iter().map(String::as_str)
166    }
167}
168
169/// Glyph usage prepared for an embedding/subsetting exporter.
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct FontSubset {
172    /// Registered font name.
173    pub font: String,
174    /// Sorted unique glyph IDs.
175    pub glyph_ids: BTreeSet<u16>,
176}
177
178impl FontSubset {
179    /// Creates an empty usage set.
180    #[must_use]
181    pub fn new(font: impl Into<String>) -> Self {
182        Self {
183            font: font.into(),
184            glyph_ids: BTreeSet::new(),
185        }
186    }
187
188    /// Records a glyph ID.
189    pub fn record(&mut self, glyph_id: u16) {
190        self.glyph_ids.insert(glyph_id);
191    }
192}
193
194fn font_error(message: impl Into<String>) -> FileMakerError {
195    FileMakerError::new(ErrorCode::FontMissing, message)
196}