diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index d846fb6..20bd5e6 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -15,11 +15,25 @@ pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::head::HeadTable; pub use tables::hhea::HheaTable; pub use tables::hmtx::HmtxTable; +pub use tables::kern::KernTable; pub use tables::loca::LocaTable; pub use tables::maxp::MaxpTable; pub use tables::name::NameTable; pub use tables::os2::Os2Table; +/// A positioned glyph in a shaped text run. +#[derive(Debug, Clone)] +pub struct ShapedGlyph { + /// Glyph ID in the font. + pub glyph_id: u16, + /// Horizontal position in pixels. + pub x_offset: f32, + /// Vertical offset in pixels (usually 0 for basic horizontal shaping). + pub y_offset: f32, + /// Horizontal advance in pixels. + pub x_advance: f32, +} + /// Errors that can occur during font parsing. #[derive(Debug)] pub enum FontError { @@ -207,6 +221,76 @@ impl Font { Os2Table::parse(data) } + /// Parse the `kern` table, if present. + /// + /// Returns an empty `KernTable` (zero pairs) if the font has no kern table. + pub fn kern(&self) -> Result { + match self.table_data(b"kern") { + Some(data) => KernTable::parse(data), + None => Ok(KernTable::empty()), + } + } + + /// Look up the raw kerning value for a pair of glyph IDs (in font units). + /// + /// Returns 0 if the font has no kern table or the pair has no adjustment. + pub fn kern_pair(&self, left: u16, right: u16) -> i16 { + self.kern().map(|k| k.kern_value(left, right)).unwrap_or(0) + } + + /// Shape a text string: map characters to glyphs and compute positions. + /// + /// Returns a list of positioned glyphs with coordinates in pixels. + pub fn shape_text(&self, text: &str, size_px: f32) -> Vec { + let head = match self.head() { + Ok(h) => h, + Err(_) => return Vec::new(), + }; + let scale = size_px / head.units_per_em as f32; + + let cmap = match self.cmap() { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let hmtx = match self.hmtx() { + Ok(h) => h, + Err(_) => return Vec::new(), + }; + let kern = self.kern().unwrap_or_else(|_| KernTable::empty()); + + // Map characters to glyph IDs. + let glyph_ids: Vec = text + .chars() + .map(|ch| cmap.glyph_index(ch as u32).unwrap_or(0)) + .collect(); + + let mut result = Vec::with_capacity(glyph_ids.len()); + let mut cursor_x: f32 = 0.0; + + for (i, &gid) in glyph_ids.iter().enumerate() { + let advance_fu = hmtx.advances.get(gid as usize).copied().unwrap_or(0); + let x_advance = advance_fu as f32 * scale; + + // Apply kerning adjustment with the next glyph. + let kern_adjust = if i + 1 < glyph_ids.len() { + kern.kern_value(gid, glyph_ids[i + 1]) as f32 * scale + } else { + 0.0 + }; + + result.push(ShapedGlyph { + glyph_id: gid, + x_offset: cursor_x, + y_offset: 0.0, + x_advance, + }); + + cursor_x += x_advance + kern_adjust; + } + + result + } + /// Map a Unicode code point to a glyph index using the cmap table. pub fn glyph_index(&self, codepoint: u32) -> Result, FontError> { let cmap = self.cmap()?; @@ -557,4 +641,112 @@ mod tests { assert!(result.is_ok(), ".notdef outline parse should not error"); // .notdef may or may not have an outline — just verify no crash. } + + #[test] + fn kern_table_loads() { + let font = test_font(); + // kern table is optional — just verify parsing doesn't error. + let kern = font.kern(); + assert!(kern.is_ok(), "kern() should not error: {:?}", kern.err()); + } + + #[test] + fn kern_pair_no_crash() { + let font = test_font(); + let gid_a = font.glyph_index(0x0041).unwrap().unwrap_or(0); + let gid_v = font.glyph_index(0x0056).unwrap().unwrap_or(0); + // Just verify no crash; value may be 0 if font has no kern table. + let _val = font.kern_pair(gid_a, gid_v); + } + + #[test] + fn shape_text_basic() { + let font = test_font(); + let shaped = font.shape_text("Hello", 16.0); + + assert_eq!(shaped.len(), 5, "should have 5 glyphs for 'Hello'"); + + // All glyph IDs should be nonzero (font has Latin glyphs). + for (i, g) in shaped.iter().enumerate() { + assert!(g.glyph_id > 0, "glyph {} should have nonzero ID", i); + } + + // Positions should be monotonically increasing. + for i in 1..shaped.len() { + assert!( + shaped[i].x_offset > shaped[i - 1].x_offset, + "glyph {} x_offset ({}) should be > glyph {} x_offset ({})", + i, + shaped[i].x_offset, + i - 1, + shaped[i - 1].x_offset + ); + } + + // First glyph should start at 0. + assert_eq!(shaped[0].x_offset, 0.0, "first glyph should start at x=0"); + + // All advances should be positive. + for (i, g) in shaped.iter().enumerate() { + assert!( + g.x_advance > 0.0, + "glyph {} advance ({}) should be positive", + i, + g.x_advance + ); + } + } + + #[test] + fn shape_text_empty() { + let font = test_font(); + let shaped = font.shape_text("", 16.0); + assert!(shaped.is_empty(), "empty text should produce no glyphs"); + } + + #[test] + fn shape_text_single_char() { + let font = test_font(); + let shaped = font.shape_text("A", 16.0); + assert_eq!(shaped.len(), 1); + assert_eq!(shaped[0].x_offset, 0.0); + assert!(shaped[0].x_advance > 0.0); + } + + #[test] + fn shape_text_space_has_advance() { + let font = test_font(); + let shaped = font.shape_text(" ", 16.0); + assert_eq!(shaped.len(), 1); + // Space should have an advance width (it occupies horizontal space). + assert!( + shaped[0].x_advance > 0.0, + "space should have positive advance: {}", + shaped[0].x_advance + ); + } + + #[test] + fn shape_text_scales_with_size() { + let font = test_font(); + let small = font.shape_text("A", 12.0); + let large = font.shape_text("A", 24.0); + + // At double the size, the advance should be double. + let ratio = large[0].x_advance / small[0].x_advance; + assert!( + (ratio - 2.0).abs() < 0.01, + "advance ratio should be ~2.0, got {}", + ratio + ); + } + + #[test] + fn shape_text_fonts_without_kern_work() { + let font = test_font(); + // Even if the font has no kern table, shaping should work fine. + let shaped = font.shape_text("AV", 16.0); + assert_eq!(shaped.len(), 2); + assert!(shaped[1].x_offset > 0.0); + } } diff --git a/crates/text/src/font/tables/kern.rs b/crates/text/src/font/tables/kern.rs new file mode 100644 index 0000000..0286cf4 --- /dev/null +++ b/crates/text/src/font/tables/kern.rs @@ -0,0 +1,182 @@ +//! `kern` — Kerning table. +//! +//! Contains kerning pair adjustments for glyph spacing. +//! Supports the classic Microsoft kern table (version 0) with format 0 subtables. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// A single kerning pair. +#[derive(Debug, Clone, Copy)] +struct KernPair { + /// Left glyph ID. + left: u16, + /// Right glyph ID. + right: u16, + /// Kerning value in font units (positive = move apart, negative = move together). + value: i16, +} + +/// Parsed `kern` table. +#[derive(Debug)] +pub struct KernTable { + /// Sorted list of kerning pairs (for binary search). + pairs: Vec, +} + +impl KernTable { + /// Create an empty kern table (no pairs). + pub fn empty() -> KernTable { + KernTable { pairs: Vec::new() } + } + + /// Parse the `kern` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 4 { + return Err(FontError::MalformedTable("kern")); + } + + let version = r.u16(0)?; + + match version { + 0 => Self::parse_version0(data), + _ => { + // Version 1 (Apple AAT) or unknown — try parsing as version 0 + // since some fonts mislabel the version. If that fails, return + // an empty table (kerning is optional, not critical). + Ok(KernTable { pairs: Vec::new() }) + } + } + } + + /// Parse a version 0 kern table (Microsoft format). + fn parse_version0(data: &[u8]) -> Result { + let r = Reader::new(data); + let n_tables = r.u16(2)? as usize; + + let mut pairs = Vec::new(); + let mut offset = 4; // Skip version + nTables + + for _ in 0..n_tables { + if offset + 6 > r.len() { + break; + } + + let _subtable_version = r.u16(offset)?; + let subtable_length = r.u16(offset + 2)? as usize; + let coverage = r.u16(offset + 4)?; + + // Coverage field: + // Bit 0: 1 = horizontal kerning + // Bit 1: 1 = minimum values (not kerning values) + // Bit 2: 1 = cross-stream + // Bits 8-15: format number + let is_horizontal = coverage & 0x0001 != 0; + let is_minimum = coverage & 0x0002 != 0; + let is_cross_stream = coverage & 0x0004 != 0; + let format = (coverage >> 8) as u8; + + // We only support horizontal kerning, format 0, non-minimum, non-cross-stream. + if format == 0 && is_horizontal && !is_minimum && !is_cross_stream { + Self::parse_format0(data, offset + 6, &mut pairs)?; + } + + // Advance to next subtable. + if subtable_length == 0 { + break; + } + offset += subtable_length; + } + + // Sort pairs for binary search. + pairs.sort_by(|a, b| a.left.cmp(&b.left).then_with(|| a.right.cmp(&b.right))); + + Ok(KernTable { pairs }) + } + + /// Parse a format 0 subtable (sorted pairs). + fn parse_format0( + data: &[u8], + offset: usize, + pairs: &mut Vec, + ) -> Result<(), FontError> { + let r = Reader::new(data); + if offset + 8 > r.len() { + return Err(FontError::MalformedTable("kern")); + } + + let n_pairs = r.u16(offset)? as usize; + // Skip searchRange(2), entrySelector(2), rangeShift(2) = 6 bytes. + let pair_offset = offset + 8; + + for i in 0..n_pairs { + let base = pair_offset + i * 6; + if base + 6 > r.len() { + break; + } + let left = r.u16(base)?; + let right = r.u16(base + 2)?; + let value = r.i16(base + 4)?; + pairs.push(KernPair { left, right, value }); + } + + Ok(()) + } + + /// Look up the kerning value for a pair of glyph IDs. + /// + /// Returns the kerning adjustment in font units, or 0 if no pair exists. + pub fn kern_value(&self, left: u16, right: u16) -> i16 { + self.pairs + .binary_search_by(|pair| pair.left.cmp(&left).then_with(|| pair.right.cmp(&right))) + .map(|idx| self.pairs[idx].value) + .unwrap_or(0) + } + + /// Returns the number of kerning pairs. + pub fn num_pairs(&self) -> usize { + self.pairs.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_kern_table() { + // Version 0, 0 subtables. + let data = [0u8, 0, 0, 0]; + let kern = KernTable::parse(&data).unwrap(); + assert_eq!(kern.num_pairs(), 0); + assert_eq!(kern.kern_value(1, 2), 0); + } + + #[test] + fn kern_value_lookup() { + // Build a minimal version 0 kern table with format 0 subtable. + let mut data = Vec::new(); + + // Header: version=0, nTables=1 + data.extend_from_slice(&[0, 0, 0, 1]); + + // Subtable header: version=0, length=20, coverage=0x0001 (horizontal, format 0) + data.extend_from_slice(&[0, 0, 0, 20, 0, 1]); + + // Format 0 header: nPairs=1, searchRange=6, entrySelector=0, rangeShift=0 + data.extend_from_slice(&[0, 1, 0, 6, 0, 0, 0, 0]); + + // One pair: left=10, right=20, value=-50 + data.extend_from_slice(&10u16.to_be_bytes()); + data.extend_from_slice(&20u16.to_be_bytes()); + data.extend_from_slice(&(-50i16).to_be_bytes()); + + let kern = KernTable::parse(&data).unwrap(); + assert_eq!(kern.num_pairs(), 1); + assert_eq!(kern.kern_value(10, 20), -50); + assert_eq!(kern.kern_value(10, 21), 0); + assert_eq!(kern.kern_value(11, 20), 0); + } +} diff --git a/crates/text/src/font/tables/mod.rs b/crates/text/src/font/tables/mod.rs index 4d5334c..106be57 100644 --- a/crates/text/src/font/tables/mod.rs +++ b/crates/text/src/font/tables/mod.rs @@ -5,6 +5,7 @@ pub mod glyf; pub mod head; pub mod hhea; pub mod hmtx; +pub mod kern; pub mod loca; pub mod maxp; pub mod name;