blockworx_text/
outline.rs1use 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#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct FontPoint {
15 pub x: f32,
16 pub y: f32,
17}
18
19#[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
31pub struct Outlines {
34 face: ttf_parser::Face<'static>,
35 metrics: Metrics,
36 traced: RefCell<HashMap<GlyphId, Arc<[PathCommand]>>>,
37}
38
39impl Outlines {
40 #[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 #[must_use]
57 pub fn metrics(&self) -> Metrics {
58 self.metrics
59 }
60
61 #[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 #[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}