dinamika_cpu/text/mod.rs
1//! Text: loading TrueType/OpenType fonts with [`ttf-parser`], turning glyph
2//! outlines into [`Path`]s and a small horizontal layout for strings.
3//!
4//! The flow mirrors the rest of the crate: a [`Font`] produces a [`Path`] (the
5//! filled glyph outlines), which is then rasterized by the usual
6//! [`Pixmap::fill_path`]. For convenience [`Pixmap::fill_text`] wraps the two
7//! steps together.
8//!
9//! # Coordinate system
10//!
11//! Glyph outlines are authored on a Y-**up** design grid in *font units* (see
12//! [`Font::units_per_em`]) with the origin on the text baseline. Pixmap space is
13//! Y-**down**. [`Font::text_path`] bakes the conversion in (uniform scale
14//! `size / units_per_em`, Y flip, baseline placement), so the returned path is
15//! already in pixels and ready to fill (see [`text::outline`](outline) for the
16//! exact mapping).
17//!
18//! # Known limitations
19//!
20//! Deliberately minimal layout for an MVP:
21//!
22//! - **No shaping or kerning.** Glyphs are placed one after another using only
23//! their horizontal advance. Ligatures, contextual forms, the `kern`/`GPOS`
24//! tables and combining marks are ignored.
25//! - **No bidirectional / complex scripts.** Characters are laid out strictly
26//! left-to-right; the only layout control is the `\n` line break.
27//! - **One glyph per `char`.** Each Unicode scalar is mapped to a single glyph
28//! via the font's `cmap`; missing characters fall back to `.notdef` (glyph 0).
29//!
30//! # Example
31//!
32//! ```no_run
33//! use dinamika_cpu::*;
34//!
35//! let data = std::fs::read("font.ttf").unwrap();
36//! let font = Font::from_slice(&data).unwrap();
37//!
38//! let mut pixmap = Pixmap::new(400, 120).unwrap();
39//! pixmap.fill(Color::WHITE);
40//!
41//! let paint = Paint::from_color(Color::BLACK);
42//! // Baseline origin at (16, 80), em size 48px.
43//! pixmap.fill_text(&font, "Hello", 48.0, 16.0, 80.0, &paint, Transform::identity(), None);
44//! ```
45
46mod outline;
47
48use crate::geometry::Transform;
49use crate::path::{Path, PathBuilder, PathSegment};
50use outline::OutlineSink;
51
52use core::fmt;
53use std::cell::RefCell;
54use std::collections::HashMap;
55use std::sync::Arc;
56
57/// The `.notdef` glyph, present in every valid font and used as the fallback
58/// for characters the font has no glyph for.
59const NOTDEF: ttf_parser::GlyphId = ttf_parser::GlyphId(0);
60
61/// An error returned while loading a [`Font`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FontError {
64 /// The byte buffer is not a valid TrueType/OpenType font/collection.
65 Parse(ttf_parser::FaceParsingError),
66}
67
68impl fmt::Display for FontError {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 FontError::Parse(e) => write!(f, "failed to parse font: {e}"),
72 }
73 }
74}
75
76impl std::error::Error for FontError {}
77
78impl From<ttf_parser::FaceParsingError> for FontError {
79 fn from(e: ttf_parser::FaceParsingError) -> Self {
80 FontError::Parse(e)
81 }
82}
83
84/// A parsed TrueType/OpenType font face.
85///
86/// The face borrows the font's byte buffer (`'a`), exactly like the underlying
87/// [`ttf_parser::Face`] — keep the bytes alive for at least as long as the
88/// `Font`. Parsing is cheap and allocation-free; metrics and outlines are read
89/// on demand.
90///
91/// # Glyph outline cache
92///
93/// Extracting a glyph's outline from the font (the `ttf-parser` outline walk) is
94/// the dominant cost when the same text is redrawn — for example, once per
95/// frame. [`Font`] therefore memoizes each glyph's outline, keyed by its
96/// [`GlyphId`](ttf_parser::GlyphId): the cached outline is stored once in
97/// font-design units and only scaled/positioned per draw. The cache uses
98/// interior mutability ([`RefCell`]), so a `Font` is single-threaded (not
99/// `Sync`) — share the loaded font bytes and create one `Font` per thread if you
100/// need concurrency.
101pub struct Font<'a> {
102 face: ttf_parser::Face<'a>,
103 /// Memoized glyph outlines (the contour segments) in unscaled font-design
104 /// space, Y already flipped to pixmap orientation with the baseline origin at
105 /// `(0, 0)`. `None` marks a glyph with no contours (e.g. a space) so the
106 /// negative result is cached too. Storing the bare segments (not a [`Path`])
107 /// keeps the cache — and `Font` — `Send`.
108 glyph_cache: RefCell<HashMap<ttf_parser::GlyphId, Option<Arc<[PathSegment]>>>>,
109}
110
111impl<'a> Font<'a> {
112 /// Parses the first face from the bytes of a font file (`.ttf`/`.otf`).
113 pub fn from_slice(data: &'a [u8]) -> Result<Self, FontError> {
114 Self::from_collection(data, 0)
115 }
116
117 /// Parses the face at `index` inside a font collection (`.ttc`). Use `0` for
118 /// a plain single-face file.
119 pub fn from_collection(data: &'a [u8], index: u32) -> Result<Self, FontError> {
120 let face = ttf_parser::Face::parse(data, index)?;
121 Ok(Font { face, glyph_cache: RefCell::new(HashMap::new()) })
122 }
123
124 /// Returns the glyph's outline in unscaled font-design space, building it
125 /// (and caching it) on first request. `None` for a glyph with no contours.
126 ///
127 /// The outline is stored with the Y axis already flipped into pixmap
128 /// orientation and the baseline origin at `(0, 0)`, so a placement only needs
129 /// a uniform scale plus a translation — see [`Font::text_path`].
130 fn glyph_outline(&self, gid: ttf_parser::GlyphId) -> Option<Arc<[PathSegment]>> {
131 if let Some(cached) = self.glyph_cache.borrow().get(&gid) {
132 return cached.clone();
133 }
134 // `scale = 1`, origin `(0, 0)`: the sink emits `(x, -y)`, i.e. font units
135 // with the Y axis flipped but not yet scaled. Empty glyphs leave the
136 // builder empty and `finish` returns `None`.
137 let mut builder = PathBuilder::new();
138 let mut sink = OutlineSink::new(&mut builder, 1.0, 0.0, 0.0);
139 let outline = match self.face.outline_glyph(gid, &mut sink) {
140 Some(_) => builder.finish().map(|p| Arc::from(p.segments())),
141 None => None,
142 };
143 self.glyph_cache.borrow_mut().insert(gid, outline.clone());
144 outline
145 }
146
147 /// The size of the design grid in font units — the denominator for scaling
148 /// outlines and metrics to a pixel `size`.
149 #[inline]
150 pub fn units_per_em(&self) -> u16 {
151 self.face.units_per_em()
152 }
153
154 /// Pixels-per-font-unit factor for an em `size` given in pixels. Returns `0`
155 /// for a degenerate font with `units_per_em == 0`.
156 #[inline]
157 fn scale(&self, size: f32) -> f32 {
158 match self.units_per_em() {
159 0 => 0.0,
160 upem => size / upem as f32,
161 }
162 }
163
164 /// Distance from the baseline up to the font's ascender, in pixels.
165 pub fn ascent(&self, size: f32) -> f32 {
166 self.face.ascender() as f32 * self.scale(size)
167 }
168
169 /// Distance from the baseline down to the font's descender, in pixels
170 /// (positive — Y grows downward in pixmap space).
171 pub fn descent(&self, size: f32) -> f32 {
172 -self.face.descender() as f32 * self.scale(size)
173 }
174
175 /// Recommended distance between successive baselines, in pixels
176 /// (`ascender - descender + line_gap`). This is the step used for `\n` in
177 /// [`Font::text_path`].
178 pub fn line_height(&self, size: f32) -> f32 {
179 self.face.height() as f32 * self.scale(size)
180 }
181
182 /// Horizontal advance of a single character, in pixels.
183 ///
184 /// Characters with no glyph fall back to the advance of `.notdef`.
185 pub fn advance_width(&self, ch: char, size: f32) -> f32 {
186 let gid = self.face.glyph_index(ch).unwrap_or(NOTDEF);
187 let advance = self.face.glyph_hor_advance(gid).unwrap_or(0);
188 advance as f32 * self.scale(size)
189 }
190
191 /// Width of the widest line of `text`, in pixels (sum of advances per line,
192 /// no kerning). `\n` separates lines.
193 pub fn measure(&self, text: &str, size: f32) -> f32 {
194 let mut widest = 0.0f32;
195 let mut current = 0.0f32;
196 for ch in text.chars() {
197 if ch == '\n' {
198 widest = widest.max(current);
199 current = 0.0;
200 } else {
201 current += self.advance_width(ch, size);
202 }
203 }
204 widest.max(current)
205 }
206
207 /// Builds the filled outline of a single character with its baseline origin
208 /// at `(x, y)` in pixmap coordinates, scaled to em `size` (pixels).
209 ///
210 /// Returns `None` when the character has no glyph or the glyph has no
211 /// contours (for example a space).
212 pub fn glyph_path(&self, ch: char, size: f32, x: f32, y: f32) -> Option<Path> {
213 let gid = self.face.glyph_index(ch)?;
214 let outline = self.glyph_outline(gid)?;
215 let mut builder = PathBuilder::new();
216 builder.push_path_transformed(&outline, &placement(self.scale(size), x, y));
217 builder.finish()
218 }
219
220 /// Lays a string out horizontally and returns one [`Path`] containing every
221 /// glyph's outline, ready to be filled with [`FillRule::NonZero`] — the rule
222 /// TrueType/OpenType outlines are authored for.
223 ///
224 /// `(x, y)` is the origin of the first baseline. Each `\n` resets the pen to
225 /// `x` and drops the baseline by one [`line_height`](Font::line_height).
226 /// Glyphs are advanced by their horizontal advance only (no kerning, see the
227 /// [module limitations](self#known-limitations)). Empty glyphs such as
228 /// spaces contribute no contours but still advance the pen.
229 ///
230 /// Returns `None` if the result is empty (e.g. whitespace-only text).
231 ///
232 /// [`FillRule::NonZero`]: crate::FillRule::NonZero
233 pub fn text_path(&self, text: &str, size: f32, x: f32, y: f32) -> Option<Path> {
234 let scale = self.scale(size);
235 let line_height = self.line_height(size);
236 let mut builder = PathBuilder::new();
237 let mut pen_x = x;
238 let mut baseline = y;
239
240 for ch in text.chars() {
241 if ch == '\n' {
242 pen_x = x;
243 baseline += line_height;
244 continue;
245 }
246 let gid = self.face.glyph_index(ch).unwrap_or(NOTDEF);
247 // Empty glyphs (e.g. spaces) have no cached outline; only their
248 // advance matters. Drawable glyphs re-emit their cached outline under
249 // this placement instead of being re-extracted from the font.
250 if let Some(outline) = self.glyph_outline(gid) {
251 builder.push_path_transformed(&outline, &placement(scale, pen_x, baseline));
252 }
253 let advance = self.face.glyph_hor_advance(gid).unwrap_or(0);
254 pen_x += advance as f32 * scale;
255 }
256
257 builder.finish()
258 }
259}
260
261/// The transform that places a cached glyph outline (unscaled font-design space,
262/// Y already flipped, baseline origin at `(0, 0)`) at pixel `(origin_x,
263/// baseline_y)` scaled by `scale` pixels per font unit: a uniform scale and a
264/// translation, no further Y flip (the outline is already flipped).
265#[inline]
266fn placement(scale: f32, origin_x: f32, baseline_y: f32) -> Transform {
267 Transform::from_row(scale, 0.0, 0.0, scale, origin_x, baseline_y)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 /// The glyph-outline cache must not cost `Font` its `Send` (so a loaded font
275 /// can still move between threads); it is intentionally not `Sync`.
276 #[test]
277 fn font_is_send() {
278 fn assert_send<T: Send>() {}
279 assert_send::<Font<'static>>();
280 }
281
282 #[test]
283 fn invalid_data_fails_to_parse() {
284 match Font::from_slice(&[0u8; 16]) {
285 Ok(_) => panic!("garbage bytes must not parse as a font"),
286 Err(err) => {
287 assert!(matches!(err, FontError::Parse(_)));
288 // The error renders a human-readable message.
289 assert!(!err.to_string().is_empty());
290 }
291 }
292 }
293}