//! OTF/TTF font file parser. //! //! 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; pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::head::HeadTable; pub use tables::hhea::HheaTable; pub use tables::hmtx::HmtxTable; pub use tables::kern::KernTable; pub use tables::loca::LocaTable; pub use tables::maxp::MaxpTable; pub use tables::name::NameTable; pub use tables::os2::Os2Table; /// A positioned glyph in a shaped text run. #[derive(Debug, Clone)] pub struct ShapedGlyph { /// Glyph ID in the font. pub glyph_id: u16, /// Horizontal position in pixels. pub x_offset: f32, /// Vertical offset in pixels (usually 0 for basic horizontal shaping). pub y_offset: f32, /// Horizontal advance in pixels. 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 { /// The data is too short to contain the expected structure. UnexpectedEof, /// The font file has an unrecognized magic number / sfVersion. InvalidMagic(u32), /// A required table is missing. MissingTable(&'static str), /// A table's data is malformed. MalformedTable(&'static str), } impl fmt::Display for FontError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { FontError::UnexpectedEof => write!(f, "unexpected end of font data"), FontError::InvalidMagic(v) => write!(f, "invalid font magic: 0x{:08X}", v), FontError::MissingTable(t) => write!(f, "missing required table: {}", t), FontError::MalformedTable(t) => write!(f, "malformed table: {}", t), } } } /// A record in the table directory describing one font table. #[derive(Debug, Clone)] pub struct TableRecord { /// Four-byte tag (e.g. b"head", b"cmap"). pub tag: [u8; 4], /// Checksum of the table. pub checksum: u32, /// Offset from the beginning of the font file. pub offset: u32, /// Length of the table in bytes. pub length: u32, } impl TableRecord { /// Return the tag as a string (for display/debugging). pub fn tag_str(&self) -> &str { std::str::from_utf8(&self.tag).unwrap_or("????") } } /// A parsed OpenType/TrueType font. pub struct Font { /// Raw font data (owned). data: Vec, /// Offset subtable version (0x00010000 for TrueType, 0x4F54544F for CFF). 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 { /// Parse a font from raw file bytes. pub fn parse(data: Vec) -> Result { let r = parse::Reader::new(&data); let sf_version = r.u32(0)?; match sf_version { 0x00010000 => {} // TrueType 0x4F54544F => {} // CFF (OpenType with PostScript outlines) 0x74727565 => {} // 'true' — old Apple TrueType _ => return Err(FontError::InvalidMagic(sf_version)), } let num_tables = r.u16(4)? as usize; // skip searchRange(2), entrySelector(2), rangeShift(2) = 6 bytes let mut tables = Vec::with_capacity(num_tables); for i in 0..num_tables { let base = 12 + i * 16; let tag = r.tag(base)?; let checksum = r.u32(base + 4)?; let offset = r.u32(base + 8)?; let length = r.u32(base + 12)?; tables.push(TableRecord { tag, checksum, offset, length, }); } Ok(Font { data, sf_version, tables, glyph_cache: RefCell::new(GlyphCache::new()), }) } /// Load a font from a file path. pub fn from_file(path: &std::path::Path) -> Result { let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?; Font::parse(data) } /// Find a table record by its 4-byte tag. pub fn table_record(&self, tag: &[u8; 4]) -> Option<&TableRecord> { self.tables.iter().find(|t| &t.tag == tag) } /// Get the raw bytes for a table. pub fn table_data(&self, tag: &[u8; 4]) -> Option<&[u8]> { let rec = self.table_record(tag)?; let start = rec.offset as usize; let end = start + rec.length as usize; if end <= self.data.len() { Some(&self.data[start..end]) } else { None } } /// Parse the `head` table. pub fn head(&self) -> Result { let data = self .table_data(b"head") .ok_or(FontError::MissingTable("head"))?; HeadTable::parse(data) } /// Parse the `maxp` table. pub fn maxp(&self) -> Result { let data = self .table_data(b"maxp") .ok_or(FontError::MissingTable("maxp"))?; MaxpTable::parse(data) } /// Parse the `hhea` table. pub fn hhea(&self) -> Result { let data = self .table_data(b"hhea") .ok_or(FontError::MissingTable("hhea"))?; HheaTable::parse(data) } /// Parse the `hmtx` table. /// /// Requires `maxp` and `hhea` to determine dimensions. pub fn hmtx(&self) -> Result { let maxp = self.maxp()?; let hhea = self.hhea()?; let data = self .table_data(b"hmtx") .ok_or(FontError::MissingTable("hmtx"))?; HmtxTable::parse(data, hhea.num_long_hor_metrics, maxp.num_glyphs) } /// Parse the `cmap` table. pub fn cmap(&self) -> Result { let data = self .table_data(b"cmap") .ok_or(FontError::MissingTable("cmap"))?; CmapTable::parse(data) } /// Parse the `name` table. pub fn name(&self) -> Result { let data = self .table_data(b"name") .ok_or(FontError::MissingTable("name"))?; NameTable::parse(data) } /// Parse the `loca` table. /// /// Requires `head` (for index format) and `maxp` (for glyph count). pub fn loca(&self) -> Result { let head = self.head()?; let maxp = self.maxp()?; let data = self .table_data(b"loca") .ok_or(FontError::MissingTable("loca"))?; LocaTable::parse(data, head.index_to_loc_format, maxp.num_glyphs) } /// Parse the `OS/2` table. pub fn os2(&self) -> Result { let data = self .table_data(b"OS/2") .ok_or(FontError::MissingTable("OS/2"))?; Os2Table::parse(data) } /// Parse the `kern` table, if present. /// /// Returns an empty `KernTable` (zero pairs) if the font has no kern table. pub fn kern(&self) -> Result { match self.table_data(b"kern") { Some(data) => KernTable::parse(data), None => Ok(KernTable::empty()), } } /// Look up the raw kerning value for a pair of glyph IDs (in font units). /// /// Returns 0 if the font has no kern table or the pair has no adjustment. pub fn kern_pair(&self, left: u16, right: u16) -> i16 { self.kern().map(|k| k.kern_value(left, right)).unwrap_or(0) } /// Shape a text string: map characters to glyphs and compute positions. /// /// Returns a list of positioned glyphs with coordinates in pixels. pub fn shape_text(&self, text: &str, size_px: f32) -> Vec { let head = match self.head() { Ok(h) => h, Err(_) => return Vec::new(), }; let scale = size_px / head.units_per_em as f32; let cmap = match self.cmap() { Ok(c) => c, Err(_) => return Vec::new(), }; let hmtx = match self.hmtx() { Ok(h) => h, Err(_) => return Vec::new(), }; let kern = self.kern().unwrap_or_else(|_| KernTable::empty()); // Map characters to glyph IDs. let glyph_ids: Vec = text .chars() .map(|ch| cmap.glyph_index(ch as u32).unwrap_or(0)) .collect(); let mut result = Vec::with_capacity(glyph_ids.len()); let mut cursor_x: f32 = 0.0; for (i, &gid) in glyph_ids.iter().enumerate() { let advance_fu = hmtx.advances.get(gid as usize).copied().unwrap_or(0); let x_advance = advance_fu as f32 * scale; // Apply kerning adjustment with the next glyph. let kern_adjust = if i + 1 < glyph_ids.len() { kern.kern_value(gid, glyph_ids[i + 1]) as f32 * scale } else { 0.0 }; result.push(ShapedGlyph { glyph_id: gid, x_offset: cursor_x, y_offset: 0.0, x_advance, }); cursor_x += x_advance + kern_adjust; } result } /// Map a Unicode code point to a glyph index using the cmap table. pub fn glyph_index(&self, codepoint: u32) -> Result, FontError> { let cmap = self.cmap()?; Ok(cmap.glyph_index(codepoint)) } /// Extract the outline for a glyph by its glyph ID. /// /// Returns `None` for glyphs with no outline (e.g., space). /// Requires the `glyf` and `loca` tables. pub fn glyph_outline(&self, glyph_id: u16) -> Result, FontError> { let loca = self.loca()?; let glyf_data = self .table_data(b"glyf") .ok_or(FontError::MissingTable("glyf"))?; tables::glyf::parse_glyph(glyph_id, glyf_data, &loca) } /// Rasterize a glyph outline into an anti-aliased bitmap at the given pixel size. pub fn rasterize_glyph(&self, glyph_id: u16, size_px: f32) -> Option { let head = self.head().ok()?; let scale = size_px / head.units_per_em as f32; let outline = self.glyph_outline(glyph_id).ok()??; 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 } } /// Load the first available system font from standard macOS paths. /// /// Tries these fonts in order: Geneva.ttf, Helvetica.ttc, Monaco.ttf. /// For `.ttc` (TrueType Collection) files, only the first font is parsed. pub fn load_system_font() -> Result { let candidates = [ "/System/Library/Fonts/Geneva.ttf", "/System/Library/Fonts/Monaco.ttf", ]; for path in &candidates { let p = std::path::Path::new(path); if p.exists() { return Font::from_file(p); } } Err(FontError::MissingTable("no system font found")) } #[cfg(test)] mod tests { use super::*; fn test_font() -> Font { // Try several common macOS fonts. let paths = [ "/System/Library/Fonts/Geneva.ttf", "/System/Library/Fonts/Monaco.ttf", "/System/Library/Fonts/Keyboard.ttf", ]; for path in &paths { let p = std::path::Path::new(path); if p.exists() { return Font::from_file(p).expect("failed to parse font"); } } panic!("no test font found — need a .ttf file in /System/Library/Fonts/"); } #[test] fn parse_table_directory() { let font = test_font(); assert!(font.is_truetype()); assert!(!font.tables.is_empty()); // Every font must have these tables. assert!(font.table_record(b"head").is_some(), "missing head table"); assert!(font.table_record(b"cmap").is_some(), "missing cmap table"); assert!(font.table_record(b"maxp").is_some(), "missing maxp table"); } #[test] fn parse_head_table() { let font = test_font(); let head = font.head().expect("failed to parse head"); assert!( head.units_per_em > 0, "units_per_em should be positive: {}", head.units_per_em ); assert!( head.units_per_em >= 16 && head.units_per_em <= 16384, "units_per_em out of range: {}", head.units_per_em ); } #[test] fn parse_maxp_table() { let font = test_font(); let maxp = font.maxp().expect("failed to parse maxp"); assert!(maxp.num_glyphs > 0, "font should have at least one glyph"); } #[test] fn parse_hhea_table() { let font = test_font(); let hhea = font.hhea().expect("failed to parse hhea"); assert!(hhea.ascent > 0, "ascent should be positive"); assert!(hhea.num_long_hor_metrics > 0, "should have metrics"); } #[test] fn parse_hmtx_table() { let font = test_font(); let hmtx = font.hmtx().expect("failed to parse hmtx"); let maxp = font.maxp().unwrap(); assert_eq!( hmtx.advances.len(), maxp.num_glyphs as usize, "should have one advance per glyph" ); assert_eq!( hmtx.lsbs.len(), maxp.num_glyphs as usize, "should have one lsb per glyph" ); // Glyph 0 (.notdef) typically has a nonzero advance. assert!(hmtx.advances[0] > 0, "glyph 0 advance should be nonzero"); } #[test] fn parse_cmap_table() { let font = test_font(); let cmap = font.cmap().expect("failed to parse cmap"); // Look up ASCII 'A' (U+0041) — every Latin font should have it. let glyph_a = cmap.glyph_index(0x0041); assert!( glyph_a.is_some() && glyph_a.unwrap() > 0, "should find a glyph for 'A'" ); // Look up space (U+0020). let glyph_space = cmap.glyph_index(0x0020); assert!(glyph_space.is_some(), "should find a glyph for space"); } #[test] fn parse_name_table() { let font = test_font(); let name = font.name().expect("failed to parse name"); let family = name.family_name(); assert!(family.is_some(), "should have a family name"); let family = family.unwrap(); assert!(!family.is_empty(), "family name should not be empty"); } #[test] fn parse_loca_table() { let font = test_font(); let loca = font.loca().expect("failed to parse loca"); let maxp = font.maxp().unwrap(); // loca has num_glyphs + 1 entries. assert_eq!( loca.offsets.len(), maxp.num_glyphs as usize + 1, "loca should have num_glyphs + 1 entries" ); } #[test] fn glyph_index_lookup() { let font = test_font(); // 'A' should map to a nonzero glyph. let gid = font.glyph_index(0x0041).expect("glyph_index failed"); assert!(gid.is_some() && gid.unwrap() > 0); // A private-use code point likely has no glyph. let gid_pua = font.glyph_index(0xFFFD).expect("glyph_index failed"); // FFFD (replacement char) might or might not exist — just check no crash. let _ = gid_pua; } #[test] fn load_system_font_works() { // This test may fail in CI where no fonts are installed, // but should pass on macOS. if std::path::Path::new("/System/Library/Fonts/Geneva.ttf").exists() || std::path::Path::new("/System/Library/Fonts/Monaco.ttf").exists() { let font = load_system_font().expect("should load a system font"); assert!(!font.tables.is_empty()); } } #[test] fn parse_os2_table() { let font = test_font(); let os2 = font.os2().expect("failed to parse OS/2"); // Weight class should be in valid range (100–900). assert!( os2.us_weight_class >= 100 && os2.us_weight_class <= 900, "weight class out of range: {}", os2.us_weight_class ); // Width class should be 1–9. assert!( os2.us_width_class >= 1 && os2.us_width_class <= 9, "width class out of range: {}", os2.us_width_class ); // Typo ascender should be positive for normal fonts. assert!( os2.s_typo_ascender > 0, "sTypoAscender should be positive: {}", os2.s_typo_ascender ); // Typo descender should be negative or zero. assert!( os2.s_typo_descender <= 0, "sTypoDescender should be <= 0: {}", os2.s_typo_descender ); } #[test] fn parse_os2_version_2_fields() { let font = test_font(); let os2 = font.os2().expect("failed to parse OS/2"); if os2.version >= 2 { // sxHeight and sCapHeight should be non-negative for version >= 2. assert!( os2.sx_height >= 0, "sxHeight should be >= 0: {}", os2.sx_height ); assert!( os2.s_cap_height >= 0, "sCapHeight should be >= 0: {}", os2.s_cap_height ); } } #[test] fn glyph_outline_simple() { let font = test_font(); // Get glyph ID for 'A'. let gid = font .glyph_index(0x0041) .expect("glyph_index failed") .expect("no glyph for 'A'"); let outline = font .glyph_outline(gid) .expect("glyph_outline failed") .expect("'A' should have an outline"); // 'A' should have at least one contour. assert!( !outline.contours.is_empty(), "'A' should have at least 1 contour" ); // Bounding box should be valid. assert!( outline.x_max >= outline.x_min, "x_max ({}) should be >= x_min ({})", outline.x_max, outline.x_min ); assert!( outline.y_max >= outline.y_min, "y_max ({}) should be >= y_min ({})", outline.y_max, outline.y_min ); // Each contour should have points. for contour in &outline.contours { assert!( !contour.points.is_empty(), "contour should have at least one point" ); } } #[test] fn glyph_outline_space_has_no_outline() { let font = test_font(); // Space should map to a glyph but have no outline. let gid = font .glyph_index(0x0020) .expect("glyph_index failed") .expect("no glyph for space"); let outline = font.glyph_outline(gid).expect("glyph_outline failed"); assert!( outline.is_none(), "space should have no outline (got {:?})", outline ); } #[test] fn glyph_outline_multiple_glyphs() { let font = test_font(); // Parse outlines for several ASCII characters. for &cp in &[0x0042u32, 0x0043, 0x004F, 0x0053] { // B, C, O, S let gid = font.glyph_index(cp).expect("glyph_index failed"); if let Some(gid) = gid { let result = font.glyph_outline(gid); assert!( result.is_ok(), "failed to parse outline for U+{:04X}: {:?}", cp, result.err() ); } } } #[test] fn glyph_outline_has_on_curve_points() { let font = test_font(); let gid = font .glyph_index(0x0041) .expect("glyph_index failed") .expect("no glyph for 'A'"); let outline = font .glyph_outline(gid) .expect("glyph_outline failed") .expect("'A' should have an outline"); // Every contour should have at least some on-curve points. for contour in &outline.contours { let on_curve_count = contour.points.iter().filter(|p| p.on_curve).count(); assert!( on_curve_count > 0, "contour should have at least one on-curve point" ); } } #[test] fn glyph_outline_notdef() { let font = test_font(); // Glyph 0 (.notdef) usually has an outline (a rectangle or empty box). let result = font.glyph_outline(0); assert!(result.is_ok(), ".notdef outline parse should not error"); // .notdef may or may not have an outline — just verify no crash. } #[test] fn kern_table_loads() { let font = test_font(); // kern table is optional — just verify parsing doesn't error. let kern = font.kern(); assert!(kern.is_ok(), "kern() should not error: {:?}", kern.err()); } #[test] fn kern_pair_no_crash() { let font = test_font(); let gid_a = font.glyph_index(0x0041).unwrap().unwrap_or(0); let gid_v = font.glyph_index(0x0056).unwrap().unwrap_or(0); // Just verify no crash; value may be 0 if font has no kern table. let _val = font.kern_pair(gid_a, gid_v); } #[test] fn shape_text_basic() { let font = test_font(); let shaped = font.shape_text("Hello", 16.0); assert_eq!(shaped.len(), 5, "should have 5 glyphs for 'Hello'"); // All glyph IDs should be nonzero (font has Latin glyphs). for (i, g) in shaped.iter().enumerate() { assert!(g.glyph_id > 0, "glyph {} should have nonzero ID", i); } // Positions should be monotonically increasing. for i in 1..shaped.len() { assert!( shaped[i].x_offset > shaped[i - 1].x_offset, "glyph {} x_offset ({}) should be > glyph {} x_offset ({})", i, shaped[i].x_offset, i - 1, shaped[i - 1].x_offset ); } // First glyph should start at 0. assert_eq!(shaped[0].x_offset, 0.0, "first glyph should start at x=0"); // All advances should be positive. for (i, g) in shaped.iter().enumerate() { assert!( g.x_advance > 0.0, "glyph {} advance ({}) should be positive", i, g.x_advance ); } } #[test] fn shape_text_empty() { let font = test_font(); let shaped = font.shape_text("", 16.0); assert!(shaped.is_empty(), "empty text should produce no glyphs"); } #[test] fn shape_text_single_char() { let font = test_font(); let shaped = font.shape_text("A", 16.0); assert_eq!(shaped.len(), 1); assert_eq!(shaped[0].x_offset, 0.0); assert!(shaped[0].x_advance > 0.0); } #[test] fn shape_text_space_has_advance() { let font = test_font(); let shaped = font.shape_text(" ", 16.0); assert_eq!(shaped.len(), 1); // Space should have an advance width (it occupies horizontal space). assert!( shaped[0].x_advance > 0.0, "space should have positive advance: {}", shaped[0].x_advance ); } #[test] fn shape_text_scales_with_size() { let font = test_font(); let small = font.shape_text("A", 12.0); let large = font.shape_text("A", 24.0); // At double the size, the advance should be double. let ratio = large[0].x_advance / small[0].x_advance; assert!( (ratio - 2.0).abs() < 0.01, "advance ratio should be ~2.0, got {}", ratio ); } #[test] fn shape_text_fonts_without_kern_work() { let font = test_font(); // Even if the font has no kern table, shaping should work fine. let shaped = font.shape_text("AV", 16.0); 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"); } } }