web engine - experimental web browser
at main 973 lines 32 kB view raw
1//! OTF/TTF font file parser. 2//! 3//! Parses the OpenType/TrueType table directory and individual tables needed 4//! for text rendering: head, maxp, hhea, hmtx, cmap, name, loca. 5 6use std::cell::RefCell; 7use std::fmt; 8 9pub mod cache; 10mod parse; 11pub mod rasterizer; 12pub mod registry; 13mod tables; 14 15pub use cache::GlyphCache; 16pub use rasterizer::GlyphBitmap; 17pub use registry::{FontEntry, FontRegistry}; 18pub use tables::cmap::CmapTable; 19pub use tables::glyf::{Contour, GlyphOutline, Point}; 20pub use tables::head::HeadTable; 21pub use tables::hhea::HheaTable; 22pub use tables::hmtx::HmtxTable; 23pub use tables::kern::KernTable; 24pub use tables::loca::LocaTable; 25pub use tables::maxp::MaxpTable; 26pub use tables::name::NameTable; 27pub use tables::os2::Os2Table; 28 29/// A positioned glyph in a shaped text run. 30#[derive(Debug, Clone)] 31pub struct ShapedGlyph { 32 /// Glyph ID in the font. 33 pub glyph_id: u16, 34 /// Horizontal position in pixels. 35 pub x_offset: f32, 36 /// Vertical offset in pixels (usually 0 for basic horizontal shaping). 37 pub y_offset: f32, 38 /// Horizontal advance in pixels. 39 pub x_advance: f32, 40} 41 42/// A positioned glyph with its rasterized bitmap, ready for rendering. 43#[derive(Debug, Clone)] 44pub 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 55/// Errors that can occur during font parsing. 56#[derive(Debug)] 57pub enum FontError { 58 /// The data is too short to contain the expected structure. 59 UnexpectedEof, 60 /// The font file has an unrecognized magic number / sfVersion. 61 InvalidMagic(u32), 62 /// A required table is missing. 63 MissingTable(&'static str), 64 /// A table's data is malformed. 65 MalformedTable(&'static str), 66} 67 68impl fmt::Display for FontError { 69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 70 match self { 71 FontError::UnexpectedEof => write!(f, "unexpected end of font data"), 72 FontError::InvalidMagic(v) => write!(f, "invalid font magic: 0x{:08X}", v), 73 FontError::MissingTable(t) => write!(f, "missing required table: {}", t), 74 FontError::MalformedTable(t) => write!(f, "malformed table: {}", t), 75 } 76 } 77} 78 79/// A record in the table directory describing one font table. 80#[derive(Debug, Clone)] 81pub struct TableRecord { 82 /// Four-byte tag (e.g. b"head", b"cmap"). 83 pub tag: [u8; 4], 84 /// Checksum of the table. 85 pub checksum: u32, 86 /// Offset from the beginning of the font file. 87 pub offset: u32, 88 /// Length of the table in bytes. 89 pub length: u32, 90} 91 92impl TableRecord { 93 /// Return the tag as a string (for display/debugging). 94 pub fn tag_str(&self) -> &str { 95 std::str::from_utf8(&self.tag).unwrap_or("????") 96 } 97} 98 99/// A parsed OpenType/TrueType font. 100pub struct Font { 101 /// Raw font data (owned). 102 data: Vec<u8>, 103 /// Offset subtable version (0x00010000 for TrueType, 0x4F54544F for CFF). 104 pub sf_version: u32, 105 /// Table directory records. 106 pub tables: Vec<TableRecord>, 107 /// Cache of rasterized glyph bitmaps, keyed by (glyph_id, size_px). 108 glyph_cache: RefCell<GlyphCache>, 109} 110 111impl 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 } 119} 120 121impl Font { 122 /// Parse a font from raw file bytes. 123 pub fn parse(data: Vec<u8>) -> Result<Font, FontError> { 124 let r = parse::Reader::new(&data); 125 126 let sf_version = r.u32(0)?; 127 match sf_version { 128 0x00010000 => {} // TrueType 129 0x4F54544F => {} // CFF (OpenType with PostScript outlines) 130 0x74727565 => {} // 'true' — old Apple TrueType 131 _ => return Err(FontError::InvalidMagic(sf_version)), 132 } 133 134 let num_tables = r.u16(4)? as usize; 135 // skip searchRange(2), entrySelector(2), rangeShift(2) = 6 bytes 136 let mut tables = Vec::with_capacity(num_tables); 137 for i in 0..num_tables { 138 let base = 12 + i * 16; 139 let tag = r.tag(base)?; 140 let checksum = r.u32(base + 4)?; 141 let offset = r.u32(base + 8)?; 142 let length = r.u32(base + 12)?; 143 tables.push(TableRecord { 144 tag, 145 checksum, 146 offset, 147 length, 148 }); 149 } 150 151 Ok(Font { 152 data, 153 sf_version, 154 tables, 155 glyph_cache: RefCell::new(GlyphCache::new()), 156 }) 157 } 158 159 /// Load a font from a file path. 160 pub fn from_file(path: &std::path::Path) -> Result<Font, FontError> { 161 let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?; 162 Font::parse(data) 163 } 164 165 /// Find a table record by its 4-byte tag. 166 pub fn table_record(&self, tag: &[u8; 4]) -> Option<&TableRecord> { 167 self.tables.iter().find(|t| &t.tag == tag) 168 } 169 170 /// Get the raw bytes for a table. 171 pub fn table_data(&self, tag: &[u8; 4]) -> Option<&[u8]> { 172 let rec = self.table_record(tag)?; 173 let start = rec.offset as usize; 174 let end = start + rec.length as usize; 175 if end <= self.data.len() { 176 Some(&self.data[start..end]) 177 } else { 178 None 179 } 180 } 181 182 /// Parse the `head` table. 183 pub fn head(&self) -> Result<HeadTable, FontError> { 184 let data = self 185 .table_data(b"head") 186 .ok_or(FontError::MissingTable("head"))?; 187 HeadTable::parse(data) 188 } 189 190 /// Parse the `maxp` table. 191 pub fn maxp(&self) -> Result<MaxpTable, FontError> { 192 let data = self 193 .table_data(b"maxp") 194 .ok_or(FontError::MissingTable("maxp"))?; 195 MaxpTable::parse(data) 196 } 197 198 /// Parse the `hhea` table. 199 pub fn hhea(&self) -> Result<HheaTable, FontError> { 200 let data = self 201 .table_data(b"hhea") 202 .ok_or(FontError::MissingTable("hhea"))?; 203 HheaTable::parse(data) 204 } 205 206 /// Parse the `hmtx` table. 207 /// 208 /// Requires `maxp` and `hhea` to determine dimensions. 209 pub fn hmtx(&self) -> Result<HmtxTable, FontError> { 210 let maxp = self.maxp()?; 211 let hhea = self.hhea()?; 212 let data = self 213 .table_data(b"hmtx") 214 .ok_or(FontError::MissingTable("hmtx"))?; 215 HmtxTable::parse(data, hhea.num_long_hor_metrics, maxp.num_glyphs) 216 } 217 218 /// Parse the `cmap` table. 219 pub fn cmap(&self) -> Result<CmapTable, FontError> { 220 let data = self 221 .table_data(b"cmap") 222 .ok_or(FontError::MissingTable("cmap"))?; 223 CmapTable::parse(data) 224 } 225 226 /// Parse the `name` table. 227 pub fn name(&self) -> Result<NameTable, FontError> { 228 let data = self 229 .table_data(b"name") 230 .ok_or(FontError::MissingTable("name"))?; 231 NameTable::parse(data) 232 } 233 234 /// Parse the `loca` table. 235 /// 236 /// Requires `head` (for index format) and `maxp` (for glyph count). 237 pub fn loca(&self) -> Result<LocaTable, FontError> { 238 let head = self.head()?; 239 let maxp = self.maxp()?; 240 let data = self 241 .table_data(b"loca") 242 .ok_or(FontError::MissingTable("loca"))?; 243 LocaTable::parse(data, head.index_to_loc_format, maxp.num_glyphs) 244 } 245 246 /// Parse the `OS/2` table. 247 pub fn os2(&self) -> Result<Os2Table, FontError> { 248 let data = self 249 .table_data(b"OS/2") 250 .ok_or(FontError::MissingTable("OS/2"))?; 251 Os2Table::parse(data) 252 } 253 254 /// Parse the `kern` table, if present. 255 /// 256 /// Returns an empty `KernTable` (zero pairs) if the font has no kern table. 257 pub fn kern(&self) -> Result<KernTable, FontError> { 258 match self.table_data(b"kern") { 259 Some(data) => KernTable::parse(data), 260 None => Ok(KernTable::empty()), 261 } 262 } 263 264 /// Look up the raw kerning value for a pair of glyph IDs (in font units). 265 /// 266 /// Returns 0 if the font has no kern table or the pair has no adjustment. 267 pub fn kern_pair(&self, left: u16, right: u16) -> i16 { 268 self.kern().map(|k| k.kern_value(left, right)).unwrap_or(0) 269 } 270 271 /// Shape a text string: map characters to glyphs and compute positions. 272 /// 273 /// Returns a list of positioned glyphs with coordinates in pixels. 274 pub fn shape_text(&self, text: &str, size_px: f32) -> Vec<ShapedGlyph> { 275 let head = match self.head() { 276 Ok(h) => h, 277 Err(_) => return Vec::new(), 278 }; 279 let scale = size_px / head.units_per_em as f32; 280 281 let cmap = match self.cmap() { 282 Ok(c) => c, 283 Err(_) => return Vec::new(), 284 }; 285 let hmtx = match self.hmtx() { 286 Ok(h) => h, 287 Err(_) => return Vec::new(), 288 }; 289 let kern = self.kern().unwrap_or_else(|_| KernTable::empty()); 290 291 // Map characters to glyph IDs. 292 let glyph_ids: Vec<u16> = text 293 .chars() 294 .map(|ch| cmap.glyph_index(ch as u32).unwrap_or(0)) 295 .collect(); 296 297 let mut result = Vec::with_capacity(glyph_ids.len()); 298 let mut cursor_x: f32 = 0.0; 299 300 for (i, &gid) in glyph_ids.iter().enumerate() { 301 let advance_fu = hmtx.advances.get(gid as usize).copied().unwrap_or(0); 302 let x_advance = advance_fu as f32 * scale; 303 304 // Apply kerning adjustment with the next glyph. 305 let kern_adjust = if i + 1 < glyph_ids.len() { 306 kern.kern_value(gid, glyph_ids[i + 1]) as f32 * scale 307 } else { 308 0.0 309 }; 310 311 result.push(ShapedGlyph { 312 glyph_id: gid, 313 x_offset: cursor_x, 314 y_offset: 0.0, 315 x_advance, 316 }); 317 318 cursor_x += x_advance + kern_adjust; 319 } 320 321 result 322 } 323 324 /// Map a Unicode code point to a glyph index using the cmap table. 325 pub fn glyph_index(&self, codepoint: u32) -> Result<Option<u16>, FontError> { 326 let cmap = self.cmap()?; 327 Ok(cmap.glyph_index(codepoint)) 328 } 329 330 /// Extract the outline for a glyph by its glyph ID. 331 /// 332 /// Returns `None` for glyphs with no outline (e.g., space). 333 /// Requires the `glyf` and `loca` tables. 334 pub fn glyph_outline(&self, glyph_id: u16) -> Result<Option<GlyphOutline>, FontError> { 335 let loca = self.loca()?; 336 let glyf_data = self 337 .table_data(b"glyf") 338 .ok_or(FontError::MissingTable("glyf"))?; 339 tables::glyf::parse_glyph(glyph_id, glyf_data, &loca) 340 } 341 342 /// Rasterize a glyph outline into an anti-aliased bitmap at the given pixel size. 343 pub fn rasterize_glyph(&self, glyph_id: u16, size_px: f32) -> Option<GlyphBitmap> { 344 let head = self.head().ok()?; 345 let scale = size_px / head.units_per_em as f32; 346 let outline = self.glyph_outline(glyph_id).ok()??; 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() 396 } 397 398 /// Returns true if this is a TrueType font (vs CFF/PostScript outlines). 399 pub fn is_truetype(&self) -> bool { 400 self.sf_version == 0x00010000 || self.sf_version == 0x74727565 401 } 402} 403 404/// Load the first available system font from standard macOS paths. 405/// 406/// Tries these fonts in order: Geneva.ttf, Helvetica.ttc, Monaco.ttf. 407/// For `.ttc` (TrueType Collection) files, only the first font is parsed. 408pub fn load_system_font() -> Result<Font, FontError> { 409 let candidates = [ 410 "/System/Library/Fonts/Geneva.ttf", 411 "/System/Library/Fonts/Monaco.ttf", 412 ]; 413 for path in &candidates { 414 let p = std::path::Path::new(path); 415 if p.exists() { 416 return Font::from_file(p); 417 } 418 } 419 Err(FontError::MissingTable("no system font found")) 420} 421 422#[cfg(test)] 423mod tests { 424 use super::*; 425 426 fn test_font() -> Font { 427 // Try several common macOS fonts. 428 let paths = [ 429 "/System/Library/Fonts/Geneva.ttf", 430 "/System/Library/Fonts/Monaco.ttf", 431 "/System/Library/Fonts/Keyboard.ttf", 432 ]; 433 for path in &paths { 434 let p = std::path::Path::new(path); 435 if p.exists() { 436 return Font::from_file(p).expect("failed to parse font"); 437 } 438 } 439 panic!("no test font found — need a .ttf file in /System/Library/Fonts/"); 440 } 441 442 #[test] 443 fn parse_table_directory() { 444 let font = test_font(); 445 assert!(font.is_truetype()); 446 assert!(!font.tables.is_empty()); 447 // Every font must have these tables. 448 assert!(font.table_record(b"head").is_some(), "missing head table"); 449 assert!(font.table_record(b"cmap").is_some(), "missing cmap table"); 450 assert!(font.table_record(b"maxp").is_some(), "missing maxp table"); 451 } 452 453 #[test] 454 fn parse_head_table() { 455 let font = test_font(); 456 let head = font.head().expect("failed to parse head"); 457 assert!( 458 head.units_per_em > 0, 459 "units_per_em should be positive: {}", 460 head.units_per_em 461 ); 462 assert!( 463 head.units_per_em >= 16 && head.units_per_em <= 16384, 464 "units_per_em out of range: {}", 465 head.units_per_em 466 ); 467 } 468 469 #[test] 470 fn parse_maxp_table() { 471 let font = test_font(); 472 let maxp = font.maxp().expect("failed to parse maxp"); 473 assert!(maxp.num_glyphs > 0, "font should have at least one glyph"); 474 } 475 476 #[test] 477 fn parse_hhea_table() { 478 let font = test_font(); 479 let hhea = font.hhea().expect("failed to parse hhea"); 480 assert!(hhea.ascent > 0, "ascent should be positive"); 481 assert!(hhea.num_long_hor_metrics > 0, "should have metrics"); 482 } 483 484 #[test] 485 fn parse_hmtx_table() { 486 let font = test_font(); 487 let hmtx = font.hmtx().expect("failed to parse hmtx"); 488 let maxp = font.maxp().unwrap(); 489 assert_eq!( 490 hmtx.advances.len(), 491 maxp.num_glyphs as usize, 492 "should have one advance per glyph" 493 ); 494 assert_eq!( 495 hmtx.lsbs.len(), 496 maxp.num_glyphs as usize, 497 "should have one lsb per glyph" 498 ); 499 // Glyph 0 (.notdef) typically has a nonzero advance. 500 assert!(hmtx.advances[0] > 0, "glyph 0 advance should be nonzero"); 501 } 502 503 #[test] 504 fn parse_cmap_table() { 505 let font = test_font(); 506 let cmap = font.cmap().expect("failed to parse cmap"); 507 508 // Look up ASCII 'A' (U+0041) — every Latin font should have it. 509 let glyph_a = cmap.glyph_index(0x0041); 510 assert!( 511 glyph_a.is_some() && glyph_a.unwrap() > 0, 512 "should find a glyph for 'A'" 513 ); 514 515 // Look up space (U+0020). 516 let glyph_space = cmap.glyph_index(0x0020); 517 assert!(glyph_space.is_some(), "should find a glyph for space"); 518 } 519 520 #[test] 521 fn parse_name_table() { 522 let font = test_font(); 523 let name = font.name().expect("failed to parse name"); 524 let family = name.family_name(); 525 assert!(family.is_some(), "should have a family name"); 526 let family = family.unwrap(); 527 assert!(!family.is_empty(), "family name should not be empty"); 528 } 529 530 #[test] 531 fn parse_loca_table() { 532 let font = test_font(); 533 let loca = font.loca().expect("failed to parse loca"); 534 let maxp = font.maxp().unwrap(); 535 // loca has num_glyphs + 1 entries. 536 assert_eq!( 537 loca.offsets.len(), 538 maxp.num_glyphs as usize + 1, 539 "loca should have num_glyphs + 1 entries" 540 ); 541 } 542 543 #[test] 544 fn glyph_index_lookup() { 545 let font = test_font(); 546 // 'A' should map to a nonzero glyph. 547 let gid = font.glyph_index(0x0041).expect("glyph_index failed"); 548 assert!(gid.is_some() && gid.unwrap() > 0); 549 550 // A private-use code point likely has no glyph. 551 let gid_pua = font.glyph_index(0xFFFD).expect("glyph_index failed"); 552 // FFFD (replacement char) might or might not exist — just check no crash. 553 let _ = gid_pua; 554 } 555 556 #[test] 557 fn load_system_font_works() { 558 // This test may fail in CI where no fonts are installed, 559 // but should pass on macOS. 560 if std::path::Path::new("/System/Library/Fonts/Geneva.ttf").exists() 561 || std::path::Path::new("/System/Library/Fonts/Monaco.ttf").exists() 562 { 563 let font = load_system_font().expect("should load a system font"); 564 assert!(!font.tables.is_empty()); 565 } 566 } 567 568 #[test] 569 fn parse_os2_table() { 570 let font = test_font(); 571 let os2 = font.os2().expect("failed to parse OS/2"); 572 // Weight class should be in valid range (100–900). 573 assert!( 574 os2.us_weight_class >= 100 && os2.us_weight_class <= 900, 575 "weight class out of range: {}", 576 os2.us_weight_class 577 ); 578 // Width class should be 1–9. 579 assert!( 580 os2.us_width_class >= 1 && os2.us_width_class <= 9, 581 "width class out of range: {}", 582 os2.us_width_class 583 ); 584 // Typo ascender should be positive for normal fonts. 585 assert!( 586 os2.s_typo_ascender > 0, 587 "sTypoAscender should be positive: {}", 588 os2.s_typo_ascender 589 ); 590 // Typo descender should be negative or zero. 591 assert!( 592 os2.s_typo_descender <= 0, 593 "sTypoDescender should be <= 0: {}", 594 os2.s_typo_descender 595 ); 596 } 597 598 #[test] 599 fn parse_os2_version_2_fields() { 600 let font = test_font(); 601 let os2 = font.os2().expect("failed to parse OS/2"); 602 if os2.version >= 2 { 603 // sxHeight and sCapHeight should be non-negative for version >= 2. 604 assert!( 605 os2.sx_height >= 0, 606 "sxHeight should be >= 0: {}", 607 os2.sx_height 608 ); 609 assert!( 610 os2.s_cap_height >= 0, 611 "sCapHeight should be >= 0: {}", 612 os2.s_cap_height 613 ); 614 } 615 } 616 617 #[test] 618 fn glyph_outline_simple() { 619 let font = test_font(); 620 // Get glyph ID for 'A'. 621 let gid = font 622 .glyph_index(0x0041) 623 .expect("glyph_index failed") 624 .expect("no glyph for 'A'"); 625 let outline = font 626 .glyph_outline(gid) 627 .expect("glyph_outline failed") 628 .expect("'A' should have an outline"); 629 630 // 'A' should have at least one contour. 631 assert!( 632 !outline.contours.is_empty(), 633 "'A' should have at least 1 contour" 634 ); 635 // Bounding box should be valid. 636 assert!( 637 outline.x_max >= outline.x_min, 638 "x_max ({}) should be >= x_min ({})", 639 outline.x_max, 640 outline.x_min 641 ); 642 assert!( 643 outline.y_max >= outline.y_min, 644 "y_max ({}) should be >= y_min ({})", 645 outline.y_max, 646 outline.y_min 647 ); 648 // Each contour should have points. 649 for contour in &outline.contours { 650 assert!( 651 !contour.points.is_empty(), 652 "contour should have at least one point" 653 ); 654 } 655 } 656 657 #[test] 658 fn glyph_outline_space_has_no_outline() { 659 let font = test_font(); 660 // Space should map to a glyph but have no outline. 661 let gid = font 662 .glyph_index(0x0020) 663 .expect("glyph_index failed") 664 .expect("no glyph for space"); 665 let outline = font.glyph_outline(gid).expect("glyph_outline failed"); 666 assert!( 667 outline.is_none(), 668 "space should have no outline (got {:?})", 669 outline 670 ); 671 } 672 673 #[test] 674 fn glyph_outline_multiple_glyphs() { 675 let font = test_font(); 676 // Parse outlines for several ASCII characters. 677 for &cp in &[0x0042u32, 0x0043, 0x004F, 0x0053] { 678 // B, C, O, S 679 let gid = font.glyph_index(cp).expect("glyph_index failed"); 680 if let Some(gid) = gid { 681 let result = font.glyph_outline(gid); 682 assert!( 683 result.is_ok(), 684 "failed to parse outline for U+{:04X}: {:?}", 685 cp, 686 result.err() 687 ); 688 } 689 } 690 } 691 692 #[test] 693 fn glyph_outline_has_on_curve_points() { 694 let font = test_font(); 695 let gid = font 696 .glyph_index(0x0041) 697 .expect("glyph_index failed") 698 .expect("no glyph for 'A'"); 699 let outline = font 700 .glyph_outline(gid) 701 .expect("glyph_outline failed") 702 .expect("'A' should have an outline"); 703 704 // Every contour should have at least some on-curve points. 705 for contour in &outline.contours { 706 let on_curve_count = contour.points.iter().filter(|p| p.on_curve).count(); 707 assert!( 708 on_curve_count > 0, 709 "contour should have at least one on-curve point" 710 ); 711 } 712 } 713 714 #[test] 715 fn glyph_outline_notdef() { 716 let font = test_font(); 717 // Glyph 0 (.notdef) usually has an outline (a rectangle or empty box). 718 let result = font.glyph_outline(0); 719 assert!(result.is_ok(), ".notdef outline parse should not error"); 720 // .notdef may or may not have an outline — just verify no crash. 721 } 722 723 #[test] 724 fn kern_table_loads() { 725 let font = test_font(); 726 // kern table is optional — just verify parsing doesn't error. 727 let kern = font.kern(); 728 assert!(kern.is_ok(), "kern() should not error: {:?}", kern.err()); 729 } 730 731 #[test] 732 fn kern_pair_no_crash() { 733 let font = test_font(); 734 let gid_a = font.glyph_index(0x0041).unwrap().unwrap_or(0); 735 let gid_v = font.glyph_index(0x0056).unwrap().unwrap_or(0); 736 // Just verify no crash; value may be 0 if font has no kern table. 737 let _val = font.kern_pair(gid_a, gid_v); 738 } 739 740 #[test] 741 fn shape_text_basic() { 742 let font = test_font(); 743 let shaped = font.shape_text("Hello", 16.0); 744 745 assert_eq!(shaped.len(), 5, "should have 5 glyphs for 'Hello'"); 746 747 // All glyph IDs should be nonzero (font has Latin glyphs). 748 for (i, g) in shaped.iter().enumerate() { 749 assert!(g.glyph_id > 0, "glyph {} should have nonzero ID", i); 750 } 751 752 // Positions should be monotonically increasing. 753 for i in 1..shaped.len() { 754 assert!( 755 shaped[i].x_offset > shaped[i - 1].x_offset, 756 "glyph {} x_offset ({}) should be > glyph {} x_offset ({})", 757 i, 758 shaped[i].x_offset, 759 i - 1, 760 shaped[i - 1].x_offset 761 ); 762 } 763 764 // First glyph should start at 0. 765 assert_eq!(shaped[0].x_offset, 0.0, "first glyph should start at x=0"); 766 767 // All advances should be positive. 768 for (i, g) in shaped.iter().enumerate() { 769 assert!( 770 g.x_advance > 0.0, 771 "glyph {} advance ({}) should be positive", 772 i, 773 g.x_advance 774 ); 775 } 776 } 777 778 #[test] 779 fn shape_text_empty() { 780 let font = test_font(); 781 let shaped = font.shape_text("", 16.0); 782 assert!(shaped.is_empty(), "empty text should produce no glyphs"); 783 } 784 785 #[test] 786 fn shape_text_single_char() { 787 let font = test_font(); 788 let shaped = font.shape_text("A", 16.0); 789 assert_eq!(shaped.len(), 1); 790 assert_eq!(shaped[0].x_offset, 0.0); 791 assert!(shaped[0].x_advance > 0.0); 792 } 793 794 #[test] 795 fn shape_text_space_has_advance() { 796 let font = test_font(); 797 let shaped = font.shape_text(" ", 16.0); 798 assert_eq!(shaped.len(), 1); 799 // Space should have an advance width (it occupies horizontal space). 800 assert!( 801 shaped[0].x_advance > 0.0, 802 "space should have positive advance: {}", 803 shaped[0].x_advance 804 ); 805 } 806 807 #[test] 808 fn shape_text_scales_with_size() { 809 let font = test_font(); 810 let small = font.shape_text("A", 12.0); 811 let large = font.shape_text("A", 24.0); 812 813 // At double the size, the advance should be double. 814 let ratio = large[0].x_advance / small[0].x_advance; 815 assert!( 816 (ratio - 2.0).abs() < 0.01, 817 "advance ratio should be ~2.0, got {}", 818 ratio 819 ); 820 } 821 822 #[test] 823 fn shape_text_fonts_without_kern_work() { 824 let font = test_font(); 825 // Even if the font has no kern table, shaping should work fine. 826 let shaped = font.shape_text("AV", 16.0); 827 assert_eq!(shaped.len(), 2); 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 } 972 } 973}