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