we (web engine): Experimental web browser project to understand the limits of Claude

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 <noreply@anthropic.com>

+279 -1
+58
crates/text/src/font/cache.rs
··· 1 + //! Glyph bitmap cache: avoids re-rasterizing the same glyphs at the same size. 2 + 3 + use std::collections::HashMap; 4 + 5 + use crate::font::rasterizer::GlyphBitmap; 6 + 7 + /// Cache key: (glyph_id, size_px quantized to integer pixels). 8 + type CacheKey = (u16, u16); 9 + 10 + /// A per-font cache of rasterized glyph bitmaps. 11 + /// 12 + /// Keys are `(glyph_id, size_px)` where size_px is rounded to the nearest 13 + /// integer pixel to bound cache size. No eviction policy — Phase 12 will 14 + /// add a texture atlas with LRU. 15 + #[derive(Debug)] 16 + pub struct GlyphCache { 17 + entries: HashMap<CacheKey, GlyphBitmap>, 18 + } 19 + 20 + impl GlyphCache { 21 + /// Create an empty glyph cache. 22 + pub fn new() -> Self { 23 + Self { 24 + entries: HashMap::new(), 25 + } 26 + } 27 + 28 + /// Quantize a floating-point pixel size to an integer key. 29 + pub fn quantize_size(size_px: f32) -> u16 { 30 + size_px.round().max(1.0) as u16 31 + } 32 + 33 + /// Look up a cached bitmap. Returns `None` on cache miss. 34 + pub fn get(&self, glyph_id: u16, size_key: u16) -> Option<&GlyphBitmap> { 35 + self.entries.get(&(glyph_id, size_key)) 36 + } 37 + 38 + /// Insert a rasterized bitmap into the cache. 39 + pub fn insert(&mut self, glyph_id: u16, size_key: u16, bitmap: GlyphBitmap) { 40 + self.entries.insert((glyph_id, size_key), bitmap); 41 + } 42 + 43 + /// Number of cached entries. 44 + pub fn len(&self) -> usize { 45 + self.entries.len() 46 + } 47 + 48 + /// Returns true if the cache is empty. 49 + pub fn is_empty(&self) -> bool { 50 + self.entries.is_empty() 51 + } 52 + } 53 + 54 + impl Default for GlyphCache { 55 + fn default() -> Self { 56 + Self::new() 57 + } 58 + }
+220 -1
crates/text/src/font/mod.rs
··· 3 3 //! Parses the OpenType/TrueType table directory and individual tables needed 4 4 //! for text rendering: head, maxp, hhea, hmtx, cmap, name, loca. 5 5 6 + use std::cell::RefCell; 6 7 use std::fmt; 7 8 9 + pub mod cache; 8 10 mod parse; 9 11 pub mod rasterizer; 10 12 pub mod registry; 11 13 mod tables; 12 14 15 + pub use cache::GlyphCache; 13 16 pub use rasterizer::GlyphBitmap; 14 17 pub use registry::{FontEntry, FontRegistry}; 15 18 pub use tables::cmap::CmapTable; ··· 36 39 pub x_advance: f32, 37 40 } 38 41 42 + /// A positioned glyph with its rasterized bitmap, ready for rendering. 43 + #[derive(Debug, Clone)] 44 + pub struct PositionedGlyph { 45 + /// Glyph ID in the font. 46 + pub glyph_id: u16, 47 + /// Horizontal position in pixels (left edge of the glyph's origin). 48 + pub x: f32, 49 + /// Vertical position in pixels (baseline). 50 + pub y: f32, 51 + /// The rasterized bitmap, or `None` for glyphs with no outline (e.g., space). 52 + pub bitmap: Option<GlyphBitmap>, 53 + } 54 + 39 55 /// Errors that can occur during font parsing. 40 56 #[derive(Debug)] 41 57 pub enum FontError { ··· 81 97 } 82 98 83 99 /// A parsed OpenType/TrueType font. 84 - #[derive(Debug)] 85 100 pub struct Font { 86 101 /// Raw font data (owned). 87 102 data: Vec<u8>, ··· 89 104 pub sf_version: u32, 90 105 /// Table directory records. 91 106 pub tables: Vec<TableRecord>, 107 + /// Cache of rasterized glyph bitmaps, keyed by (glyph_id, size_px). 108 + glyph_cache: RefCell<GlyphCache>, 109 + } 110 + 111 + impl fmt::Debug for Font { 112 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 113 + f.debug_struct("Font") 114 + .field("sf_version", &self.sf_version) 115 + .field("tables", &self.tables) 116 + .field("glyph_cache_size", &self.glyph_cache.borrow().len()) 117 + .finish() 118 + } 92 119 } 93 120 94 121 impl Font { ··· 125 152 data, 126 153 sf_version, 127 154 tables, 155 + glyph_cache: RefCell::new(GlyphCache::new()), 128 156 }) 129 157 } 130 158 ··· 317 345 let scale = size_px / head.units_per_em as f32; 318 346 let outline = self.glyph_outline(glyph_id).ok()??; 319 347 rasterizer::rasterize(&outline, scale) 348 + } 349 + 350 + /// Get a rasterized glyph bitmap, using the cache to avoid re-rasterization. 351 + /// 352 + /// The pixel size is quantized to the nearest integer to bound cache size. 353 + /// Returns `None` for glyphs with no outline (e.g., space). 354 + pub fn get_glyph_bitmap(&self, glyph_id: u16, size_px: f32) -> Option<GlyphBitmap> { 355 + let size_key = GlyphCache::quantize_size(size_px); 356 + 357 + // Check cache first. 358 + if let Some(bitmap) = self.glyph_cache.borrow().get(glyph_id, size_key) { 359 + return Some(bitmap.clone()); 360 + } 361 + 362 + // Cache miss: rasterize using the quantized size for consistency. 363 + let bitmap = self.rasterize_glyph(glyph_id, size_key as f32)?; 364 + 365 + self.glyph_cache 366 + .borrow_mut() 367 + .insert(glyph_id, size_key, bitmap.clone()); 368 + Some(bitmap) 369 + } 370 + 371 + /// Render a text string: shape, rasterize, and position glyphs. 372 + /// 373 + /// Combines text shaping (advance widths + kerning) with cached glyph 374 + /// rasterization. Each `PositionedGlyph` contains its screen position 375 + /// and the rasterized bitmap data. 376 + pub fn render_text(&self, text: &str, size_px: f32) -> Vec<PositionedGlyph> { 377 + let shaped = self.shape_text(text, size_px); 378 + 379 + shaped 380 + .iter() 381 + .map(|sg| { 382 + let bitmap = self.get_glyph_bitmap(sg.glyph_id, size_px); 383 + PositionedGlyph { 384 + glyph_id: sg.glyph_id, 385 + x: sg.x_offset, 386 + y: sg.y_offset, 387 + bitmap, 388 + } 389 + }) 390 + .collect() 391 + } 392 + 393 + /// Number of cached glyph bitmaps. 394 + pub fn glyph_cache_len(&self) -> usize { 395 + self.glyph_cache.borrow().len() 320 396 } 321 397 322 398 /// Returns true if this is a TrueType font (vs CFF/PostScript outlines). ··· 750 826 let shaped = font.shape_text("AV", 16.0); 751 827 assert_eq!(shaped.len(), 2); 752 828 assert!(shaped[1].x_offset > 0.0); 829 + } 830 + 831 + #[test] 832 + fn get_glyph_bitmap_caches() { 833 + let font = test_font(); 834 + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); 835 + 836 + assert_eq!(font.glyph_cache_len(), 0, "cache should start empty"); 837 + 838 + // First call: cache miss → rasterize. 839 + let bm1 = font.get_glyph_bitmap(gid, 16.0).expect("should rasterize"); 840 + assert_eq!(font.glyph_cache_len(), 1, "cache should have 1 entry"); 841 + 842 + // Second call: cache hit → same result, no new entry. 843 + let bm2 = font.get_glyph_bitmap(gid, 16.0).expect("should hit cache"); 844 + assert_eq!(font.glyph_cache_len(), 1, "cache size should not change"); 845 + assert_eq!(bm1, bm2, "cached bitmap should be identical"); 846 + } 847 + 848 + #[test] 849 + fn get_glyph_bitmap_different_sizes() { 850 + let font = test_font(); 851 + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); 852 + 853 + let _bm16 = font.get_glyph_bitmap(gid, 16.0); 854 + let _bm32 = font.get_glyph_bitmap(gid, 32.0); 855 + 856 + assert_eq!( 857 + font.glyph_cache_len(), 858 + 2, 859 + "different sizes should be cached independently" 860 + ); 861 + } 862 + 863 + #[test] 864 + fn get_glyph_bitmap_quantizes_size() { 865 + let font = test_font(); 866 + let gid = font.glyph_index(0x0041).unwrap().expect("no glyph for 'A'"); 867 + 868 + // 16.3 and 15.7 both round to 16. 869 + let bm1 = font.get_glyph_bitmap(gid, 16.3); 870 + let bm2 = font.get_glyph_bitmap(gid, 15.7); 871 + 872 + assert_eq!( 873 + font.glyph_cache_len(), 874 + 1, 875 + "quantized sizes should share a cache entry" 876 + ); 877 + assert_eq!(bm1, bm2, "same quantized size should produce same bitmap"); 878 + } 879 + 880 + #[test] 881 + fn get_glyph_bitmap_space_returns_none() { 882 + let font = test_font(); 883 + let gid = font 884 + .glyph_index(0x0020) 885 + .unwrap() 886 + .expect("no glyph for space"); 887 + 888 + let bitmap = font.get_glyph_bitmap(gid, 16.0); 889 + assert!(bitmap.is_none(), "space should have no bitmap"); 890 + } 891 + 892 + #[test] 893 + fn render_text_basic() { 894 + let font = test_font(); 895 + let glyphs = font.render_text("Hi", 16.0); 896 + 897 + assert_eq!(glyphs.len(), 2, "should have 2 glyphs for 'Hi'"); 898 + 899 + // First glyph should start at x=0. 900 + assert_eq!(glyphs[0].x, 0.0, "first glyph should start at x=0"); 901 + 902 + // Second glyph should be to the right. 903 + assert!(glyphs[1].x > 0.0, "second glyph should be offset right"); 904 + 905 + // 'H' and 'i' should have bitmaps. 906 + assert!(glyphs[0].bitmap.is_some(), "'H' should have a bitmap"); 907 + assert!(glyphs[1].bitmap.is_some(), "'i' should have a bitmap"); 908 + } 909 + 910 + #[test] 911 + fn render_text_uses_cache() { 912 + let font = test_font(); 913 + 914 + // Render "AA" — same glyph twice, should only rasterize once. 915 + let glyphs = font.render_text("AA", 16.0); 916 + 917 + assert_eq!(glyphs.len(), 2); 918 + // Both should have the same bitmap (from cache). 919 + assert_eq!( 920 + glyphs[0].bitmap, glyphs[1].bitmap, 921 + "repeated glyph should return identical bitmaps from cache" 922 + ); 923 + // Only one entry in the cache for 'A' at 16px. 924 + // (There may be more entries if the font maps 'A' to multiple glyphs, 925 + // but typically it's just one.) 926 + assert!( 927 + font.glyph_cache_len() >= 1, 928 + "cache should have at least 1 entry" 929 + ); 930 + } 931 + 932 + #[test] 933 + fn render_text_empty() { 934 + let font = test_font(); 935 + let glyphs = font.render_text("", 16.0); 936 + assert!(glyphs.is_empty(), "empty text should produce no glyphs"); 937 + } 938 + 939 + #[test] 940 + fn render_text_with_space() { 941 + let font = test_font(); 942 + let glyphs = font.render_text("A B", 16.0); 943 + 944 + assert_eq!(glyphs.len(), 3, "should have 3 glyphs for 'A B'"); 945 + 946 + // Space glyph should have no bitmap. 947 + assert!( 948 + glyphs[1].bitmap.is_none(), 949 + "space glyph should have no bitmap" 950 + ); 951 + 952 + // But it should still advance the cursor. 953 + assert!( 954 + glyphs[2].x > glyphs[0].x, 955 + "'B' should be further right than 'A'" 956 + ); 957 + } 958 + 959 + #[test] 960 + fn render_text_positions_match_shaping() { 961 + let font = test_font(); 962 + let shaped = font.shape_text("Hello", 16.0); 963 + let rendered = font.render_text("Hello", 16.0); 964 + 965 + assert_eq!(shaped.len(), rendered.len()); 966 + 967 + for (s, r) in shaped.iter().zip(rendered.iter()) { 968 + assert_eq!(s.glyph_id, r.glyph_id, "glyph IDs should match"); 969 + assert_eq!(s.x_offset, r.x, "x positions should match shaping"); 970 + assert_eq!(s.y_offset, r.y, "y positions should match shaping"); 971 + } 753 972 } 754 973 }
+1
crates/text/src/font/registry.rs
··· 296 296 data, 297 297 sf_version, 298 298 tables, 299 + glyph_cache: std::cell::RefCell::new(super::cache::GlyphCache::new()), 299 300 }) 300 301 } 301 302