Skip to main content

pite_render/
text.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use ab_glyph::{Font, FontArc, PxScale};
3use anyhow::{Context, Result};
4
5pub const FONT_BYTES: &[u8] = include_bytes!("../fonts/Inter-Regular.ttf");
6
7pub struct BakedText {
8    pub rgba: Vec<u8>,
9    pub w: u32,
10    pub h: u32,
11}
12
13pub struct TextAtlas {
14    font: FontArc,
15}
16
17impl TextAtlas {
18    pub fn new() -> Result<Self> {
19        let font =
20            FontArc::try_from_vec(FONT_BYTES.to_vec()).context("cannot parse bundled font")?;
21        Ok(Self { font })
22    }
23
24    pub fn has_glyph(&self, ch: char) -> bool {
25        let gid = self.font.glyph_id(ch);
26        gid.0 != 0
27    }
28
29    pub fn bake(&self, text: &str, px: f32, color: [u8; 4]) -> BakedText {
30        let px = px.max(1.0);
31        let scale = PxScale::from(px);
32        let units = self.font.units_per_em().unwrap_or(2048.0);
33        let ascent = self.font.ascent_unscaled() * px / units;
34        let descent = self.font.descent_unscaled().abs() * px / units;
35        let mut width = 0.0f32;
36        let mut glyphs = Vec::new();
37        for ch in text.chars() {
38            let gid = self.font.glyph_id(ch);
39            let advance = self.font.h_advance_unscaled(gid) * px / units;
40            glyphs.push((gid, width));
41            width += advance;
42        }
43        let w = width.ceil().max(1.0) as u32;
44        let h = (ascent + descent).ceil().max(1.0) as u32;
45        let mut rgba = vec![0u8; (w * h * 4) as usize];
46        for (gid, pen_x) in glyphs {
47            let positioned = gid.with_scale_and_position(scale, ab_glyph::point(pen_x, ascent));
48            let Some(outlined) = self.font.outline_glyph(positioned) else {
49                continue;
50            };
51            let bounds = outlined.px_bounds();
52            outlined.draw(|gx, gy, v| {
53                let x = (bounds.min.x as i32 + gx as i32).clamp(0, w as i32 - 1);
54                let y = (bounds.min.y as i32 + gy as i32).clamp(0, h as i32 - 1);
55                let i = ((y as u32 * w + x as u32) * 4) as usize;
56                let a = (v * color[3] as f32).round() as u8;
57                rgba[i] = color[0];
58                rgba[i + 1] = color[1];
59                rgba[i + 2] = color[2];
60                rgba[i + 3] = rgba[i + 3].max(a);
61            });
62        }
63        BakedText { rgba, w, h }
64    }
65}
66
67pub fn parse_color(s: &str) -> [u8; 4] {
68    let hex = s.strip_prefix('#').unwrap_or(s);
69    let digits: Vec<u8> = match hex.len() {
70        3 => hex
71            .chars()
72            .flat_map(|c| [c, c])
73            .collect::<String>()
74            .chars()
75            .collect::<Vec<_>>()
76            .chunks(2)
77            .map(|pair| u8::from_str_radix(&pair.iter().collect::<String>(), 16).unwrap_or(255))
78            .collect(),
79        6 | 8 => (0..hex.len())
80            .step_by(2)
81            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(255))
82            .collect(),
83        _ => return [255, 255, 255, 255],
84    };
85    let mut out = [255, 255, 255, 255];
86    for (i, d) in digits.iter().take(4).enumerate() {
87        out[i] = *d;
88    }
89    if hex.len() == 6 {
90        out[3] = 255;
91    }
92    out
93}
94
95pub fn glyph_cache_key(text: &str, px: u32, color: [u8; 4]) -> String {
96    format!(
97        "text\0{text}\0{px}\0{}\0{}\0{}\0{}",
98        color[0], color[1], color[2], color[3]
99    )
100}
101
102pub fn lru_touch(order: &mut std::collections::VecDeque<String>, key: &str, cap: usize) {
103    order.retain(|k| k != key);
104    order.push_back(key.to_string());
105    while order.len() > cap {
106        order.pop_front();
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn bundled_font_parses_and_covers_latin() {
116        let atlas = TextAtlas::new().unwrap();
117        assert!(atlas.has_glyph('H'));
118        assert!(atlas.has_glyph('3'));
119        assert!(atlas.has_glyph(':'));
120    }
121
122    #[test]
123    fn baked_text_has_ink() {
124        let atlas = TextAtlas::new().unwrap();
125        let baked = atlas.bake("HP: 3", 20.0, [255, 255, 255, 255]);
126        assert!(baked.w > 10 && baked.h > 10);
127        let inked = baked.rgba.chunks(4).filter(|px| px[3] > 0).count();
128        assert!(inked > 20, "expected visible glyphs, got {inked}");
129    }
130
131    #[test]
132    fn empty_text_bakes_fully_transparent() {
133        let atlas = TextAtlas::new().unwrap();
134        let baked = atlas.bake("", 20.0, [255, 255, 255, 255]);
135        assert_eq!(baked.w, 1);
136        assert!(baked.rgba.iter().all(|b| *b == 0));
137    }
138
139    #[test]
140    fn color_forms_parse() {
141        assert_eq!(parse_color("#ff0000"), [255, 0, 0, 255]);
142        assert_eq!(parse_color("#0f0"), [0, 255, 0, 255]);
143        assert_eq!(parse_color("#00000000"), [0, 0, 0, 0]);
144        assert_eq!(parse_color("junk"), [255, 255, 255, 255]);
145    }
146}