web engine - experimental web browser
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}