Skip to main content

yog_book/
font.rs

1//! Custom TTF/OTF font support for book rendering.
2//! Uses `fontdue` to rasterize glyphs into a packed atlas texture (RGBA8).
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Reference to a registered font — serializable, stored in `BookPage::CustomText`.
8/// The actual TTF bytes live in `BookFontRegistry` (registered separately).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct BookFont {
11    /// Font registry ID (e.g. `"example:display_font"`).
12    pub font_id: String,
13    /// Render size in pixels (e.g. 14.0).
14    pub size_px: f32,
15}
16
17/// Global font data registry — maps font_id → raw TTF/OTF bytes.
18/// Populated independently of book JSON, before the first render.
19#[derive(Debug, Default)]
20pub struct BookFontRegistry {
21    pub fonts: HashMap<String, Vec<u8>>,
22}
23
24impl BookFontRegistry {
25    pub fn register(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
26        self.fonts.insert(id.into(), ttf);
27    }
28    pub fn get(&self, id: &str) -> Option<&[u8]> {
29        self.fonts.get(id).map(Vec::as_slice)
30    }
31}
32
33/// UV coordinates + metrics for one glyph in the atlas.
34#[derive(Debug, Clone)]
35pub struct GlyphInfo {
36    pub u0: f32,
37    pub v0: f32,
38    pub u1: f32,
39    pub v1: f32,
40    pub width: u32,
41    pub height: u32,
42    pub xoff: f32,
43    pub yoff: f32,
44    pub advance: f32,
45}
46
47/// Built glyph atlas: RGBA8 pixel buffer + glyph map.
48/// Upload `pixels` to GPU via `ctx.create_texture_rgba()` to get a GL handle.
49pub struct FontAtlas {
50    pub pixels: Vec<u8>,
51    pub atlas_size: u32,
52    pub glyphs: HashMap<char, GlyphInfo>,
53    pub line_height: f32,
54}
55
56/// ASCII + common Latin Extended-A charset for the atlas.
57#[cfg(feature = "fonts")]
58const ATLAS_CHARS: &str = " !\"#$%&'()*+,-./0123456789:;<=>?@\
59     ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`\
60     abcdefghijklmnopqrstuvwxyz{|}~\
61     ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß\
62     àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ";
63
64impl FontAtlas {
65    /// Build a glyph atlas from raw TTF/OTF bytes at the given pixel size.
66    /// Returns `None` if the `fonts` feature is not enabled or on parse error.
67    pub fn build(font_data: &[u8], size_px: f32) -> Option<FontAtlas> {
68        Self::build_impl(font_data, size_px)
69    }
70
71    #[cfg(feature = "fonts")]
72    fn build_impl(font_data: &[u8], size_px: f32) -> Option<FontAtlas> {
73        // Requires `fontdue = "0.8"` in Cargo.toml + feature "fonts" enabled.
74        let font = ::fontdue::Font::from_bytes(
75            font_data,
76            ::fontdue::FontSettings {
77                scale: size_px,
78                ..Default::default()
79            },
80        )
81        .ok()?;
82
83        const ATLAS_W: u32 = 512;
84        const ATLAS_H: u32 = 512;
85        let mut pixels = vec![0u8; (ATLAS_W * ATLAS_H * 4) as usize];
86        let mut glyphs = HashMap::new();
87
88        let row_h = size_px.ceil() as u32 + 2;
89        let mut cx: u32 = 1;
90        let mut cy: u32 = 1;
91
92        for ch in ATLAS_CHARS.chars() {
93            let (metrics, bitmap) = font.rasterize(ch, size_px);
94            let gw = metrics.width as u32;
95            let gh = metrics.height as u32;
96
97            if gw == 0 || gh == 0 {
98                glyphs.insert(
99                    ch,
100                    GlyphInfo {
101                        u0: 0.0,
102                        v0: 0.0,
103                        u1: 0.0,
104                        v1: 0.0,
105                        width: 0,
106                        height: 0,
107                        xoff: metrics.xmin as f32,
108                        yoff: metrics.ymin as f32,
109                        advance: metrics.advance_width,
110                    },
111                );
112                continue;
113            }
114            if cx + gw + 1 >= ATLAS_W {
115                cx = 1;
116                cy += row_h;
117            }
118            if cy + gh + 1 >= ATLAS_H {
119                break;
120            }
121
122            for row in 0..gh {
123                for col in 0..gw {
124                    let alpha = bitmap[(row * gw + col) as usize];
125                    let idx = ((cy + row) * ATLAS_W + (cx + col)) as usize * 4;
126                    pixels[idx] = 255;
127                    pixels[idx + 1] = 255;
128                    pixels[idx + 2] = 255;
129                    pixels[idx + 3] = alpha;
130                }
131            }
132
133            glyphs.insert(
134                ch,
135                GlyphInfo {
136                    u0: cx as f32 / ATLAS_W as f32,
137                    v0: cy as f32 / ATLAS_H as f32,
138                    u1: (cx + gw) as f32 / ATLAS_W as f32,
139                    v1: (cy + gh) as f32 / ATLAS_H as f32,
140                    width: gw,
141                    height: gh,
142                    xoff: metrics.xmin as f32,
143                    yoff: metrics.ymin as f32,
144                    advance: metrics.advance_width,
145                },
146            );
147            cx += gw + 1;
148        }
149
150        Some(FontAtlas {
151            pixels,
152            atlas_size: ATLAS_W,
153            glyphs,
154            line_height: size_px * 1.2,
155        })
156    }
157
158    #[cfg(not(feature = "fonts"))]
159    fn build_impl(_font_data: &[u8], _size_px: f32) -> Option<FontAtlas> {
160        None
161    }
162
163    /// Measure the width of a text string in pixels.
164    pub fn measure_width(&self, text: &str) -> f32 {
165        text.chars()
166            .map(|c| self.glyphs.get(&c).map(|g| g.advance).unwrap_or(0.0))
167            .sum()
168    }
169}