Skip to main content

oxihuman_core/
font_renderer.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Font rendering providing glyph metrics, text layout, and bounding box utilities.
5//! Backed by fontdue for real TTF/OTF rendering with a stub fallback when no font is loaded.
6
7#[allow(dead_code)]
8#[derive(Debug, Clone)]
9pub struct FontConfig {
10    pub size_px: u32,
11    pub bold: bool,
12    pub italic: bool,
13    pub name: String,
14}
15
16#[allow(dead_code)]
17#[derive(Debug, Clone)]
18pub struct GlyphMetrics {
19    pub advance: f32,
20    pub bearing_x: f32,
21    pub bearing_y: f32,
22    pub width: u32,
23    pub height: u32,
24}
25
26#[allow(dead_code)]
27#[derive(Debug, Clone)]
28pub struct TextLayout {
29    pub glyphs: Vec<GlyphMetrics>,
30    pub total_width: f32,
31    pub line_height: f32,
32}
33
34#[allow(dead_code)]
35#[derive(Debug, Clone)]
36pub struct FontRendererStub {
37    pub config: FontConfig,
38    pub glyph_cache_size: usize,
39}
40
41/// Error types for font rendering operations.
42#[derive(Debug, thiserror::Error)]
43pub enum FontError {
44    #[error("Failed to parse font: {0}")]
45    ParseError(String),
46    #[error("Glyph not found for character: {0}")]
47    GlyphNotFound(char),
48}
49
50/// Font renderer backed by a real TTF/OTF via fontdue.
51/// Falls back to approximate metrics when no font is loaded.
52pub struct FontRenderer {
53    inner: Option<fontdue::Font>,
54}
55
56impl FontRenderer {
57    /// Create a stub renderer with no underlying font.
58    /// Glyph metrics are approximated from character properties.
59    pub fn new_stub() -> Self {
60        FontRenderer { inner: None }
61    }
62
63    /// Create a renderer from raw TTF/OTF font bytes.
64    pub fn from_font_bytes(bytes: &[u8]) -> Result<Self, FontError> {
65        let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())
66            .map_err(|e| FontError::ParseError(e.to_string()))?;
67        Ok(FontRenderer { inner: Some(font) })
68    }
69
70    /// Measure a single glyph at the given pixel size.
71    /// Uses real fontdue metrics if a font is loaded, otherwise approximates.
72    pub fn measure_glyph(&self, c: char, px_size: f32) -> GlyphMetrics {
73        if let Some(ref font) = self.inner {
74            let m = font.metrics(c, px_size);
75            GlyphMetrics {
76                advance: m.advance_width,
77                bearing_x: m.xmin as f32,
78                bearing_y: m.ymin as f32,
79                width: m.width as u32,
80                height: m.height as u32,
81            }
82        } else {
83            // Stub fallback: approximate widths from character properties
84            let advance = if c.is_alphabetic() {
85                px_size * 0.6
86            } else {
87                px_size * 0.5
88            };
89            GlyphMetrics {
90                advance,
91                bearing_x: 0.0,
92                bearing_y: px_size * 0.8,
93                width: (advance as u32).max(1),
94                height: px_size as u32,
95            }
96        }
97    }
98
99    /// Rasterize a single glyph at the given pixel size.
100    /// Returns `(width, height, alpha_bitmap)` where the bitmap contains
101    /// 8-bit alpha coverage values (one byte per pixel).
102    /// Returns `None` if no font is loaded.
103    pub fn rasterize(&self, c: char, px_size: f32) -> Option<(usize, usize, Vec<u8>)> {
104        let font = self.inner.as_ref()?;
105        let (metrics, bitmap) = font.rasterize(c, px_size);
106        Some((metrics.width, metrics.height, bitmap))
107    }
108}
109
110#[allow(dead_code)]
111pub fn default_font_config() -> FontConfig {
112    FontConfig {
113        size_px: 16,
114        bold: false,
115        italic: false,
116        name: String::from("sans-serif"),
117    }
118}
119
120#[allow(dead_code)]
121pub fn new_font_renderer_stub(cfg: FontConfig) -> FontRendererStub {
122    FontRendererStub {
123        config: cfg,
124        glyph_cache_size: 256,
125    }
126}
127
128#[allow(dead_code)]
129pub fn measure_glyph(renderer: &FontRendererStub, ch: char) -> GlyphMetrics {
130    let size = renderer.config.size_px as f32;
131    let bold_scale = if renderer.config.bold { 1.1 } else { 1.0 };
132    // Stub: approximate advance from character code for determinism.
133    let char_scale = if ch.is_ascii_alphabetic() { 0.6 } else { 0.5 };
134    let advance = size * char_scale * bold_scale;
135    GlyphMetrics {
136        advance,
137        bearing_x: 0.0,
138        bearing_y: size * 0.8,
139        width: (advance as u32).max(1),
140        height: size as u32,
141    }
142}
143
144#[allow(dead_code)]
145pub fn layout_text(renderer: &FontRendererStub, text: &str) -> TextLayout {
146    let mut glyphs = Vec::with_capacity(text.len());
147    let mut total_width = 0.0f32;
148    for ch in text.chars() {
149        let g = measure_glyph(renderer, ch);
150        total_width += g.advance;
151        glyphs.push(g);
152    }
153    let line_height = renderer.config.size_px as f32 * 1.2;
154    TextLayout {
155        glyphs,
156        total_width,
157        line_height,
158    }
159}
160
161#[allow(dead_code)]
162pub fn glyph_count(layout: &TextLayout) -> usize {
163    layout.glyphs.len()
164}
165
166#[allow(dead_code)]
167pub fn font_config_to_json(cfg: &FontConfig) -> String {
168    format!(
169        "{{\"size_px\":{},\"bold\":{},\"italic\":{},\"name\":\"{}\"}}",
170        cfg.size_px, cfg.bold, cfg.italic, cfg.name
171    )
172}
173
174/// Returns `[x, y, w, h]` bounding box for the laid-out text.
175#[allow(dead_code)]
176pub fn text_bounding_box(layout: &TextLayout) -> [f32; 4] {
177    [0.0, 0.0, layout.total_width, layout.line_height]
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_default_font_config() {
186        let cfg = default_font_config();
187        assert_eq!(cfg.size_px, 16);
188        assert!(!cfg.bold);
189        assert!(!cfg.italic);
190        assert!(!cfg.name.is_empty());
191    }
192
193    #[test]
194    fn test_new_font_renderer_stub() {
195        let cfg = default_font_config();
196        let renderer = new_font_renderer_stub(cfg);
197        assert_eq!(renderer.glyph_cache_size, 256);
198        assert_eq!(renderer.config.size_px, 16);
199    }
200
201    #[test]
202    fn test_measure_glyph_nonzero() {
203        let renderer = new_font_renderer_stub(default_font_config());
204        let g = measure_glyph(&renderer, 'A');
205        assert!(g.advance > 0.0);
206        assert!(g.height > 0);
207    }
208
209    #[test]
210    fn test_layout_text_empty() {
211        let renderer = new_font_renderer_stub(default_font_config());
212        let layout = layout_text(&renderer, "");
213        assert_eq!(glyph_count(&layout), 0);
214        assert!((layout.total_width - 0.0).abs() < 1e-6);
215    }
216
217    #[test]
218    fn test_layout_text_nonempty() {
219        let renderer = new_font_renderer_stub(default_font_config());
220        let layout = layout_text(&renderer, "Hi");
221        assert_eq!(glyph_count(&layout), 2);
222        assert!(layout.total_width > 0.0);
223    }
224
225    #[test]
226    fn test_glyph_count() {
227        let renderer = new_font_renderer_stub(default_font_config());
228        let layout = layout_text(&renderer, "abc");
229        assert_eq!(glyph_count(&layout), 3);
230    }
231
232    #[test]
233    fn test_font_config_to_json() {
234        let cfg = default_font_config();
235        let json = font_config_to_json(&cfg);
236        assert!(json.contains("size_px"));
237        assert!(json.contains("sans-serif"));
238    }
239
240    #[test]
241    fn test_text_bounding_box() {
242        let renderer = new_font_renderer_stub(default_font_config());
243        let layout = layout_text(&renderer, "Test");
244        let bb = text_bounding_box(&layout);
245        assert!((bb[0] - 0.0).abs() < 1e-6);
246        assert!((bb[1] - 0.0).abs() < 1e-6);
247        assert!(bb[2] > 0.0);
248        assert!(bb[3] > 0.0);
249    }
250
251    #[test]
252    fn test_bold_advance_larger() {
253        let cfg_normal = default_font_config();
254        let mut cfg_bold = default_font_config();
255        cfg_bold.bold = true;
256        let r_normal = new_font_renderer_stub(cfg_normal);
257        let r_bold = new_font_renderer_stub(cfg_bold);
258        let g_normal = measure_glyph(&r_normal, 'A');
259        let g_bold = measure_glyph(&r_bold, 'A');
260        assert!(g_bold.advance > g_normal.advance);
261    }
262
263    // --- FontRenderer (fontdue-backed) tests ---
264
265    #[test]
266    fn test_stub_fallback_no_font() {
267        let renderer = FontRenderer::new_stub();
268        let m = renderer.measure_glyph('A', 16.0);
269        assert!(m.advance > 0.0);
270    }
271
272    #[test]
273    fn test_layout_accumulates() {
274        let renderer = FontRenderer::new_stub();
275        let glyphs: Vec<f32> = "hello"
276            .chars()
277            .map(|c| renderer.measure_glyph(c, 16.0).advance)
278            .collect();
279        let total: f32 = glyphs.iter().sum();
280        assert!(total > 0.0);
281        assert_eq!(glyphs.len(), 5);
282    }
283
284    #[test]
285    fn test_real_font_metrics() {
286        let font_path = match std::env::var("OXIHUMAN_FONT_DATA") {
287            Ok(p) => p,
288            Err(_) => return, // skip if not set
289        };
290        let bytes = std::fs::read(&font_path).expect("read font file");
291        let renderer = FontRenderer::from_font_bytes(&bytes).expect("parse font");
292        let m = renderer.measure_glyph('A', 16.0);
293        assert!(
294            m.advance > 0.0,
295            "advance should be positive for 'A' at 16px"
296        );
297    }
298
299    #[test]
300    fn test_rasterize_returns_none_without_font() {
301        let renderer = FontRenderer::new_stub();
302        assert!(renderer.rasterize('A', 16.0).is_none());
303    }
304}