Skip to main content

blockworx_text/
outline.rs

1//! Glyph outlines: the ink behind a laid-out glyph, in font units.
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use blockworx_paint::FontChoice;
8use blockworx_paint::text::GlyphId;
9
10use crate::metrics::Metrics;
11
12/// A point of a glyph outline: font units, Y up, as the face states it.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct FontPoint {
15    pub x: f32,
16    pub y: f32,
17}
18
19/// One step of a glyph's outline. The vocabulary a face's contours are made
20/// of, which a consumer renders into its own path type — an SVG `d`, a
21/// `Path2D`.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum PathCommand {
24    MoveTo(FontPoint),
25    LineTo(FontPoint),
26    QuadTo(FontPoint, FontPoint),
27    CurveTo(FontPoint, FontPoint, FontPoint),
28    Close,
29}
30
31/// A face's outlines, traced on demand and kept: a label redrawn every frame
32/// traces its glyphs once.
33pub struct Outlines {
34    face: ttf_parser::Face<'static>,
35    metrics: Metrics,
36    traced: RefCell<HashMap<GlyphId, Arc<[PathCommand]>>>,
37}
38
39impl Outlines {
40    /// # Panics
41    ///
42    /// If the typeface's embedded bytes are not a font this can read.
43    #[expect(clippy::expect_used)]
44    #[must_use]
45    pub fn new(typeface: FontChoice) -> Self {
46        Self {
47            face: ttf_parser::Face::parse(typeface.bytes(), 0).expect("embedded font is valid"),
48            metrics: Metrics::of(typeface),
49            traced: RefCell::default(),
50        }
51    }
52
53    /// The metrics the [`Shaper`](crate::Shaper) over the same typeface lays
54    /// rows out by, so a consumer scales these outlines the way that layout
55    /// was measured.
56    #[must_use]
57    pub fn metrics(&self) -> Metrics {
58        self.metrics
59    }
60
61    /// The glyph a face's character map files `chr` under — the identity a
62    /// layout that carries none falls back to.
63    #[must_use]
64    pub fn glyph_index(&self, chr: char) -> Option<GlyphId> {
65        self.face.glyph_index(chr).map(|id| GlyphId(id.0))
66    }
67
68    /// `id`'s contours, empty where the glyph draws nothing (a space, an
69    /// unmapped character).
70    #[must_use]
71    pub fn outline(&self, id: GlyphId) -> Arc<[PathCommand]> {
72        if let Some(traced) = self.traced.borrow().get(&id) {
73            return Arc::clone(traced);
74        }
75        let mut trace = Trace::default();
76        self.face
77            .outline_glyph(ttf_parser::GlyphId(id.0), &mut trace);
78        let outline: Arc<[PathCommand]> = trace.commands.into();
79        self.traced.borrow_mut().insert(id, Arc::clone(&outline));
80        outline
81    }
82}
83
84#[derive(Default)]
85struct Trace {
86    commands: Vec<PathCommand>,
87}
88
89fn at(x: f32, y: f32) -> FontPoint {
90    FontPoint { x, y }
91}
92
93impl ttf_parser::OutlineBuilder for Trace {
94    fn move_to(&mut self, x: f32, y: f32) {
95        self.commands.push(PathCommand::MoveTo(at(x, y)));
96    }
97    fn line_to(&mut self, x: f32, y: f32) {
98        self.commands.push(PathCommand::LineTo(at(x, y)));
99    }
100    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
101        self.commands
102            .push(PathCommand::QuadTo(at(x1, y1), at(x, y)));
103    }
104    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
105        self.commands
106            .push(PathCommand::CurveTo(at(x1, y1), at(x2, y2), at(x, y)));
107    }
108    fn close(&mut self) {
109        self.commands.push(PathCommand::Close);
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn every_bundled_face_outlines_its_letters() {
119        for choice in FontChoice::ALL {
120            let outlines = Outlines::new(choice);
121            for chr in ['A', 'g', 'W'] {
122                let id = outlines
123                    .glyph_index(chr)
124                    .unwrap_or_else(|| panic!("{} maps {chr}", choice.display_name()));
125                let outline = outlines.outline(id);
126                assert!(
127                    matches!(outline.first(), Some(PathCommand::MoveTo(_))),
128                    "{} traces {chr} from a move: {outline:?}",
129                    choice.display_name()
130                );
131                assert!(
132                    outline.len() > 2,
133                    "{} traces {chr} as a contour",
134                    choice.display_name()
135                );
136            }
137        }
138    }
139
140    #[test]
141    fn a_glyph_that_draws_nothing_traces_nothing() {
142        let outlines = Outlines::new(FontChoice::Basic);
143        let space = outlines.glyph_index(' ').expect("Roboto maps the space");
144        assert!(outlines.outline(space).is_empty(), "a space has no ink");
145        assert!(
146            outlines.outline(GlyphId(u16::MAX)).is_empty(),
147            "a glyph the face does not hold has no ink"
148        );
149    }
150
151    #[test]
152    fn a_traced_glyph_comes_back_from_the_cache() {
153        let outlines = Outlines::new(FontChoice::Formal);
154        let id = outlines.glyph_index('B').expect("Forum maps B");
155        let once = outlines.outline(id);
156        let twice = outlines.outline(id);
157        assert!(Arc::ptr_eq(&once, &twice), "the outline is traced once");
158    }
159}