Skip to main content

fmd_math/
faces.rs

1//! Face management: the engine's face roster, character→glyph resolution
2//! with the TeX math-italic convention and the math-alphabet mappings, and
3//! glyph-metric queries in em units.
4//!
5//! Multi-face layout is structural (G0-3 Verdict 3): `∑ ∫ ∏` do not exist
6//! in CM Unicode and resolve through the math-symbol fallback face, so
7//! **every positioned glyph names its face** and resolution is data, not a
8//! rendering afterthought. Resolution walks a per-request preference chain
9//! and falls back across the roster; a character no face maps is a precise
10//! [`MathError::UnmappedChar`](crate::MathError).
11//!
12//! **Italic correction, synthesized.** TFM italic corrections do not exist
13//! in the sfnt world; per the ratified measure-and-validate seam they are
14//! synthesized from decoded geometry as the ink's right overhang past the
15//! advance (`max(0, bbox_xmax − advance)`), which reproduces their role in
16//! script placement (slanted `∫` and italic letters push superscripts
17//! right).
18
19use crate::mbox::FaceId;
20use crate::node::MathFont;
21
22/// The engine's face roster, in [`FaceId`] order. The set mirrors the
23/// bundled sovereign default (§11.1): CM regular/italic/bold/bold-italic,
24/// CM Typewriter, a sans face, and the math-symbol coverage face.
25pub struct FaceSet {
26    fonts: Vec<fmd_font::Font>,
27}
28
29/// [`FaceId`] of CM Regular (upright letters, digits, most symbols).
30pub const FACE_REGULAR: FaceId = FaceId(0);
31/// [`FaceId`] of CM Italic (math letters, lowercase Greek).
32pub const FACE_ITALIC: FaceId = FaceId(1);
33/// [`FaceId`] of CM Bold.
34pub const FACE_BOLD: FaceId = FaceId(2);
35/// [`FaceId`] of CM Bold Italic.
36pub const FACE_BOLD_ITALIC: FaceId = FaceId(3);
37/// [`FaceId`] of CM Typewriter.
38pub const FACE_TYPEWRITER: FaceId = FaceId(4);
39/// [`FaceId`] of the sans face (IBM Plex Sans).
40pub const FACE_SANS: FaceId = FaceId(5);
41/// [`FaceId`] of the math-symbol coverage face (Noto Sans Math subset).
42pub const FACE_SYMBOLS: FaceId = FaceId(6);
43
44impl FaceSet {
45    /// Build a face set from parsed fonts, in [`FaceId`] order: regular,
46    /// italic, bold, bold-italic, typewriter, sans, symbols.
47    #[must_use]
48    pub fn from_fonts(fonts: Vec<fmd_font::Font>) -> Self {
49        Self { fonts }
50    }
51
52    /// The bundled sovereign default: the four CM faces, CM Typewriter,
53    /// IBM Plex Sans, and the Noto math-symbol subset.
54    ///
55    /// # Errors
56    ///
57    /// Propagates [`fmd_font::FontError`] if a bundled face fails to parse
58    /// (which would be a build corruption, not a runtime condition).
59    #[cfg(feature = "bundled-faces")]
60    pub fn bundled() -> Result<Self, fmd_font::FontError> {
61        let fonts = vec![
62            fmd_font::Font::parse(fmd_font::bundled::CM_REGULAR.to_vec())?,
63            fmd_font::Font::parse(fmd_font::bundled::CM_ITALIC.to_vec())?,
64            fmd_font::Font::parse(fmd_font::bundled::CM_BOLD.to_vec())?,
65            fmd_font::Font::parse(fmd_font::bundled::CM_BOLD_ITALIC.to_vec())?,
66            fmd_font::Font::parse(fmd_font::bundled::CM_TYPEWRITER.to_vec())?,
67            fmd_font::Font::parse(fmd_font::bundled::PLEX_REGULAR.to_vec())?,
68            fmd_font::Font::parse(fmd_font::bundled::NOTO_SANS_MATH_SYMBOLS.to_vec())?,
69        ];
70        Ok(Self::from_fonts(fonts))
71    }
72
73    /// The font behind a face id, if present.
74    #[must_use]
75    pub fn font(&self, id: FaceId) -> Option<&fmd_font::Font> {
76        self.fonts.get(id.0)
77    }
78
79    /// Number of faces in the roster.
80    #[must_use]
81    pub fn len(&self) -> usize {
82        self.fonts.len()
83    }
84
85    /// True when the roster is empty.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.fonts.is_empty()
89    }
90
91    /// Resolve a character against a preference chain, falling back across
92    /// the whole roster, then through the documented glyph alternates.
93    /// Returns the face and glyph id, or `None` when nothing maps it.
94    #[must_use]
95    pub fn resolve(&self, ch: char, prefer: &[FaceId]) -> Option<(FaceId, u16)> {
96        if let Some(hit) = self.resolve_exact(ch, prefer) {
97            return Some(hit);
98        }
99        char_alternate(ch).and_then(|alt| self.resolve_exact(alt, prefer))
100    }
101
102    fn resolve_exact(&self, ch: char, prefer: &[FaceId]) -> Option<(FaceId, u16)> {
103        for &id in prefer {
104            if let Some(font) = self.font(id) {
105                let gid = font.glyph_index(ch);
106                if gid != 0 {
107                    return Some((id, gid));
108                }
109            }
110        }
111        for (i, font) in self.fonts.iter().enumerate() {
112            let gid = font.glyph_index(ch);
113            if gid != 0 {
114                return Some((FaceId(i), gid));
115            }
116        }
117        None
118    }
119}
120
121/// Documented glyph alternates, applied only when the exact character is
122/// unmapped across the whole roster: variant forms whose conventional
123/// glyph the bundled faces do carry. The substitution stops firing the
124/// moment a face gains the exact codepoint (miss-only), and every pair is
125/// a same-letter variant — different-but-fine territory, not a semantic
126/// change.
127#[must_use]
128pub fn char_alternate(ch: char) -> Option<char> {
129    Some(match ch {
130        'ϵ' => 'ε', // lunate epsilon → epsilon (CM Unicode maps only U+03B5)
131        'ϑ' => 'θ',
132        'ϱ' => 'ρ',
133        'ϖ' => 'π',
134        '⌀' => '∅',
135        _ => return None,
136    })
137}
138
139/// Per-glyph metrics in ems of the face's design size.
140#[derive(Clone, Copy, Debug, PartialEq, Default)]
141pub struct GlyphMetrics {
142    /// Advance width.
143    pub advance: f64,
144    /// Ink extent above the baseline (0 for empty glyphs).
145    pub height: f64,
146    /// Ink extent below the baseline, positive (0 for empty glyphs).
147    pub depth: f64,
148    /// Synthesized italic correction: right ink overhang past the advance.
149    pub italic: f64,
150}
151
152/// Measure a glyph in em units.
153#[must_use]
154pub fn glyph_metrics(font: &fmd_font::Font, gid: u16) -> GlyphMetrics {
155    let upm = f64::from(font.units_per_em.max(1));
156    let advance = f64::from(font.advance_width(gid)) / upm;
157    let (height, depth, italic) = font.glyph_bbox(gid).map_or((0.0, 0.0, 0.0), |bbox| {
158        let xmax = f64::from(bbox[2]) / upm;
159        (
160            f64::from(bbox[3]).max(0.0) / upm,
161            (-f64::from(bbox[1])).max(0.0) / upm,
162            (xmax - advance).max(0.0),
163        )
164    });
165    GlyphMetrics {
166        advance,
167        height,
168        depth,
169        italic,
170    }
171}
172
173/// Kerning between two glyphs of one face, in ems.
174#[must_use]
175pub fn kern_em(font: &fmd_font::Font, left: u16, right: u16) -> f64 {
176    let upm = f64::from(font.units_per_em.max(1));
177    f64::from(font.kerning_between_glyphs(left, right)) / upm
178}
179
180/// The TeX math-italic convention for a direct character with no alphabet
181/// override: Latin letters and lowercase Greek render from the italic
182/// face; everything else (digits, uppercase Greek, operators, symbols)
183/// from the regular face — with the roster as fallback either way.
184#[must_use]
185pub fn default_math_chain(ch: char) -> &'static [FaceId] {
186    let italicized = ch.is_ascii_alphabetic()
187        || ('α'..='ω').contains(&ch)
188        || ch == 'ϵ'
189        || ch == 'ϑ'
190        || ch == 'ϖ'
191        || ch == 'ϱ'
192        || ch == 'ς'
193        || ch == 'φ'
194        || ch == 'ϕ';
195    if italicized {
196        &[FACE_ITALIC, FACE_REGULAR, FACE_SYMBOLS]
197    } else {
198        &[FACE_REGULAR, FACE_SYMBOLS, FACE_ITALIC]
199    }
200}
201
202/// Map a character under a math alphabet, returning the (possibly
203/// remapped) character and its face-preference chain. Blackboard and
204/// calligraphic go through the Unicode math-alphanumeric planes (with the
205/// Letterlike exceptions), which the symbol face covers; the styled text
206/// faces handle the rest.
207#[must_use]
208pub fn alphabet_map(font: MathFont, ch: char) -> (char, &'static [FaceId]) {
209    match font {
210        MathFont::Roman => (ch, &[FACE_REGULAR, FACE_SYMBOLS]),
211        MathFont::Bold => (ch, &[FACE_BOLD, FACE_REGULAR, FACE_SYMBOLS]),
212        MathFont::Italic => (ch, &[FACE_ITALIC, FACE_REGULAR, FACE_SYMBOLS]),
213        MathFont::BoldItalic => (
214            ch,
215            &[FACE_BOLD_ITALIC, FACE_BOLD, FACE_ITALIC, FACE_REGULAR],
216        ),
217        MathFont::Typewriter => (ch, &[FACE_TYPEWRITER, FACE_REGULAR]),
218        MathFont::SansSerif => (ch, &[FACE_SANS, FACE_REGULAR]),
219        MathFont::Blackboard => (
220            blackboard_char(ch),
221            &[FACE_SYMBOLS, FACE_REGULAR, FACE_ITALIC],
222        ),
223        MathFont::Calligraphic => (
224            calligraphic_char(ch),
225            &[FACE_SYMBOLS, FACE_ITALIC, FACE_REGULAR],
226        ),
227    }
228}
229
230/// The double-struck (blackboard) codepoint of a character: the Letterlike
231/// exceptions first, then U+1D538-block letters and U+1D7D8-block digits;
232/// unmapped characters pass through (and then fail resolution precisely).
233#[must_use]
234pub fn blackboard_char(ch: char) -> char {
235    match ch {
236        'C' => 'ℂ',
237        'H' => 'ℍ',
238        'N' => 'ℕ',
239        'P' => 'ℙ',
240        'Q' => 'ℚ',
241        'R' => 'ℝ',
242        'Z' => 'ℤ',
243        'A'..='Z' => offset_char(0x1D538, ch, 'A'),
244        'a'..='z' => offset_char(0x1D552, ch, 'a'),
245        '0'..='9' => offset_char(0x1D7D8, ch, '0'),
246        other => other,
247    }
248}
249
250/// The script (calligraphic) codepoint of a character, with the Letterlike
251/// exceptions.
252#[must_use]
253pub fn calligraphic_char(ch: char) -> char {
254    match ch {
255        'B' => 'ℬ',
256        'E' => 'ℰ',
257        'F' => 'ℱ',
258        'H' => 'ℋ',
259        'I' => 'ℐ',
260        'L' => 'ℒ',
261        'M' => 'ℳ',
262        'R' => 'ℛ',
263        'e' => 'ℯ',
264        'g' => 'ℊ',
265        'o' => 'ℴ',
266        'A'..='Z' => offset_char(0x1D49C, ch, 'A'),
267        'a'..='z' => offset_char(0x1D4B6, ch, 'a'),
268        other => other,
269    }
270}
271
272fn offset_char(base: u32, ch: char, zero: char) -> char {
273    char::from_u32(base + (ch as u32) - (zero as u32)).unwrap_or(ch)
274}
275
276/// Spacing fallbacks for combining accent marks: when a face maps neither
277/// the combining character nor anything in the chain, the spacing
278/// equivalent often exists (CM Unicode carries the spacing accents).
279#[must_use]
280pub fn accent_spacing_fallback(combining: char) -> Option<char> {
281    Some(match combining {
282        '\u{0302}' => '\u{02C6}', // circumflex
283        '\u{0303}' => '\u{02DC}', // small tilde
284        '\u{0304}' => '\u{00AF}', // macron
285        '\u{0306}' => '\u{02D8}', // breve
286        '\u{0307}' => '\u{02D9}', // dot above
287        '\u{0308}' => '\u{00A8}', // diaeresis
288        '\u{030A}' => '\u{02DA}', // ring above
289        '\u{030C}' => '\u{02C7}', // caron
290        '\u{0301}' => '\u{00B4}', // acute
291        '\u{0300}' => '`',        // grave
292        '\u{20D7}' => '\u{2192}', // vector arrow → right arrow, scaled
293        _ => return None,
294    })
295}
296
297#[cfg(all(test, feature = "bundled-faces"))]
298#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn bundled_roster_parses_and_resolves_the_basics() {
304        let faces = match FaceSet::bundled() {
305            Ok(f) => f,
306            Err(e) => panic!("bundled faces: {e}"),
307        };
308        assert_eq!(faces.len(), 7);
309        // Letters italicize; digits stay upright.
310        let Some((face, gid)) = faces.resolve('x', default_math_chain('x')) else {
311            panic!("x must resolve");
312        };
313        assert_eq!(face, FACE_ITALIC);
314        assert_ne!(gid, 0);
315        let Some((face, _)) = faces.resolve('7', default_math_chain('7')) else {
316            panic!("7 must resolve");
317        };
318        assert_eq!(face, FACE_REGULAR);
319    }
320
321    #[test]
322    fn metrics_are_sane_for_x() {
323        let faces = match FaceSet::bundled() {
324            Ok(f) => f,
325            Err(e) => panic!("bundled faces: {e}"),
326        };
327        let Some((face, gid)) = faces.resolve('x', &[FACE_REGULAR]) else {
328            panic!("x in regular");
329        };
330        let Some(font) = faces.font(face) else {
331            panic!("face")
332        };
333        let m = glyph_metrics(font, gid);
334        assert!(m.advance > 0.3 && m.advance < 0.7, "{m:?}");
335        // x-height of the bundled CM within 0.13% of σ5 (the ratified
336        // validation).
337        assert!(
338            (m.height - crate::metrics::CM.x_height).abs() < 0.002,
339            "measured x-height {} vs σ5 {}",
340            m.height,
341            crate::metrics::CM.x_height
342        );
343    }
344}