Skip to main content

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}