feat(editor): add TtfFont unified interface with glyph caching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:12:02 +09:00
parent 94e7f6262e
commit 58bce839fe
2 changed files with 155 additions and 0 deletions

View File

@@ -29,3 +29,5 @@ pub use asset_browser::{AssetBrowser, asset_browser_panel};
pub mod ttf_parser;
pub mod rasterizer;
pub mod glyph_cache;
pub mod ttf_font;
pub use ttf_font::TtfFont;

View File

@@ -0,0 +1,153 @@
use crate::ttf_parser::TtfParser;
use crate::rasterizer::rasterize;
use crate::glyph_cache::{GlyphCache, GlyphInfo, ATLAS_SIZE};
pub struct TtfFont {
parser: TtfParser,
cache: GlyphCache,
pub font_size: f32,
pub line_height: f32,
pub ascender: f32,
pub descender: f32,
scale: f32,
}
impl TtfFont {
pub fn new(data: &[u8], font_size: f32) -> Result<Self, String> {
let parser = TtfParser::parse(data.to_vec())?;
let scale = font_size / parser.units_per_em as f32;
let ascender = parser.ascender as f32 * scale;
let descender = parser.descender as f32 * scale;
let line_height = (parser.ascender - parser.descender + parser.line_gap) as f32 * scale;
let cache = GlyphCache::new(ATLAS_SIZE, ATLAS_SIZE);
Ok(TtfFont {
parser,
cache,
font_size,
line_height,
ascender,
descender,
scale,
})
}
/// Get glyph info for a character, rasterizing on cache miss.
pub fn glyph(&mut self, ch: char) -> &GlyphInfo {
if self.cache.get(ch).is_some() {
return self.cache.get(ch).unwrap();
}
let glyph_id = self.parser.glyph_index(ch as u32);
let metrics = self.parser.glyph_metrics(glyph_id);
let advance = metrics.advance_width as f32 * self.scale;
let bearing_x = metrics.left_side_bearing as f32 * self.scale;
let outline = self.parser.glyph_outline(glyph_id);
match outline {
Some(ref o) if !o.contours.is_empty() => {
let result = rasterize(o, self.scale);
// bearing_y = y_max * scale (distance above baseline)
// When rendering: glyph_y = baseline_y - bearing_y
// For our UI: text_y is the top of the line, baseline = text_y + ascender
// So: glyph_y = text_y + ascender - bearing_y
let bearing_y = o.y_max as f32 * self.scale;
self.cache.insert(
ch,
&result.bitmap,
result.width,
result.height,
advance,
bearing_x,
bearing_y,
);
}
_ => {
// No outline (space, etc.) — insert empty glyph
self.cache.insert(ch, &[], 0, 0, advance, 0.0, 0.0);
}
}
self.cache.get(ch).unwrap()
}
/// Calculate total width of a text string.
pub fn text_width(&mut self, text: &str) -> f32 {
let mut width = 0.0;
for ch in text.chars() {
let info = self.glyph(ch);
width += info.advance;
}
width
}
pub fn atlas_data(&self) -> &[u8] {
&self.cache.atlas_data
}
pub fn atlas_size(&self) -> (u32, u32) {
(self.cache.atlas_width, self.cache.atlas_height)
}
pub fn is_dirty(&self) -> bool {
self.cache.dirty
}
pub fn clear_dirty(&mut self) {
self.cache.clear_dirty();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load_test_font() -> Option<TtfFont> {
let paths = ["C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/consola.ttf"];
for p in &paths {
if let Ok(data) = std::fs::read(p) {
if let Ok(font) = TtfFont::new(&data, 24.0) {
return Some(font);
}
}
}
None
}
#[test]
fn test_new() {
let font = load_test_font().expect("no test font");
assert!(font.font_size == 24.0);
assert!(font.ascender > 0.0);
assert!(font.line_height > 0.0);
}
#[test]
fn test_glyph_caches() {
let mut font = load_test_font().expect("no test font");
let _g1 = font.glyph('A');
assert!(font.is_dirty());
font.clear_dirty();
let _g2 = font.glyph('A'); // cache hit
assert!(!font.is_dirty());
}
#[test]
fn test_text_width() {
let mut font = load_test_font().expect("no test font");
let w1 = font.text_width("Hello");
let w2 = font.text_width("Hello World");
assert!(w1 > 0.0);
assert!(w2 > w1);
}
#[test]
fn test_space_glyph() {
let mut font = load_test_font().expect("no test font");
let info = font.glyph(' ');
assert!(info.advance > 0.0); // space has advance but no bitmap
assert!(info.width == 0.0);
}
}