Skip to main content

lightweight_pdf_fonts/
font.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use skrifa::attribute::Style;
6use skrifa::raw::TableProvider;
7use skrifa::{FontRef, MetadataProvider, Tag};
8
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum FontError {
11    /// Not a static TrueType `glyf` font (ADR-012: variable fonts and
12    /// CFF/OTF are explicitly rejected in V1).
13    UnsupportedFont,
14    /// Malformed font data that `skrifa` could not parse at all.
15    ParseError,
16    /// Structurally inconsistent font tables discovered while subsetting
17    /// (e.g. a `loca`/`glyf`/`maxp` mismatch, or a mandatory table `skrifa`
18    /// couldn't decode) — distinct from `ParseError` because the font's
19    /// sfnt directory itself parsed fine; this is caught by lightweight-pdf's
20    /// own table walking.
21    MalformedFont,
22}
23
24impl core::fmt::Display for FontError {
25    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
26        match self {
27            FontError::UnsupportedFont => write!(f, "unsupported font (need static TrueType glyf)"),
28            FontError::ParseError => write!(f, "could not parse font data"),
29            FontError::MalformedFont => write!(f, "malformed or inconsistent font tables"),
30        }
31    }
32}
33
34/// Owns font bytes; a `skrifa::FontRef` is only ever created transiently on
35/// access, never stored self-referentially (ADR-010 / contract point 3).
36#[derive(Clone, Debug)]
37pub struct FontData {
38    bytes: Arc<[u8]>,
39}
40
41impl FontData {
42    /// Accepts only static TrueType fonts with `glyf` outlines (ADR-012).
43    /// `FontRef::new` only validates the sfnt directory itself — presence of
44    /// `glyf` and absence of `fvar` (the standard variable-font marker) are
45    /// checked explicitly here, same as before.
46    pub fn load(bytes: impl Into<Arc<[u8]>>) -> Result<Self, FontError> {
47        let bytes: Arc<[u8]> = bytes.into();
48        let font = FontRef::new(&bytes).map_err(|_| FontError::ParseError)?;
49        if font.data_for_tag(Tag::new(b"glyf")).is_none() {
50            return Err(FontError::UnsupportedFont);
51        }
52        if font.data_for_tag(Tag::new(b"fvar")).is_some() {
53            return Err(FontError::UnsupportedFont);
54        }
55        Ok(FontData { bytes })
56    }
57
58    pub fn bytes(&self) -> &[u8] {
59        &self.bytes
60    }
61
62    pub fn with_font<R>(&self, f: impl FnOnce(&FontRef) -> R) -> Result<R, FontError> {
63        let font = FontRef::new(&self.bytes).map_err(|_| FontError::ParseError)?;
64        Ok(f(&font))
65    }
66}
67
68/// Metrics for a font, keyed by Unicode character rather than a fixed
69/// 256-code table (Phase 4 lifts the WinAnsi-only limitation from Phase
70/// 0-2's simple-font text path — Type-0/CID text has no such limit).
71/// Advances are computed lazily via `cmap`+`hmtx` lookups and cached per
72/// character, since the same handful of glyphs typically repeat often in
73/// one document (invoice/report text, not novel-scale volume — this
74/// simplicity/performance trade-off is accepted explicitly for V1: "kein
75/// Kerning ... zunächst ignorierbar").
76#[derive(Debug)]
77pub struct EmbeddedFontMetrics {
78    font: FontData,
79    advance_cache: RefCell<HashMap<char, f32>>,
80    pub ascent: f32,
81    pub descent: f32,
82    pub cap_height: f32,
83    pub italic_angle: f32,
84    /// FontBBox in 1000-upm glyph space: (xmin, ymin, xmax, ymax).
85    pub bbox: (f32, f32, f32, f32),
86    pub is_italic: bool,
87    pub is_bold: bool,
88}
89
90impl Clone for EmbeddedFontMetrics {
91    fn clone(&self) -> Self {
92        EmbeddedFontMetrics {
93            font: self.font.clone(),
94            advance_cache: RefCell::new(self.advance_cache.borrow().clone()),
95            ascent: self.ascent,
96            descent: self.descent,
97            cap_height: self.cap_height,
98            italic_angle: self.italic_angle,
99            bbox: self.bbox,
100            is_italic: self.is_italic,
101            is_bold: self.is_bold,
102        }
103    }
104}
105
106impl EmbeddedFontMetrics {
107    pub fn from_font_data(data: &FontData) -> Result<Self, FontError> {
108        data.with_font(|font| -> Result<Self, FontError> {
109            let head = font.head().map_err(|_| FontError::MalformedFont)?;
110            let hhea = font.hhea().map_err(|_| FontError::MalformedFont)?;
111            let upem = head.units_per_em() as f32;
112            let scale = 1000.0 / upem;
113            let ascent = hhea.ascender().to_i16() as f32 * scale;
114            let cap_height = font
115                .os2()
116                .ok()
117                .and_then(|os2| os2.s_cap_height())
118                .map(|c| c as f32 * scale)
119                .unwrap_or(ascent);
120            let italic_angle = font.post().ok().map(|post| post.italic_angle().to_f64() as f32).unwrap_or(0.0);
121            // `Style::Oblique` counts as italic too (skrifa's attribute model
122            // distinguishes the two; ttf-parser's narrower `is_italic()`
123            // only covered the ITALIC bit — a deliberate, documented
124            // widening, not a bug, see ADR-015).
125            let attrs = font.attributes();
126            Ok(EmbeddedFontMetrics {
127                font: data.clone(),
128                advance_cache: RefCell::new(HashMap::new()),
129                ascent,
130                descent: hhea.descender().to_i16() as f32 * scale,
131                cap_height,
132                italic_angle,
133                bbox: (
134                    head.x_min() as f32 * scale,
135                    head.y_min() as f32 * scale,
136                    head.x_max() as f32 * scale,
137                    head.y_max() as f32 * scale,
138                ),
139                is_italic: attrs.style != Style::Normal,
140                is_bold: attrs.weight >= skrifa::attribute::Weight::BOLD,
141            })
142        })?
143    }
144
145    /// Advance width for a character in 1/1000 em units, or `None` if the
146    /// font has no glyph for it.
147    pub fn advance_1000(&self, ch: char) -> Option<f32> {
148        if let Some(w) = self.advance_cache.borrow().get(&ch) {
149            return Some(*w);
150        }
151        let advance = self
152            .font
153            .with_font(|font| {
154                let upem = font.head().ok()?.units_per_em() as f32;
155                let gid = font.charmap().map(ch)?;
156                font.hmtx().ok()?.advance(gid).map(|a| a as f32 * 1000.0 / upem)
157            })
158            .ok()
159            .flatten();
160        if let Some(w) = advance {
161            self.advance_cache.borrow_mut().insert(ch, w);
162        }
163        advance
164    }
165
166    /// The original glyph ID for a character, or `None` if the font has no
167    /// glyph for it. Used by the subsetter (`subset::subset_font`) to
168    /// determine exactly which glyphs a document needs.
169    pub fn glyph_id(&self, ch: char) -> Option<u16> {
170        self.font
171            .with_font(|font| font.charmap().map(ch).map(|g| g.to_u32() as u16))
172            .ok()
173            .flatten()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn regular_bytes() -> Vec<u8> {
182        std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).expect("test font asset present")
183    }
184
185    fn bold_bytes() -> Vec<u8> {
186        std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Bold.ttf")).expect("test font asset present")
187    }
188
189    #[test]
190    fn loads_static_glyf_font() {
191        let data = FontData::load(regular_bytes()).unwrap();
192        assert!(data.bytes().len() > 1000);
193    }
194
195    #[test]
196    fn rejects_garbage_bytes() {
197        assert_eq!(FontData::load(vec![0u8; 16]).unwrap_err(), FontError::ParseError);
198    }
199
200    #[test]
201    fn computes_plausible_metrics_for_regular_and_bold() {
202        for bytes in [regular_bytes(), bold_bytes()] {
203            let data = FontData::load(bytes).unwrap();
204            let metrics = EmbeddedFontMetrics::from_font_data(&data).unwrap();
205            let w = metrics.advance_1000('H').unwrap();
206            assert!(w > 400.0 && w < 900.0, "unexpected advance for 'H': {w}");
207            assert!(metrics.ascent > 0.0);
208            assert!(metrics.descent < 0.0);
209            // German business-document essentials must be present.
210            for ch in ['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß', '€', '–', '„', '"'] {
211                assert!(metrics.advance_1000(ch).is_some(), "missing glyph for {ch:?}");
212                assert!(metrics.glyph_id(ch).is_some(), "missing glyph id for {ch:?}");
213            }
214        }
215    }
216
217    #[test]
218    fn advance_lookup_is_cached_and_repeatable() {
219        let data = FontData::load(regular_bytes()).unwrap();
220        let metrics = EmbeddedFontMetrics::from_font_data(&data).unwrap();
221        let first = metrics.advance_1000('x').unwrap();
222        let second = metrics.advance_1000('x').unwrap();
223        assert_eq!(first, second);
224    }
225
226    #[test]
227    fn unrepresentable_glyph_is_none_not_a_panic() {
228        let data = FontData::load(regular_bytes()).unwrap();
229        let metrics = EmbeddedFontMetrics::from_font_data(&data).unwrap();
230        // A private-use-area codepoint no reasonable text font maps.
231        assert_eq!(metrics.advance_1000('\u{E000}'), None);
232        assert_eq!(metrics.glyph_id('\u{E000}'), None);
233    }
234}