From 8db0c5af688a8ba07f12763ce0916bbc028e214b Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Tue, 24 Feb 2026 02:10:18 +0100 Subject: [PATCH] Implement OTF/TTF font file parsing in text crate Parse the OpenType/TrueType table directory and essential font tables: - head: font header (units per em, bounding box, loca index format) - maxp: maximum profile (number of glyphs) - hhea: horizontal header (ascent, descent, line gap, metrics count) - hmtx: horizontal metrics (advance width + left side bearing per glyph) - cmap: character to glyph mapping (format 4 for BMP, format 12 for full Unicode) - name: naming table (family name, subfamily, full name with UTF-16BE and Mac Roman decoding) - loca: glyph location index (short and long offset formats) Includes a binary reader utility for big-endian parsing and a system font loader for macOS. All parsers are tested against real system fonts (Geneva/Monaco). Co-Authored-By: Claude Opus 4.6 --- crates/text/src/font/mod.rs | 373 ++++++++++++++++++++++++++++ crates/text/src/font/parse.rs | 79 ++++++ crates/text/src/font/tables/cmap.rs | 285 +++++++++++++++++++++ crates/text/src/font/tables/head.rs | 74 ++++++ crates/text/src/font/tables/hhea.rs | 60 +++++ crates/text/src/font/tables/hmtx.rs | 55 ++++ crates/text/src/font/tables/loca.rs | 79 ++++++ crates/text/src/font/tables/maxp.rs | 35 +++ crates/text/src/font/tables/mod.rs | 9 + crates/text/src/font/tables/name.rs | 221 ++++++++++++++++ crates/text/src/lib.rs | 2 + 11 files changed, 1272 insertions(+) create mode 100644 crates/text/src/font/mod.rs create mode 100644 crates/text/src/font/parse.rs create mode 100644 crates/text/src/font/tables/cmap.rs create mode 100644 crates/text/src/font/tables/head.rs create mode 100644 crates/text/src/font/tables/hhea.rs create mode 100644 crates/text/src/font/tables/hmtx.rs create mode 100644 crates/text/src/font/tables/loca.rs create mode 100644 crates/text/src/font/tables/maxp.rs create mode 100644 crates/text/src/font/tables/mod.rs create mode 100644 crates/text/src/font/tables/name.rs diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs new file mode 100644 index 0000000..e2001e3 --- /dev/null +++ b/crates/text/src/font/mod.rs @@ -0,0 +1,373 @@ +//! 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::fmt; + +mod parse; +mod tables; + +pub use tables::cmap::CmapTable; +pub use tables::head::HeadTable; +pub use tables::hhea::HheaTable; +pub use tables::hmtx::HmtxTable; +pub use tables::loca::LocaTable; +pub use tables::maxp::MaxpTable; +pub use tables::name::NameTable; + +/// 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. +#[derive(Debug)] +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, +} + +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, + }) + } + + /// 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) + } + + /// 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)) + } + + /// 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()); + } + } +} diff --git a/crates/text/src/font/parse.rs b/crates/text/src/font/parse.rs new file mode 100644 index 0000000..f7435df --- /dev/null +++ b/crates/text/src/font/parse.rs @@ -0,0 +1,79 @@ +//! Binary parsing utilities for reading big-endian font data. + +use super::FontError; + +/// A zero-copy reader over a byte slice, for big-endian binary parsing. +pub struct Reader<'a> { + data: &'a [u8], +} + +impl<'a> Reader<'a> { + pub fn new(data: &'a [u8]) -> Self { + Reader { data } + } + + pub fn len(&self) -> usize { + self.data.len() + } + + fn check(&self, offset: usize, size: usize) -> Result<(), FontError> { + if offset + size > self.data.len() { + Err(FontError::UnexpectedEof) + } else { + Ok(()) + } + } + + pub fn u16(&self, offset: usize) -> Result { + self.check(offset, 2)?; + Ok(u16::from_be_bytes([ + self.data[offset], + self.data[offset + 1], + ])) + } + + pub fn i16(&self, offset: usize) -> Result { + self.check(offset, 2)?; + Ok(i16::from_be_bytes([ + self.data[offset], + self.data[offset + 1], + ])) + } + + pub fn u32(&self, offset: usize) -> Result { + self.check(offset, 4)?; + Ok(u32::from_be_bytes([ + self.data[offset], + self.data[offset + 1], + self.data[offset + 2], + self.data[offset + 3], + ])) + } + + pub fn i32(&self, offset: usize) -> Result { + self.check(offset, 4)?; + Ok(i32::from_be_bytes([ + self.data[offset], + self.data[offset + 1], + self.data[offset + 2], + self.data[offset + 3], + ])) + } + + /// Read a 4-byte tag (e.g., table tags like b"head"). + pub fn tag(&self, offset: usize) -> Result<[u8; 4], FontError> { + self.check(offset, 4)?; + Ok([ + self.data[offset], + self.data[offset + 1], + self.data[offset + 2], + self.data[offset + 3], + ]) + } + + /// Get a sub-slice of the data. + pub fn slice(&self, offset: usize, len: usize) -> Result<&'a [u8], FontError> { + self.check(offset, len)?; + Ok(&self.data[offset..offset + len]) + } +} diff --git a/crates/text/src/font/tables/cmap.rs b/crates/text/src/font/tables/cmap.rs new file mode 100644 index 0000000..5c4f982 --- /dev/null +++ b/crates/text/src/font/tables/cmap.rs @@ -0,0 +1,285 @@ +//! `cmap` — Character to Glyph Index Mapping table. +//! +//! Maps Unicode code points to glyph indices. Supports format 4 (BMP) and +//! format 12 (full Unicode). +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `cmap` table. +#[derive(Debug)] +pub struct CmapTable { + /// The best subtable we found (preferring format 12 over format 4). + subtable: CmapSubtable, +} + +#[derive(Debug)] +enum CmapSubtable { + Format4(Format4), + Format12(Format12), +} + +/// cmap format 4: Segment mapping to delta values (BMP only). +#[derive(Debug)] +struct Format4 { + /// Parallel arrays defining segments. + end_codes: Vec, + start_codes: Vec, + id_deltas: Vec, + id_range_offsets: Vec, + /// The raw glyph index array following the segments. + glyph_indices: Vec, +} + +/// cmap format 12: Segmented coverage for the full Unicode range. +#[derive(Debug)] +struct Format12 { + groups: Vec, +} + +#[derive(Debug)] +struct SequentialMapGroup { + start_char: u32, + end_char: u32, + start_glyph: u32, +} + +impl CmapTable { + /// Parse the `cmap` table from raw bytes. + /// + /// Selects the best available subtable: + /// 1. Platform 3 (Windows), Encoding 10 (Unicode full) — format 12 + /// 2. Platform 0 (Unicode), Encoding 4 (Unicode full) — format 12 + /// 3. Platform 3 (Windows), Encoding 1 (Unicode BMP) — format 4 + /// 4. Platform 0 (Unicode), Encoding 3 (Unicode BMP) — format 4 + /// 5. First platform 0 or 3 subtable that parses successfully + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 4 { + return Err(FontError::MalformedTable("cmap")); + } + + let num_tables = r.u16(2)? as usize; + + // Collect encoding records. + struct EncodingRecord { + platform_id: u16, + encoding_id: u16, + offset: u32, + } + + let mut records = Vec::with_capacity(num_tables); + for i in 0..num_tables { + let base = 4 + i * 8; + records.push(EncodingRecord { + platform_id: r.u16(base)?, + encoding_id: r.u16(base + 2)?, + offset: r.u32(base + 4)?, + }); + } + + // Try subtables in preference order. + // Priority: (3,10) > (0,4) > (0,6) > (3,1) > (0,3) > (0,*) > (3,*) + let priority = |pid: u16, eid: u16| -> u8 { + match (pid, eid) { + (3, 10) => 0, + (0, 4) => 1, + (0, 6) => 2, + (3, 1) => 3, + (0, 3) => 4, + (0, _) => 5, + (3, _) => 6, + _ => 255, + } + }; + + let mut best: Option<(u8, CmapSubtable)> = None; + + for rec in &records { + let p = priority(rec.platform_id, rec.encoding_id); + if p == 255 { + continue; + } + if let Some((bp, _)) = &best { + if p >= *bp { + continue; + } + } + + let offset = rec.offset as usize; + if offset + 2 > data.len() { + continue; + } + let format = r.u16(offset)?; + + match format { + 4 => { + if let Ok(st) = parse_format4(data, offset) { + best = Some((p, CmapSubtable::Format4(st))); + } + } + 12 => { + if let Ok(st) = parse_format12(data, offset) { + best = Some((p, CmapSubtable::Format12(st))); + } + } + _ => {} + } + } + + match best { + Some((_, subtable)) => Ok(CmapTable { subtable }), + None => Err(FontError::MalformedTable("cmap: no usable subtable")), + } + } + + /// Look up a Unicode code point and return the corresponding glyph index. + /// + /// Returns `None` if the code point is not mapped (maps to glyph 0). + pub fn glyph_index(&self, codepoint: u32) -> Option { + let gid = match &self.subtable { + CmapSubtable::Format4(f4) => lookup_format4(f4, codepoint), + CmapSubtable::Format12(f12) => lookup_format12(f12, codepoint), + }; + if gid == 0 { + None + } else { + Some(gid) + } + } +} + +fn parse_format4(data: &[u8], offset: usize) -> Result { + let r = Reader::new(data); + // format(2) + length(2) + language(2) + segCountX2(2) + if offset + 14 > data.len() { + return Err(FontError::MalformedTable("cmap format 4")); + } + + let seg_count_x2 = r.u16(offset + 6)? as usize; + let seg_count = seg_count_x2 / 2; + // skip searchRange(2) + entrySelector(2) + rangeShift(2) + let end_codes_offset = offset + 14; + // After endCodes there is a reservedPad(2), then startCodes. + let start_codes_offset = end_codes_offset + seg_count_x2 + 2; + let id_delta_offset = start_codes_offset + seg_count_x2; + let id_range_offset = id_delta_offset + seg_count_x2; + + let mut end_codes = Vec::with_capacity(seg_count); + let mut start_codes = Vec::with_capacity(seg_count); + let mut id_deltas = Vec::with_capacity(seg_count); + let mut id_range_offsets = Vec::with_capacity(seg_count); + + for i in 0..seg_count { + end_codes.push(r.u16(end_codes_offset + i * 2)?); + start_codes.push(r.u16(start_codes_offset + i * 2)?); + id_deltas.push(r.i16(id_delta_offset + i * 2)?); + id_range_offsets.push(r.u16(id_range_offset + i * 2)?); + } + + // Everything after idRangeOffset is the glyphIdArray. + let glyph_array_offset = id_range_offset + seg_count_x2; + let remaining_bytes = data.len().saturating_sub(glyph_array_offset); + let num_glyph_indices = remaining_bytes / 2; + let mut glyph_indices = Vec::with_capacity(num_glyph_indices); + for i in 0..num_glyph_indices { + glyph_indices.push(r.u16(glyph_array_offset + i * 2)?); + } + + Ok(Format4 { + end_codes, + start_codes, + id_deltas, + id_range_offsets, + glyph_indices, + }) +} + +fn lookup_format4(f4: &Format4, codepoint: u32) -> u16 { + if codepoint > 0xFFFF { + return 0; + } + let cp = codepoint as u16; + + for i in 0..f4.end_codes.len() { + if cp > f4.end_codes[i] { + continue; + } + if cp < f4.start_codes[i] { + return 0; + } + + if f4.id_range_offsets[i] == 0 { + // Use delta. + return (cp as i32 + f4.id_deltas[i] as i32) as u16; + } + + // Use range offset into glyphIdArray. + // The offset is relative to the position of idRangeOffset[i] in the data. + // index = idRangeOffset[i]/2 + (cp - startCode[i]) - segCount + i + let range_offset = f4.id_range_offsets[i] as usize; + let seg_count = f4.end_codes.len(); + let idx = range_offset / 2 + (cp - f4.start_codes[i]) as usize; + // idx is relative to position of idRangeOffset[i], which is at + // range_offset_base + i*2 in the original data. We need to convert + // to an index into our glyph_indices array. + // The glyph_indices array starts at range_offset_base + seg_count*2. + // So the array index = idx - seg_count + i + let array_idx = idx.wrapping_sub(seg_count).wrapping_add(i); + if array_idx < f4.glyph_indices.len() { + let gid = f4.glyph_indices[array_idx]; + if gid == 0 { + return 0; + } + return (gid as i32 + f4.id_deltas[i] as i32) as u16; + } + + return 0; + } + + 0 +} + +fn parse_format12(data: &[u8], offset: usize) -> Result { + let r = Reader::new(data); + // format(2) + reserved(2) + length(4) + language(4) + numGroups(4) + if offset + 16 > data.len() { + return Err(FontError::MalformedTable("cmap format 12")); + } + + let num_groups = r.u32(offset + 12)? as usize; + let groups_offset = offset + 16; + + let mut groups = Vec::with_capacity(num_groups); + for i in 0..num_groups { + let base = groups_offset + i * 12; + groups.push(SequentialMapGroup { + start_char: r.u32(base)?, + end_char: r.u32(base + 4)?, + start_glyph: r.u32(base + 8)?, + }); + } + + Ok(Format12 { groups }) +} + +fn lookup_format12(f12: &Format12, codepoint: u32) -> u16 { + // Binary search for the group containing codepoint. + let mut lo = 0usize; + let mut hi = f12.groups.len(); + while lo < hi { + let mid = lo + (hi - lo) / 2; + let group = &f12.groups[mid]; + if codepoint < group.start_char { + hi = mid; + } else if codepoint > group.end_char { + lo = mid + 1; + } else { + // Found it. + let gid = group.start_glyph + (codepoint - group.start_char); + return gid as u16; + } + } + 0 +} diff --git a/crates/text/src/font/tables/head.rs b/crates/text/src/font/tables/head.rs new file mode 100644 index 0000000..4d70743 --- /dev/null +++ b/crates/text/src/font/tables/head.rs @@ -0,0 +1,74 @@ +//! `head` — Font Header table. +//! +//! Contains global font metrics and flags. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `head` table. +#[derive(Debug)] +pub struct HeadTable { + /// Major version (should be 1). + pub major_version: u16, + /// Minor version (should be 0). + pub minor_version: u16, + /// Font revision (fixed-point 16.16). + pub font_revision: i32, + /// Units per em (typically 1000 or 2048). + pub units_per_em: u16, + /// Bounding box: minimum x. + pub x_min: i16, + /// Bounding box: minimum y. + pub y_min: i16, + /// Bounding box: maximum x. + pub x_max: i16, + /// Bounding box: maximum y. + pub y_max: i16, + /// Mac style flags (bit 0 = bold, bit 1 = italic). + pub mac_style: u16, + /// Smallest readable size in pixels. + pub lowest_rec_ppem: u16, + /// 0 = short offsets in loca, 1 = long offsets. + pub index_to_loc_format: i16, +} + +impl HeadTable { + /// Parse the `head` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + // Minimum head table size is 54 bytes. + if r.len() < 54 { + return Err(FontError::MalformedTable("head")); + } + + let major_version = r.u16(0)?; + let minor_version = r.u16(2)?; + let font_revision = r.i32(4)?; + // skip checksumAdjustment(4) + magicNumber(4) + flags(2) + let units_per_em = r.u16(18)?; + // skip created(8) + modified(8) + let x_min = r.i16(36)?; + let y_min = r.i16(38)?; + let x_max = r.i16(40)?; + let y_max = r.i16(42)?; + let mac_style = r.u16(44)?; + let lowest_rec_ppem = r.u16(46)?; + // skip fontDirectionHint(2) + let index_to_loc_format = r.i16(50)?; + + Ok(HeadTable { + major_version, + minor_version, + font_revision, + units_per_em, + x_min, + y_min, + x_max, + y_max, + mac_style, + lowest_rec_ppem, + index_to_loc_format, + }) + } +} diff --git a/crates/text/src/font/tables/hhea.rs b/crates/text/src/font/tables/hhea.rs new file mode 100644 index 0000000..7fd57bc --- /dev/null +++ b/crates/text/src/font/tables/hhea.rs @@ -0,0 +1,60 @@ +//! `hhea` — Horizontal Header table. +//! +//! Contains global horizontal layout metrics. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `hhea` table. +#[derive(Debug)] +pub struct HheaTable { + /// Typographic ascent (in font units). + pub ascent: i16, + /// Typographic descent (typically negative, in font units). + pub descent: i16, + /// Typographic line gap (in font units). + pub line_gap: i16, + /// Maximum advance width across all glyphs. + pub advance_width_max: u16, + /// Minimum left side bearing across all glyphs. + pub min_left_side_bearing: i16, + /// Minimum right side bearing across all glyphs. + pub min_right_side_bearing: i16, + /// Maximum x extent (max(lsb + (xMax - xMin))). + pub x_max_extent: i16, + /// Number of entries in the hmtx table's longHorMetric array. + pub num_long_hor_metrics: u16, +} + +impl HheaTable { + /// Parse the `hhea` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 36 { + return Err(FontError::MalformedTable("hhea")); + } + + // skip version(4) + let ascent = r.i16(4)?; + let descent = r.i16(6)?; + let line_gap = r.i16(8)?; + let advance_width_max = r.u16(10)?; + let min_left_side_bearing = r.i16(12)?; + let min_right_side_bearing = r.i16(14)?; + let x_max_extent = r.i16(16)?; + // skip caretSlopeRise(2), caretSlopeRun(2), caretOffset(2), reserved(8), metricDataFormat(2) + let num_long_hor_metrics = r.u16(34)?; + + Ok(HheaTable { + ascent, + descent, + line_gap, + advance_width_max, + min_left_side_bearing, + min_right_side_bearing, + x_max_extent, + num_long_hor_metrics, + }) + } +} diff --git a/crates/text/src/font/tables/hmtx.rs b/crates/text/src/font/tables/hmtx.rs new file mode 100644 index 0000000..42fae7d --- /dev/null +++ b/crates/text/src/font/tables/hmtx.rs @@ -0,0 +1,55 @@ +//! `hmtx` — Horizontal Metrics table. +//! +//! Contains per-glyph horizontal metrics (advance width + left side bearing). +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `hmtx` table. +/// +/// Both `advances` and `lsbs` are indexed by glyph ID and have exactly +/// `num_glyphs` entries. +#[derive(Debug)] +pub struct HmtxTable { + /// Advance widths for each glyph (in font units). + pub advances: Vec, + /// Left side bearings for each glyph (in font units). + pub lsbs: Vec, +} + +impl HmtxTable { + /// Parse the `hmtx` table from raw bytes. + /// + /// `num_long_hor_metrics` comes from `hhea`, `num_glyphs` from `maxp`. + pub fn parse( + data: &[u8], + num_long_hor_metrics: u16, + num_glyphs: u16, + ) -> Result { + let r = Reader::new(data); + let n_long = num_long_hor_metrics as usize; + let n_glyphs = num_glyphs as usize; + + let mut advances = Vec::with_capacity(n_glyphs); + let mut lsbs = Vec::with_capacity(n_glyphs); + + // First n_long entries are (advance_width: u16, lsb: i16) pairs. + for i in 0..n_long { + let offset = i * 4; + advances.push(r.u16(offset)?); + lsbs.push(r.i16(offset + 2)?); + } + + // Remaining glyphs share the last advance width, but have individual lsbs. + let last_advance = advances.last().copied().unwrap_or(0); + let remaining = n_glyphs.saturating_sub(n_long); + let lsb_offset = n_long * 4; + for i in 0..remaining { + advances.push(last_advance); + lsbs.push(r.i16(lsb_offset + i * 2)?); + } + + Ok(HmtxTable { advances, lsbs }) + } +} diff --git a/crates/text/src/font/tables/loca.rs b/crates/text/src/font/tables/loca.rs new file mode 100644 index 0000000..0bae99a --- /dev/null +++ b/crates/text/src/font/tables/loca.rs @@ -0,0 +1,79 @@ +//! `loca` — Index to Location table. +//! +//! Maps glyph IDs to byte offsets within the `glyf` table. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `loca` table. +/// +/// Contains `num_glyphs + 1` offsets. The glyph data for glyph `i` starts at +/// `offsets[i]` and ends at `offsets[i + 1]`. If they are equal, the glyph +/// has no outline (e.g., a space character). +#[derive(Debug)] +pub struct LocaTable { + /// Byte offsets into the `glyf` table, one per glyph plus a sentinel. + pub offsets: Vec, +} + +impl LocaTable { + /// Parse the `loca` table from raw bytes. + /// + /// `index_to_loc_format` comes from the `head` table (0 = short, 1 = long). + /// `num_glyphs` comes from the `maxp` table. + pub fn parse( + data: &[u8], + index_to_loc_format: i16, + num_glyphs: u16, + ) -> Result { + let r = Reader::new(data); + let count = num_glyphs as usize + 1; + let mut offsets = Vec::with_capacity(count); + + match index_to_loc_format { + 0 => { + // Short format: offsets are u16 values divided by 2. + for i in 0..count { + let raw = r.u16(i * 2)? as u32; + offsets.push(raw * 2); + } + } + 1 => { + // Long format: offsets are u32 values. + for i in 0..count { + offsets.push(r.u32(i * 4)?); + } + } + _ => return Err(FontError::MalformedTable("loca: invalid index format")), + } + + Ok(LocaTable { offsets }) + } + + /// Returns true if the glyph has outline data (non-empty in glyf). + pub fn has_outline(&self, glyph_id: u16) -> bool { + let i = glyph_id as usize; + if i + 1 < self.offsets.len() { + self.offsets[i] != self.offsets[i + 1] + } else { + false + } + } + + /// Get the byte range for a glyph within the `glyf` table. + pub fn glyph_range(&self, glyph_id: u16) -> Option<(u32, u32)> { + let i = glyph_id as usize; + if i + 1 < self.offsets.len() { + let start = self.offsets[i]; + let end = self.offsets[i + 1]; + if start < end { + Some((start, end)) + } else { + None + } + } else { + None + } + } +} diff --git a/crates/text/src/font/tables/maxp.rs b/crates/text/src/font/tables/maxp.rs new file mode 100644 index 0000000..a918fd5 --- /dev/null +++ b/crates/text/src/font/tables/maxp.rs @@ -0,0 +1,35 @@ +//! `maxp` — Maximum Profile table. +//! +//! Contains the number of glyphs in the font plus (for TrueType) various +//! maximum values used for memory allocation. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `maxp` table. +#[derive(Debug)] +pub struct MaxpTable { + /// Version (0x00005000 for CFF, 0x00010000 for TrueType). + pub version: u32, + /// Total number of glyphs in the font. + pub num_glyphs: u16, +} + +impl MaxpTable { + /// Parse the `maxp` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 6 { + return Err(FontError::MalformedTable("maxp")); + } + + let version = r.u32(0)?; + let num_glyphs = r.u16(4)?; + + Ok(MaxpTable { + version, + num_glyphs, + }) + } +} diff --git a/crates/text/src/font/tables/mod.rs b/crates/text/src/font/tables/mod.rs new file mode 100644 index 0000000..8a74647 --- /dev/null +++ b/crates/text/src/font/tables/mod.rs @@ -0,0 +1,9 @@ +//! Individual font table parsers. + +pub mod cmap; +pub mod head; +pub mod hhea; +pub mod hmtx; +pub mod loca; +pub mod maxp; +pub mod name; diff --git a/crates/text/src/font/tables/name.rs b/crates/text/src/font/tables/name.rs new file mode 100644 index 0000000..f0d8ca2 --- /dev/null +++ b/crates/text/src/font/tables/name.rs @@ -0,0 +1,221 @@ +//! `name` — Naming table. +//! +//! Contains human-readable strings like family name, style name, copyright, etc. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `name` table. +#[derive(Debug)] +pub struct NameTable { + /// All name records extracted from the table. + pub records: Vec, +} + +/// A single name record. +#[derive(Debug)] +pub struct NameRecord { + /// Platform ID (0 = Unicode, 1 = Macintosh, 3 = Windows). + pub platform_id: u16, + /// Encoding ID (platform-specific). + pub encoding_id: u16, + /// Language ID. + pub language_id: u16, + /// Name ID (1 = family, 2 = subfamily, 4 = full name, 6 = PostScript name, etc.). + pub name_id: u16, + /// The decoded string value. + pub value: String, +} + +impl NameTable { + /// Parse the `name` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 6 { + return Err(FontError::MalformedTable("name")); + } + + // format(2) + count(2) + stringOffset(2) + let count = r.u16(2)? as usize; + let string_offset = r.u16(4)? as usize; + + let mut records = Vec::with_capacity(count); + + for i in 0..count { + let base = 6 + i * 12; + if base + 12 > data.len() { + break; + } + + let platform_id = r.u16(base)?; + let encoding_id = r.u16(base + 2)?; + let language_id = r.u16(base + 4)?; + let name_id = r.u16(base + 6)?; + let length = r.u16(base + 8)? as usize; + let offset = r.u16(base + 10)? as usize; + + let str_start = string_offset + offset; + if str_start + length > data.len() { + continue; + } + + let raw = r.slice(str_start, length)?; + let value = decode_name_string(platform_id, encoding_id, raw); + + records.push(NameRecord { + platform_id, + encoding_id, + language_id, + name_id, + value, + }); + } + + Ok(NameTable { records }) + } + + /// Get the font family name (name ID 1). + /// + /// Prefers Windows/Unicode platform, falls back to any platform. + pub fn family_name(&self) -> Option<&str> { + self.get_name(1) + } + + /// Get the font subfamily/style name (name ID 2, e.g. "Regular", "Bold"). + pub fn subfamily_name(&self) -> Option<&str> { + self.get_name(2) + } + + /// Get the full font name (name ID 4). + pub fn full_name(&self) -> Option<&str> { + self.get_name(4) + } + + /// Get a name string by name ID. + /// + /// Prefers Windows platform (3) with English, then any platform. + fn get_name(&self, name_id: u16) -> Option<&str> { + // Prefer Windows platform (3), English (language_id 0x0409). + let win_en = self + .records + .iter() + .find(|r| r.name_id == name_id && r.platform_id == 3 && r.language_id == 0x0409); + if let Some(rec) = win_en { + if !rec.value.is_empty() { + return Some(&rec.value); + } + } + + // Fall back to any Windows platform record. + let win = self + .records + .iter() + .find(|r| r.name_id == name_id && r.platform_id == 3); + if let Some(rec) = win { + if !rec.value.is_empty() { + return Some(&rec.value); + } + } + + // Fall back to any record. + self.records + .iter() + .find(|r| r.name_id == name_id && !r.value.is_empty()) + .map(|r| r.value.as_str()) + } +} + +/// Decode a name string based on platform/encoding. +fn decode_name_string(platform_id: u16, encoding_id: u16, data: &[u8]) -> String { + match platform_id { + 0 => { + // Unicode platform — always UTF-16BE. + decode_utf16be(data) + } + 1 => { + // Macintosh platform. + if encoding_id == 0 { + // Mac Roman. + decode_mac_roman(data) + } else { + // Other Mac encodings — treat as ASCII fallback. + String::from_utf8_lossy(data).into_owned() + } + } + 3 => { + // Windows platform — encoding 1 = UTF-16BE, encoding 10 = UTF-16BE. + match encoding_id { + 1 | 10 => decode_utf16be(data), + 0 => { + // Symbol encoding — treat as UTF-16BE. + decode_utf16be(data) + } + _ => String::from_utf8_lossy(data).into_owned(), + } + } + _ => String::from_utf8_lossy(data).into_owned(), + } +} + +fn decode_utf16be(data: &[u8]) -> String { + let mut chars = Vec::with_capacity(data.len() / 2); + let mut i = 0; + while i + 1 < data.len() { + let unit = u16::from_be_bytes([data[i], data[i + 1]]); + i += 2; + + // Handle surrogate pairs. + if (0xD800..=0xDBFF).contains(&unit) { + if i + 1 < data.len() { + let lo = u16::from_be_bytes([data[i], data[i + 1]]); + if (0xDC00..=0xDFFF).contains(&lo) { + i += 2; + let cp = 0x10000 + ((unit as u32 - 0xD800) << 10) + (lo as u32 - 0xDC00); + if let Some(ch) = char::from_u32(cp) { + chars.push(ch); + } + continue; + } + } + // Lone surrogate — skip. + continue; + } + + if let Some(ch) = char::from_u32(unit as u32) { + chars.push(ch); + } + } + chars.into_iter().collect() +} + +fn decode_mac_roman(data: &[u8]) -> String { + // Mac Roman: 0x00-0x7F are ASCII, 0x80-0xFF map to specific Unicode code points. + static MAC_ROMAN_HIGH: [u16; 128] = [ + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, + 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, + 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, + 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, + 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, + 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, + 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, + 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, + 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, + 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, + 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, + 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, + ]; + + let mut s = String::with_capacity(data.len()); + for &b in data { + if b < 0x80 { + s.push(b as char); + } else { + let cp = MAC_ROMAN_HIGH[(b - 0x80) as usize]; + if let Some(ch) = char::from_u32(cp as u32) { + s.push(ch); + } + } + } + s +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 150e351..12e8401 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1 +1,3 @@ //! Font parsing (OTF/TTF), shaping, rasterization, and line breaking — pure Rust. + +pub mod font; -- 2.51.2