Skip to main content

stipple_render/
text.rs

1//! Text: font loading and shaping, bridged to the scene graph via
2//! `oxideav-scribe`.
3//!
4//! A [`Font`] wraps a scribe `FaceChain`. [`Scene::fill_text`](crate::Scene)
5//! shapes a string into positioned glyph outlines and emits them as
6//! `oxideav-core` nodes, so text composites through the same CPU rasterizer as
7//! every other primitive — no separate text pipeline.
8//!
9//! Apps provide font bytes via [`Font::from_bytes`]; [`Font::system_default`]
10//! is a convenience that probes common OS font locations (handy for examples
11//! and tests, not meant for shipping apps).
12
13use crate::Color;
14use crate::scene::Scene;
15use core::fmt;
16use oxideav_core::{Group, Node, Paint, Transform2D};
17use oxideav_scribe::{Face, FaceChain, Shaper};
18use stipple_geometry::{Point, Size};
19
20/// A loaded, shapeable font face.
21pub struct Font {
22    chain: FaceChain,
23}
24
25impl fmt::Debug for Font {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.debug_struct("Font")
28            .field("units_per_em", &self.chain.primary().units_per_em())
29            .finish()
30    }
31}
32
33/// Error loading a [`Font`].
34#[derive(Debug)]
35pub struct FontError(String);
36
37impl fmt::Display for FontError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "font load error: {}", self.0)
40    }
41}
42
43impl std::error::Error for FontError {}
44
45impl Font {
46    /// Load a font from `sfnt` bytes (TrueType, OpenType/CFF, or TrueType
47    /// Collection — the first face of a collection is used).
48    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
49        let face = parse_face(bytes)?;
50        Ok(Self {
51            chain: FaceChain::new(face),
52        })
53    }
54
55    /// Probe common operating-system font directories and load the first
56    /// usable sans-serif face. Returns `None` if none is found.
57    ///
58    /// Intended for examples and tests; shipping apps should bundle or
59    /// explicitly locate their fonts and use [`Font::from_bytes`].
60    pub fn system_default() -> Option<Self> {
61        const CANDIDATES: &[&str] = &[
62            // Linux (Liberation / DejaVu / Noto are near-ubiquitous).
63            "/usr/share/fonts/liberation-fonts/LiberationSans-Regular.ttf",
64            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
65            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
66            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
67            "/usr/share/fonts/TTF/DejaVuSans.ttf",
68            "/usr/share/fonts/noto/NotoSans-Regular.ttf",
69            // macOS.
70            "/System/Library/Fonts/Helvetica.ttc",
71            "/Library/Fonts/Arial.ttf",
72            // Windows.
73            "C:\\Windows\\Fonts\\segoeui.ttf",
74            "C:\\Windows\\Fonts\\arial.ttf",
75        ];
76        for path in CANDIDATES {
77            if let Ok(bytes) = std::fs::read(path)
78                && let Ok(font) = Font::from_bytes(bytes)
79            {
80                return Some(font);
81            }
82        }
83        None
84    }
85
86    /// Distance from the top of the text box to the baseline, in logical
87    /// pixels, at `size_px`.
88    pub fn ascent(&self, size_px: f64) -> f64 {
89        self.chain.primary().ascent_px(size_px as f32) as f64
90    }
91
92    /// Full line height (ascent + descent + line gap) at `size_px`.
93    pub fn line_height(&self, size_px: f64) -> f64 {
94        self.chain.primary().line_height_px(size_px as f32) as f64
95    }
96
97    /// Summed advance width of a single line (no newlines) at `size_px`.
98    fn line_width(&self, line: &str, size_px: f64) -> f64 {
99        match self.chain.shape(line, size_px as f32) {
100            Ok(glyphs) => glyphs.iter().map(|g| g.x_advance).sum::<f32>() as f64,
101            Err(_) => 0.0,
102        }
103    }
104
105    /// Measure the rendered size of `text` at `size_px`: the widest line's
106    /// advance width × the number of newline-separated lines times line height.
107    /// A trailing newline counts as an extra (empty) line.
108    pub fn measure(&self, text: &str, size_px: f64) -> Size {
109        let mut max_w: f64 = 0.0;
110        let mut lines = 0usize;
111        for line in text.split('\n') {
112            lines += 1;
113            max_w = max_w.max(self.line_width(line, size_px));
114        }
115        Size::new(max_w, self.line_height(size_px) * lines as f64)
116    }
117
118    /// Greedily wrap `text` to lines no wider than `max_width` logical pixels at
119    /// `size_px`, breaking at spaces. Existing `\n` are hard breaks. A single
120    /// word wider than `max_width` is kept on its own (over-long) line rather
121    /// than split mid-word. Returns at least one line.
122    pub fn wrap(&self, text: &str, size_px: f64, max_width: f64) -> Vec<String> {
123        // Shape each word once and accumulate widths (a space between words),
124        // rather than re-shaping the growing line — O(words) shapes per call.
125        let space = self.line_width(" ", size_px);
126        let mut out = Vec::new();
127        for hard in text.split('\n') {
128            let mut line = String::new();
129            let mut width = 0.0;
130            for word in hard.split(' ') {
131                let ww = self.line_width(word, size_px);
132                if line.is_empty() {
133                    line.push_str(word);
134                    width = ww;
135                } else if width + space + ww <= max_width {
136                    line.push(' ');
137                    line.push_str(word);
138                    width += space + ww;
139                } else {
140                    out.push(std::mem::take(&mut line));
141                    line.push_str(word);
142                    width = ww;
143                }
144            }
145            out.push(line);
146        }
147        if out.is_empty() {
148            out.push(String::new());
149        }
150        out
151    }
152
153    pub(crate) fn chain(&self) -> &FaceChain {
154        &self.chain
155    }
156}
157
158fn parse_face(bytes: Vec<u8>) -> Result<Face, FontError> {
159    let result = match bytes.first_chunk::<4>() {
160        Some(b"OTTO") => Face::from_otf_bytes(bytes),
161        Some(b"ttcf") => Face::from_ttc_bytes(bytes, 0),
162        _ => Face::from_ttf_bytes(bytes),
163    };
164    result.map_err(|e| FontError(format!("{e:?}")))
165}
166
167/// Recolor an outline glyph to `color`, recursing into groups (the shaper
168/// wraps each glyph's path in a cache-keyed `Group`). Non-outline leaves (e.g.
169/// color-bitmap emoji `Node::Image`) are left untouched.
170fn recolor(node: Node, color: Color) -> Node {
171    match node {
172        Node::Path(mut path) => {
173            path.fill = Some(Paint::Solid(color.to_oxideav()));
174            Node::Path(path)
175        }
176        Node::Group(mut group) => {
177            group.children = group
178                .children
179                .into_iter()
180                .map(|c| recolor(c, color))
181                .collect();
182            Node::Group(group)
183        }
184        other => other,
185    }
186}
187
188impl Scene {
189    /// Shape and paint `text` with `font` at `origin` (the top-left of the text
190    /// box, logical pixels), `size_px`, and `color`. Newlines (`\n`) start a new
191    /// line, each dropped by one `line_height` from the last.
192    ///
193    /// Glyphs are emitted as scene-graph nodes under a group translated to the
194    /// baseline, so they rasterize and composite like any other primitive.
195    pub fn fill_text(
196        &mut self,
197        font: &Font,
198        text: &str,
199        origin: Point,
200        size_px: f64,
201        color: Color,
202    ) {
203        if text.is_empty() || size_px <= 0.0 {
204            return;
205        }
206        // Record a structured command for the GPU backend (the CPU rasterizer
207        // uses the glyph nodes pushed below).
208        self.record_text(text, origin, size_px, color);
209        let line_height = font.line_height(size_px);
210        let ascent = font.ascent(size_px);
211        for (i, line) in text.split('\n').enumerate() {
212            if line.is_empty() {
213                continue;
214            }
215            let glyphs = Shaper::shape_to_paths(font.chain(), line, size_px as f32);
216            if glyphs.is_empty() {
217                continue;
218            }
219            let mut run = Vec::with_capacity(glyphs.len());
220            for (_face_idx, node, transform) in glyphs {
221                let glyph = Group::new()
222                    .with_transform(transform)
223                    .with_child(recolor(node, color));
224                run.push(Node::Group(glyph));
225            }
226            // Pen starts at origin.x; the baseline drops by the ascent plus this
227            // line's offset so the text box top aligns to origin.y.
228            let baseline = (origin.y + i as f64 * line_height + ascent) as f32;
229            let placed = Group::new()
230                .with_transform(Transform2D::translate(origin.x as f32, baseline))
231                .with_children(run);
232            self.push_node(Node::Group(placed));
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::SoftwareRenderer;
241    use stipple_geometry::{Rect, ScaleFactor};
242
243    #[test]
244    fn measure_is_monotonic_in_length() {
245        let Some(font) = Font::system_default() else {
246            eprintln!("skipping: no system font found");
247            return;
248        };
249        let short = font.measure("i", 16.0);
250        let long = font.measure("internationalization", 16.0);
251        assert!(long.width > short.width);
252        assert!(short.height > 0.0);
253    }
254
255    #[test]
256    fn measure_counts_newline_separated_lines() {
257        let Some(font) = Font::system_default() else {
258            eprintln!("skipping: no system font found");
259            return;
260        };
261        let one = font.measure("Hello", 16.0);
262        let two = font.measure("Hello\nWorld!", 16.0);
263        // Two lines are about twice as tall as one (within rounding).
264        assert!((two.height - 2.0 * one.height).abs() < 1.0);
265        // Width is the widest line ("World!" > "Hello").
266        assert!(two.width >= one.width);
267        // A trailing newline adds an (empty) third line of height.
268        let trailing = font.measure("Hello\nWorld!\n", 16.0);
269        assert!((trailing.height - 3.0 * one.height).abs() < 1.0);
270    }
271
272    #[test]
273    fn wrap_breaks_at_spaces_within_width() {
274        let Some(font) = Font::system_default() else {
275            eprintln!("skipping: no system font found");
276            return;
277        };
278        let text = "the quick brown fox jumps over the lazy dog";
279        let full = font.measure(text, 16.0).width;
280        // Wrapping to half the natural width yields more than one line, and no
281        // wrapped line exceeds that width (each individual word fits).
282        let lines = font.wrap(text, 16.0, full / 2.0);
283        assert!(lines.len() > 1);
284        for line in &lines {
285            assert!(font.measure(line, 16.0).width <= full / 2.0 + 0.5);
286        }
287        // Hard newlines are preserved as breaks.
288        assert_eq!(font.wrap("a\nb", 16.0, 10_000.0), vec!["a", "b"]);
289    }
290
291    #[test]
292    fn text_paints_visible_pixels() {
293        let Some(font) = Font::system_default() else {
294            eprintln!("skipping: no system font found");
295            return;
296        };
297        let mut scene = Scene::new(Size::new(200.0, 50.0));
298        // White background so black text stands out.
299        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 200.0, 50.0), Color::WHITE);
300        scene.fill_text(&font, "Hello", Point::new(8.0, 8.0), 28.0, Color::BLACK);
301
302        let pm = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
303        // Some pixel must be darkened by a glyph (not pure white).
304        let mut darkened = 0;
305        for y in 0..pm.size().height {
306            for x in 0..pm.size().width {
307                if let Some([r, _, _, _]) = pm.pixel(x, y)
308                    && r < 128
309                {
310                    darkened += 1;
311                }
312            }
313        }
314        assert!(
315            darkened > 20,
316            "expected glyph coverage, got {darkened} dark pixels"
317        );
318    }
319
320    #[test]
321    fn text_uses_requested_color() {
322        let Some(font) = Font::system_default() else {
323            eprintln!("skipping: no system font found");
324            return;
325        };
326        let mut scene = Scene::new(Size::new(160.0, 50.0));
327        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 160.0, 50.0), Color::WHITE);
328        // Pure red text: glyph interiors must be red, not the default black.
329        scene.fill_text(
330            &font,
331            "RED",
332            Point::new(8.0, 8.0),
333            32.0,
334            Color::rgb(255, 0, 0),
335        );
336
337        let pm = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
338        let mut reddish = 0;
339        for y in 0..pm.size().height {
340            for x in 0..pm.size().width {
341                if let Some([r, g, b, _]) = pm.pixel(x, y)
342                    && r > 180
343                    && g < 80
344                    && b < 80
345                {
346                    reddish += 1;
347                }
348            }
349        }
350        assert!(
351            reddish > 20,
352            "expected red glyph coverage, got {reddish} red pixels"
353        );
354    }
355}