Skip to main content

zenith_layout/
font_meta.rs

1//! Font face metadata extraction via `rustybuzz::ttf_parser`.
2//!
3//! Reads the family name, weight, and style from a raw TTF/OTF byte slice
4//! without loading a full shaping engine. Used at project-load time to
5//! register asset-declared fonts in [`BytesFontProvider`](zenith_core::BytesFontProvider).
6
7use rustybuzz::ttf_parser;
8use rustybuzz::ttf_parser::name_id;
9use zenith_core::FontStyle;
10
11use crate::error::LayoutError;
12
13/// Metadata extracted from a font face's name and OS/2 tables.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FaceMetadata {
16    /// The typographic family name (e.g. `"Noto Sans"`).
17    pub family: String,
18    /// Numeric weight (e.g. 400, 700).
19    pub weight: u16,
20    /// Normal or italic style.
21    pub style: FontStyle,
22}
23
24/// Extract [`FaceMetadata`] from raw font bytes at the given face `index`.
25///
26/// Family name resolution prefers name ID 16 (Typographic Family) over name
27/// ID 1 (Family), and prefers unicode-encoded entries within each ID group.
28///
29/// # Errors
30///
31/// Returns [`LayoutError`] when:
32/// - The bytes cannot be parsed as a valid font face.
33/// - The name table contains no usable family name entry.
34pub fn face_metadata(bytes: &[u8], index: u32) -> Result<FaceMetadata, LayoutError> {
35    let face = ttf_parser::Face::parse(bytes, index)
36        .map_err(|e| LayoutError::new(format!("font parse failed: {e:?}")))?;
37
38    let family =
39        best_family_name(&face).ok_or_else(|| LayoutError::new("font has no family name"))?;
40
41    let weight = face.weight().to_number();
42    let style = if face.is_italic() {
43        FontStyle::Italic
44    } else {
45        FontStyle::Normal
46    };
47
48    Ok(FaceMetadata {
49        family,
50        weight,
51        style,
52    })
53}
54
55/// Walk the name table and return the best available family name.
56///
57/// Strategy:
58/// 1. Collect the best unicode string for name ID 16 (Typographic Family).
59/// 2. Collect the best unicode string for name ID 1 (Family).
60/// 3. Return whichever is found first in that order; prefer unicode encoding.
61fn best_family_name(face: &ttf_parser::Face<'_>) -> Option<String> {
62    let mut typo_family: Option<String> = None;
63    let mut family: Option<String> = None;
64
65    for name in face.names() {
66        if name.name_id == name_id::TYPOGRAPHIC_FAMILY
67            && typo_family.is_none()
68            && let Some(s) = name.to_string()
69        {
70            typo_family = Some(s);
71        } else if name.name_id == name_id::FAMILY
72            && family.is_none()
73            && let Some(s) = name.to_string()
74        {
75            family = Some(s);
76        }
77    }
78
79    typo_family.or(family)
80}
81
82// ── Tests ─────────────────────────────────────────────────────────────────────
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    // Reuse the exact font bytes bundled by zenith-core rather than embedding a
89    // second copy here (the fonts live under zenith-core/assets/fonts/).
90    use zenith_core::font::embedded;
91    const REGULAR: &[u8] = embedded::NOTO_SANS_REGULAR;
92    const BOLD: &[u8] = embedded::NOTO_SANS_BOLD;
93    const ITALIC: &[u8] = embedded::NOTO_SANS_ITALIC;
94    const BOLD_ITALIC: &[u8] = embedded::NOTO_SANS_BOLD_ITALIC;
95    const MONO: &[u8] = embedded::NOTO_SANS_MONO_REGULAR;
96
97    #[test]
98    fn noto_sans_regular_family_weight_style() {
99        let m = face_metadata(REGULAR, 0).expect("regular must parse");
100        assert!(
101            m.family.contains("Noto Sans"),
102            "family should contain 'Noto Sans', got '{}'",
103            m.family
104        );
105        assert_eq!(m.weight, 400, "Regular weight must be 400");
106        assert_eq!(m.style, FontStyle::Normal, "Regular must be Normal");
107    }
108
109    #[test]
110    fn noto_sans_bold_weight() {
111        let m = face_metadata(BOLD, 0).expect("bold must parse");
112        assert!(
113            m.family.contains("Noto Sans"),
114            "family should contain 'Noto Sans', got '{}'",
115            m.family
116        );
117        assert_eq!(m.weight, 700, "Bold weight must be 700");
118        assert_eq!(m.style, FontStyle::Normal, "Bold (upright) must be Normal");
119    }
120
121    #[test]
122    fn noto_sans_italic_style() {
123        let m = face_metadata(ITALIC, 0).expect("italic must parse");
124        assert!(
125            m.family.contains("Noto Sans"),
126            "family should contain 'Noto Sans', got '{}'",
127            m.family
128        );
129        assert_eq!(m.style, FontStyle::Italic, "Italic must be Italic");
130    }
131
132    #[test]
133    fn noto_sans_bold_italic_weight_and_style() {
134        let m = face_metadata(BOLD_ITALIC, 0).expect("bold-italic must parse");
135        assert!(
136            m.family.contains("Noto Sans"),
137            "family should contain 'Noto Sans', got '{}'",
138            m.family
139        );
140        assert_eq!(m.weight, 700, "Bold-Italic weight must be 700");
141        assert_eq!(m.style, FontStyle::Italic, "Bold-Italic must be Italic");
142    }
143
144    #[test]
145    fn noto_sans_mono_family() {
146        let m = face_metadata(MONO, 0).expect("mono must parse");
147        assert!(
148            m.family.contains("Noto Sans Mono") || m.family.contains("Noto Sans"),
149            "mono family should contain 'Noto Sans', got '{}'",
150            m.family
151        );
152        assert_eq!(m.weight, 400, "Mono Regular weight must be 400");
153        assert_eq!(m.style, FontStyle::Normal, "Mono Regular must be Normal");
154    }
155
156    #[test]
157    fn invalid_bytes_return_err() {
158        let result = face_metadata(b"not a font", 0);
159        assert!(result.is_err(), "invalid bytes must return Err");
160        let msg = result.unwrap_err().message;
161        assert!(
162            msg.contains("font parse failed"),
163            "error should mention 'font parse failed', got: {msg}"
164        );
165    }
166}