From a841298ae6b979262829cc035140be7bd617dcfe Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 27 Feb 2026 18:36:32 +0100 Subject: [PATCH] Implement glyph cache: HashMap of rasterized bitmaps - GlyphCache stores rasterized bitmaps keyed by (glyph_id, size_px) - Size quantized to integer pixels to bound cache size - Font::get_glyph_bitmap() checks cache before rasterizing - Font::render_text() combines shaping with cached bitmap lookup - PositionedGlyph struct with x/y position and optional bitmap - 9 new tests: cache hit/miss, size quantization, render_text behavior Co-Authored-By: Claude Opus 4.6 --- crates/text/src/font/cache.rs | 58 ++++++++ crates/text/src/font/mod.rs | 221 ++++++++++++++++++++++++++++++- crates/text/src/font/registry.rs | 1 + 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 crates/text/src/font/cache.rs diff --git a/crates/text/src/font/cache.rs b/crates/text/src/font/cache.rs new file mode 100644 index 0000000..594e5cc --- /dev/null +++ b/crates/text/src/font/cache.rs @@ -0,0 +1,58 @@ +//! Glyph bitmap cache: avoids re-rasterizing the same glyphs at the same size. + +use std::collections::HashMap; + +use crate::font::rasterizer::GlyphBitmap; + +/// Cache key: (glyph_id, size_px quantized to integer pixels). +type CacheKey = (u16, u16); + +/// A per-font cache of rasterized glyph bitmaps. +/// +/// Keys are `(glyph_id, size_px)` where size_px is rounded to the nearest +/// integer pixel to bound cache size. No eviction policy — Phase 12 will +/// add a texture atlas with LRU. +#[derive(Debug)] +pub struct GlyphCache { + entries: HashMap, +} + +impl GlyphCache { + /// Create an empty glyph cache. + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + /// Quantize a floating-point pixel size to an integer key. + pub fn quantize_size(size_px: f32) -> u16 { + size_px.round().max(1.0) as u16 + } + + /// Look up a cached bitmap. Returns `None` on cache miss. + pub fn get(&self, glyph_id: u16, size_key: u16) -> Option<&GlyphBitmap> { + self.entries.get(&(glyph_id, size_key)) + } + + /// Insert a rasterized bitmap into the cache. + pub fn insert(&mut self, glyph_id: u16, size_key: u16, bitmap: GlyphBitmap) { + self.entries.insert((glyph_id, size_key), bitmap); + } + + /// Number of cached entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for GlyphCache { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index 8bc08c6..c42a8aa 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -3,13 +3,16 @@ //! Parses the OpenType/TrueType table directory and individual tables needed //! for text rendering: head, maxp, hhea, hmtx, cmap, name, loca. +use std::cell::RefCell; use std::fmt; +pub mod cache; mod parse; pub mod rasterizer; pub mod registry; mod tables; +pub use cache::GlyphCache; pub use rasterizer::GlyphBitmap; pub use registry::{FontEntry, FontRegistry}; pub use tables::cmap::CmapTable; @@ -36,6 +39,19 @@ pub struct ShapedGlyph { pub x_advance: f32, } +/// A positioned glyph with its rasterized bitmap, ready for rendering. +#[derive(Debug, Clone)] +pub struct PositionedGlyph { + /// Glyph ID in the font. + pub glyph_id: u16, + /// Horizontal position in pixels (left edge of the glyph's origin). + pub x: f32, + /// Vertical position in pixels (baseline). + pub y: f32, + /// The rasterized bitmap, or `None` for glyphs with no outline (e.g., space). + pub bitmap: Option, +} + /// Errors that can occur during font parsing. #[derive(Debug)] pub enum FontError { @@ -81,7 +97,6 @@ impl TableRecord { } /// A parsed OpenType/TrueType font. -#[derive(Debug)] pub struct Font { /// Raw font data (owned). data: Vec, @@ -89,6 +104,18 @@ pub struct Font { pub sf_version: u32, /// Table directory records. pub tables: Vec, + /// Cache of rasterized glyph bitmaps, keyed by (glyph_id, size_px). + glyph_cache: RefCell, +} + +impl fmt::Debug for Font { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Font") + .field("sf_version", &self.sf_version) + .field("tables", &self.tables) + .field("glyph_cache_size", &self.glyph_cache.borrow().len()) + .finish() + } } impl Font { @@ -125,6 +152,7 @@ impl Font { data, sf_version, tables, + glyph_cache: RefCell::new(GlyphCache::new()), }) } @@ -319,6 +347,54 @@ impl Font { rasterizer::rasterize(&outline, scale) } + /// Get a rasterized glyph bitmap, using the cache to avoid re-rasterization. + /// + /// The pixel size is quantized to the nearest integer to bound cache size. + /// Returns `None` for glyphs with no outline (e.g., space). + pub fn get_glyph_bitmap(&self, glyph_id: u16, size_px: f32) -> Option { + let size_key = GlyphCache::quantize_size(size_px); + + // Check cache first. + if let Some(bitmap) = self.glyph_cache.borrow().get(glyph_id, size_key) { + return Some(bitmap.clone()); + } + + // Cache miss: rasterize using the quantized size for consistency. + let bitmap = self.rasterize_glyph(glyph_id, size_key as f32)?; + + self.glyph_cache + .borrow_mut() + .insert(glyph_id, size_key, bitmap.clone()); + Some(bitmap) + } + + /// Render a text string: shape, rasterize, and position glyphs. + /// + /// Combines text shaping (advance widths + kerning) with cached glyph + /// rasterization. Each `PositionedGlyph` contains its screen position + /// and the rasterized bitmap data. + pub fn render_text(&self, text: &str, size_px: f32) -> Vec { + let shaped = self.shape_text(text, size_px); + + shaped + .iter() + .map(|sg| { + let bitmap = self.get_glyph_bitmap(sg.glyph_id, size_px); + PositionedGlyph { + glyph_id: sg.glyph_id, + x: sg.x_offset, + y: sg.y_offset, + bitmap, + } + }) + .collect() + } + + /// Number of cached glyph bitmaps. + pub fn glyph_cache_len(&self) -> usize { + self.glyph_cache.borrow().len() + } + /// Returns true if this is a TrueType font (vs CFF/PostScript outlines). pub fn is_truetype(&self) -> bool { self.sf_version == 0x00010000 || self.sf_version == 0x74727565 @@ -751,4 +827,147 @@ mod tests { assert_eq!(shaped.len(), 2); assert!(shaped[1].x_offset > 0.0); } + + #[test] + fn get_glyph_bitmap_caches() { + let font = test_font(); + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); + + assert_eq!(font.glyph_cache_len(), 0, "cache should start empty"); + + // First call: cache miss → rasterize. + let bm1 = font.get_glyph_bitmap(gid, 16.0).expect("should rasterize"); + assert_eq!(font.glyph_cache_len(), 1, "cache should have 1 entry"); + + // Second call: cache hit → same result, no new entry. + let bm2 = font.get_glyph_bitmap(gid, 16.0).expect("should hit cache"); + assert_eq!(font.glyph_cache_len(), 1, "cache size should not change"); + assert_eq!(bm1, bm2, "cached bitmap should be identical"); + } + + #[test] + fn get_glyph_bitmap_different_sizes() { + let font = test_font(); + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); + + let _bm16 = font.get_glyph_bitmap(gid, 16.0); + let _bm32 = font.get_glyph_bitmap(gid, 32.0); + + assert_eq!( + font.glyph_cache_len(), + 2, + "different sizes should be cached independently" + ); + } + + #[test] + fn get_glyph_bitmap_quantizes_size() { + let font = test_font(); + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); + + // 16.3 and 15.7 both round to 16. + let bm1 = font.get_glyph_bitmap(gid, 16.3); + let bm2 = font.get_glyph_bitmap(gid, 15.7); + + assert_eq!( + font.glyph_cache_len(), + 1, + "quantized sizes should share a cache entry" + ); + assert_eq!(bm1, bm2, "same quantized size should produce same bitmap"); + } + + #[test] + fn get_glyph_bitmap_space_returns_none() { + let font = test_font(); + let gid = font + .glyph_index(0x0020) + .unwrap() + .expect("no glyph for space"); + + let bitmap = font.get_glyph_bitmap(gid, 16.0); + assert!(bitmap.is_none(), "space should have no bitmap"); + } + + #[test] + fn render_text_basic() { + let font = test_font(); + let glyphs = font.render_text("Hi", 16.0); + + assert_eq!(glyphs.len(), 2, "should have 2 glyphs for 'Hi'"); + + // First glyph should start at x=0. + assert_eq!(glyphs[0].x, 0.0, "first glyph should start at x=0"); + + // Second glyph should be to the right. + assert!(glyphs[1].x > 0.0, "second glyph should be offset right"); + + // 'H' and 'i' should have bitmaps. + assert!(glyphs[0].bitmap.is_some(), "'H' should have a bitmap"); + assert!(glyphs[1].bitmap.is_some(), "'i' should have a bitmap"); + } + + #[test] + fn render_text_uses_cache() { + let font = test_font(); + + // Render "AA" — same glyph twice, should only rasterize once. + let glyphs = font.render_text("AA", 16.0); + + assert_eq!(glyphs.len(), 2); + // Both should have the same bitmap (from cache). + assert_eq!( + glyphs[0].bitmap, glyphs[1].bitmap, + "repeated glyph should return identical bitmaps from cache" + ); + // Only one entry in the cache for 'A' at 16px. + // (There may be more entries if the font maps 'A' to multiple glyphs, + // but typically it's just one.) + assert!( + font.glyph_cache_len() >= 1, + "cache should have at least 1 entry" + ); + } + + #[test] + fn render_text_empty() { + let font = test_font(); + let glyphs = font.render_text("", 16.0); + assert!(glyphs.is_empty(), "empty text should produce no glyphs"); + } + + #[test] + fn render_text_with_space() { + let font = test_font(); + let glyphs = font.render_text("A B", 16.0); + + assert_eq!(glyphs.len(), 3, "should have 3 glyphs for 'A B'"); + + // Space glyph should have no bitmap. + assert!( + glyphs[1].bitmap.is_none(), + "space glyph should have no bitmap" + ); + + // But it should still advance the cursor. + assert!( + glyphs[2].x > glyphs[0].x, + "'B' should be further right than 'A'" + ); + } + + #[test] + fn render_text_positions_match_shaping() { + let font = test_font(); + let shaped = font.shape_text("Hello", 16.0); + let rendered = font.render_text("Hello", 16.0); + + assert_eq!(shaped.len(), rendered.len()); + + for (s, r) in shaped.iter().zip(rendered.iter()) { + assert_eq!(s.glyph_id, r.glyph_id, "glyph IDs should match"); + assert_eq!(s.x_offset, r.x, "x positions should match shaping"); + assert_eq!(s.y_offset, r.y, "y positions should match shaping"); + } + } } diff --git a/crates/text/src/font/registry.rs b/crates/text/src/font/registry.rs index 03e97c9..5da2d0a 100644 --- a/crates/text/src/font/registry.rs +++ b/crates/text/src/font/registry.rs @@ -296,6 +296,7 @@ fn parse_font_at_offset(data: Vec, offset: u32) -> Result { data, sf_version, tables, + glyph_cache: std::cell::RefCell::new(super::cache::GlyphCache::new()), }) } -- 2.51.2