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:
@@ -29,3 +29,5 @@ pub use asset_browser::{AssetBrowser, asset_browser_panel};
|
|||||||
pub mod ttf_parser;
|
pub mod ttf_parser;
|
||||||
pub mod rasterizer;
|
pub mod rasterizer;
|
||||||
pub mod glyph_cache;
|
pub mod glyph_cache;
|
||||||
|
pub mod ttf_font;
|
||||||
|
pub use ttf_font::TtfFont;
|
||||||
|
|||||||
153
crates/voltex_editor/src/ttf_font.rs
Normal file
153
crates/voltex_editor/src/ttf_font.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user