Skip to main content

pite_render/
text.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Font loading and software rasterization into CPU/GPU pixel buffers.
3
4use ab_glyph::{Font, FontArc, PxScale};
5use anyhow::{Context, Result};
6
7/// Bundled default font bytes (Inter Regular).
8pub const FONT_BYTES: &[u8] = include_bytes!("../fonts/Inter-Regular.ttf");
9
10/// Rasterized text raster image buffer in RGBA8 format.
11pub struct BakedText {
12    /// Contiguous RGBA8 pixel bytes.
13    pub rgba: Vec<u8>,
14    /// Pixel width.
15    pub w: u32,
16    /// Pixel height.
17    pub h: u32,
18}
19
20/// Font renderer and text rasterizer backed by `ab_glyph`.
21pub struct TextAtlas {
22    font: FontArc,
23}
24
25impl TextAtlas {
26    /// Creates a text rasterizer initialized with the bundled Inter font.
27    pub fn new() -> Result<Self> {
28        let font =
29            FontArc::try_from_vec(FONT_BYTES.to_vec()).context("cannot parse bundled font")?;
30        Ok(Self { font })
31    }
32
33    /// Checks if the loaded font contains a glyph for the given character.
34    pub fn has_glyph(&self, ch: char) -> bool {
35        let gid = self.font.glyph_id(ch);
36        gid.0 != 0
37    }
38
39    /// Bakes a string into an RGBA8 pixel bitmap at the specified point size and color.
40    pub fn bake(&self, text: &str, px: f32, color: [u8; 4]) -> BakedText {
41        let px = px.max(1.0);
42        let scale = PxScale::from(px);
43        let units = self.font.units_per_em().unwrap_or(2048.0);
44        let ascent = self.font.ascent_unscaled() * px / units;
45        let descent = self.font.descent_unscaled().abs() * px / units;
46        let mut width = 0.0f32;
47        let mut glyphs = Vec::new();
48        for ch in text.chars() {
49            let gid = self.font.glyph_id(ch);
50            let advance = self.font.h_advance_unscaled(gid) * px / units;
51            glyphs.push((gid, width));
52            width += advance;
53        }
54        let w = width.ceil().max(1.0) as u32;
55        let h = (ascent + descent).ceil().max(1.0) as u32;
56        let mut rgba = vec![0u8; (w * h * 4) as usize];
57        for (gid, pen_x) in glyphs {
58            let positioned = gid.with_scale_and_position(scale, ab_glyph::point(pen_x, ascent));
59            let Some(outlined) = self.font.outline_glyph(positioned) else {
60                continue;
61            };
62            let bounds = outlined.px_bounds();
63            outlined.draw(|gx, gy, v| {
64                let x = (bounds.min.x as i32 + gx as i32).clamp(0, w as i32 - 1);
65                let y = (bounds.min.y as i32 + gy as i32).clamp(0, h as i32 - 1);
66                let i = ((y as u32 * w + x as u32) * 4) as usize;
67                let a = (v * color[3] as f32).round() as u8;
68                rgba[i] = color[0];
69                rgba[i + 1] = color[1];
70                rgba[i + 2] = color[2];
71                rgba[i + 3] = rgba[i + 3].max(a);
72            });
73        }
74        BakedText { rgba, w, h }
75    }
76}
77
78pub fn glyph_cache_key(text: &str, px: u32, color: [u8; 4]) -> String {
79    format!(
80        "text\0{text}\0{px}\0{}\0{}\0{}\0{}",
81        color[0], color[1], color[2], color[3]
82    )
83}
84
85pub fn lru_touch(order: &mut std::collections::VecDeque<String>, key: &str, cap: usize) {
86    order.retain(|k| k != key);
87    order.push_back(key.to_string());
88    while order.len() > cap {
89        order.pop_front();
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn bundled_font_parses_and_covers_latin() {
99        let atlas = TextAtlas::new().unwrap();
100        assert!(atlas.has_glyph('H'));
101        assert!(atlas.has_glyph('3'));
102        assert!(atlas.has_glyph(':'));
103    }
104
105    #[test]
106    fn baked_text_has_ink() {
107        let atlas = TextAtlas::new().unwrap();
108        let baked = atlas.bake("HP: 3", 20.0, [255, 255, 255, 255]);
109        assert!(baked.w > 10 && baked.h > 10);
110        let inked = baked.rgba.chunks(4).filter(|px| px[3] > 0).count();
111        assert!(inked > 20, "expected visible glyphs, got {inked}");
112    }
113
114    #[test]
115    fn empty_text_bakes_fully_transparent() {
116        let atlas = TextAtlas::new().unwrap();
117        let baked = atlas.bake("", 20.0, [255, 255, 255, 255]);
118        assert_eq!(baked.w, 1);
119        assert!(baked.rgba.iter().all(|b| *b == 0));
120    }
121}