From 76661e15cb963ed867df9d6b69662dcda562136e Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Tue, 24 Feb 2026 06:51:28 +0100 Subject: [PATCH] Implement glyf and OS/2 table parsing for glyph outline extraction Parse TrueType glyph outlines from the glyf table: - Simple glyphs: contour endpoints, flags, delta-encoded coordinates - Compound/composite glyphs: recursive component flattening with translation, scale, and 2x2 matrix transforms - Public API: Font::glyph_outline(glyph_id) -> Option Parse OS/2 table for font-wide metrics: - Typographic ascender/descender/line gap - Weight class, width class, embedding flags - Strikeout size/position, sub/superscript offsets - sxHeight, sCapHeight (version >= 2) Data structures: Point (x, y, on_curve), Contour, GlyphOutline 7 new tests against real system fonts, all passing. Co-Authored-By: Claude Opus 4.6 --- crates/text/src/font/mod.rs | 177 ++++++++++++ crates/text/src/font/tables/glyf.rs | 407 ++++++++++++++++++++++++++++ crates/text/src/font/tables/mod.rs | 2 + crates/text/src/font/tables/os2.rs | 97 +++++++ 4 files changed, 683 insertions(+) create mode 100644 crates/text/src/font/tables/glyf.rs create mode 100644 crates/text/src/font/tables/os2.rs diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index e2001e3..2cd2869 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -9,12 +9,14 @@ mod parse; mod tables; pub use tables::cmap::CmapTable; +pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::head::HeadTable; pub use tables::hhea::HheaTable; pub use tables::hmtx::HmtxTable; pub use tables::loca::LocaTable; pub use tables::maxp::MaxpTable; pub use tables::name::NameTable; +pub use tables::os2::Os2Table; /// Errors that can occur during font parsing. #[derive(Debug)] @@ -195,12 +197,32 @@ impl Font { LocaTable::parse(data, head.index_to_loc_format, maxp.num_glyphs) } + /// Parse the `OS/2` table. + pub fn os2(&self) -> Result { + let data = self + .table_data(b"OS/2") + .ok_or(FontError::MissingTable("OS/2"))?; + Os2Table::parse(data) + } + /// Map a Unicode code point to a glyph index using the cmap table. pub fn glyph_index(&self, codepoint: u32) -> Result, FontError> { let cmap = self.cmap()?; Ok(cmap.glyph_index(codepoint)) } + /// Extract the outline for a glyph by its glyph ID. + /// + /// Returns `None` for glyphs with no outline (e.g., space). + /// Requires the `glyf` and `loca` tables. + pub fn glyph_outline(&self, glyph_id: u16) -> Result, FontError> { + let loca = self.loca()?; + let glyf_data = self + .table_data(b"glyf") + .ok_or(FontError::MissingTable("glyf"))?; + tables::glyf::parse_glyph(glyph_id, glyf_data, &loca) + } + /// 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 @@ -370,4 +392,159 @@ mod tests { assert!(!font.tables.is_empty()); } } + + #[test] + fn parse_os2_table() { + let font = test_font(); + let os2 = font.os2().expect("failed to parse OS/2"); + // Weight class should be in valid range (100–900). + assert!( + os2.us_weight_class >= 100 && os2.us_weight_class <= 900, + "weight class out of range: {}", + os2.us_weight_class + ); + // Width class should be 1–9. + assert!( + os2.us_width_class >= 1 && os2.us_width_class <= 9, + "width class out of range: {}", + os2.us_width_class + ); + // Typo ascender should be positive for normal fonts. + assert!( + os2.s_typo_ascender > 0, + "sTypoAscender should be positive: {}", + os2.s_typo_ascender + ); + // Typo descender should be negative or zero. + assert!( + os2.s_typo_descender <= 0, + "sTypoDescender should be <= 0: {}", + os2.s_typo_descender + ); + } + + #[test] + fn parse_os2_version_2_fields() { + let font = test_font(); + let os2 = font.os2().expect("failed to parse OS/2"); + if os2.version >= 2 { + // sxHeight and sCapHeight should be non-negative for version >= 2. + assert!( + os2.sx_height >= 0, + "sxHeight should be >= 0: {}", + os2.sx_height + ); + assert!( + os2.s_cap_height >= 0, + "sCapHeight should be >= 0: {}", + os2.s_cap_height + ); + } + } + + #[test] + fn glyph_outline_simple() { + let font = test_font(); + // Get glyph ID for 'A'. + let gid = font + .glyph_index(0x0041) + .expect("glyph_index failed") + .expect("no glyph for 'A'"); + let outline = font + .glyph_outline(gid) + .expect("glyph_outline failed") + .expect("'A' should have an outline"); + + // 'A' should have at least one contour. + assert!( + !outline.contours.is_empty(), + "'A' should have at least 1 contour" + ); + // Bounding box should be valid. + assert!( + outline.x_max >= outline.x_min, + "x_max ({}) should be >= x_min ({})", + outline.x_max, + outline.x_min + ); + assert!( + outline.y_max >= outline.y_min, + "y_max ({}) should be >= y_min ({})", + outline.y_max, + outline.y_min + ); + // Each contour should have points. + for contour in &outline.contours { + assert!( + !contour.points.is_empty(), + "contour should have at least one point" + ); + } + } + + #[test] + fn glyph_outline_space_has_no_outline() { + let font = test_font(); + // Space should map to a glyph but have no outline. + let gid = font + .glyph_index(0x0020) + .expect("glyph_index failed") + .expect("no glyph for space"); + let outline = font.glyph_outline(gid).expect("glyph_outline failed"); + assert!( + outline.is_none(), + "space should have no outline (got {:?})", + outline + ); + } + + #[test] + fn glyph_outline_multiple_glyphs() { + let font = test_font(); + // Parse outlines for several ASCII characters. + for &cp in &[0x0042u32, 0x0043, 0x004F, 0x0053] { + // B, C, O, S + let gid = font.glyph_index(cp).expect("glyph_index failed"); + if let Some(gid) = gid { + let result = font.glyph_outline(gid); + assert!( + result.is_ok(), + "failed to parse outline for U+{:04X}: {:?}", + cp, + result.err() + ); + } + } + } + + #[test] + fn glyph_outline_has_on_curve_points() { + let font = test_font(); + let gid = font + .glyph_index(0x0041) + .expect("glyph_index failed") + .expect("no glyph for 'A'"); + let outline = font + .glyph_outline(gid) + .expect("glyph_outline failed") + .expect("'A' should have an outline"); + + // Every contour should have at least some on-curve points. + for contour in &outline.contours { + let on_curve_count = contour.points.iter().filter(|p| p.on_curve).count(); + assert!( + on_curve_count > 0, + "contour should have at least one on-curve point" + ); + } + } + + #[test] + fn glyph_outline_notdef() { + let font = test_font(); + // Glyph 0 (.notdef) usually has an outline (a rectangle or empty box). + let result = font.glyph_outline(0); + assert!(result.is_ok(), ".notdef outline parse should not error"); + // .notdef may or may not have an outline — just verify no crash. + } } diff --git a/crates/text/src/font/tables/glyf.rs b/crates/text/src/font/tables/glyf.rs new file mode 100644 index 0000000..3c0df71 --- /dev/null +++ b/crates/text/src/font/tables/glyf.rs @@ -0,0 +1,407 @@ +//! `glyf` — Glyph Data table. +//! +//! Parses TrueType glyph outlines: simple glyphs (quadratic Bézier contours) +//! and compound/composite glyphs (assembled from other glyphs). +//! Reference: + +use crate::font::parse::Reader; +use crate::font::tables::loca::LocaTable; +use crate::font::FontError; + +/// A point on a glyph outline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Point { + /// X coordinate in font design units. + pub x: i16, + /// Y coordinate in font design units. + pub y: i16, + /// Whether this point is on the curve (vs. an off-curve control point). + pub on_curve: bool, +} + +/// A single contour of a glyph outline (a closed path of points). +#[derive(Debug, Clone)] +pub struct Contour { + pub points: Vec, +} + +/// A complete glyph outline, consisting of one or more contours. +#[derive(Debug, Clone)] +pub struct GlyphOutline { + /// 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, + /// The contours that make up this glyph. + pub contours: Vec, +} + +// Simple glyph flag bits. +const ON_CURVE_POINT: u8 = 0x01; +const X_SHORT_VECTOR: u8 = 0x02; +const Y_SHORT_VECTOR: u8 = 0x04; +const REPEAT_FLAG: u8 = 0x08; +const X_IS_SAME_OR_POSITIVE: u8 = 0x10; +const Y_IS_SAME_OR_POSITIVE: u8 = 0x20; + +// Compound glyph flag bits. +const ARG_1_AND_2_ARE_WORDS: u16 = 0x0001; +const ARGS_ARE_XY_VALUES: u16 = 0x0002; +const WE_HAVE_A_SCALE: u16 = 0x0008; +const MORE_COMPONENTS: u16 = 0x0020; +const WE_HAVE_AN_X_AND_Y_SCALE: u16 = 0x0040; +const WE_HAVE_A_TWO_BY_TWO: u16 = 0x0080; + +/// Glyph header fields parsed from the first 10 bytes. +struct GlyphHeader { + x_min: i16, + y_min: i16, + x_max: i16, + y_max: i16, +} + +/// Parse a simple glyph from raw bytes (starting after the glyph header). +/// +/// `number_of_contours` must be > 0. +fn parse_simple_glyph( + data: &[u8], + number_of_contours: i16, + header: &GlyphHeader, +) -> Result { + let r = Reader::new(data); + let n_contours = number_of_contours as usize; + + // Read endPtsOfContours array. + let mut end_pts = Vec::with_capacity(n_contours); + for i in 0..n_contours { + end_pts.push(r.u16(i * 2)? as usize); + } + + let total_points = match end_pts.last() { + Some(&last) => last + 1, + None => return Err(FontError::MalformedTable("glyf: no contour endpoints")), + }; + + // Skip instructions. + let instructions_offset = n_contours * 2; + let instruction_length = r.u16(instructions_offset)? as usize; + let flags_offset = instructions_offset + 2 + instruction_length; + + // Read flags (with REPEAT_FLAG expansion). + let mut flags = Vec::with_capacity(total_points); + let mut offset = flags_offset; + while flags.len() < total_points { + if offset >= data.len() { + return Err(FontError::MalformedTable("glyf: flags truncated")); + } + let flag = data[offset]; + offset += 1; + flags.push(flag); + + if flag & REPEAT_FLAG != 0 { + if offset >= data.len() { + return Err(FontError::MalformedTable("glyf: repeat count truncated")); + } + let repeat_count = data[offset] as usize; + offset += 1; + for _ in 0..repeat_count { + flags.push(flag); + } + } + } + + // Read X coordinates (delta-encoded). + let mut x_coords = Vec::with_capacity(total_points); + let mut x: i16 = 0; + for &flag in &flags[..total_points] { + if flag & X_SHORT_VECTOR != 0 { + if offset >= data.len() { + return Err(FontError::MalformedTable("glyf: x coordinates truncated")); + } + let dx = data[offset] as i16; + offset += 1; + if flag & X_IS_SAME_OR_POSITIVE != 0 { + x += dx; + } else { + x -= dx; + } + } else if flag & X_IS_SAME_OR_POSITIVE != 0 { + // x is the same as previous (delta = 0). + } else { + if offset + 1 >= data.len() { + return Err(FontError::MalformedTable("glyf: x coordinates truncated")); + } + let dx = i16::from_be_bytes([data[offset], data[offset + 1]]); + offset += 2; + x += dx; + } + x_coords.push(x); + } + + // Read Y coordinates (delta-encoded). + let mut y_coords = Vec::with_capacity(total_points); + let mut y: i16 = 0; + for &flag in &flags[..total_points] { + if flag & Y_SHORT_VECTOR != 0 { + if offset >= data.len() { + return Err(FontError::MalformedTable("glyf: y coordinates truncated")); + } + let dy = data[offset] as i16; + offset += 1; + if flag & Y_IS_SAME_OR_POSITIVE != 0 { + y += dy; + } else { + y -= dy; + } + } else if flag & Y_IS_SAME_OR_POSITIVE != 0 { + // y is the same as previous (delta = 0). + } else { + if offset + 1 >= data.len() { + return Err(FontError::MalformedTable("glyf: y coordinates truncated")); + } + let dy = i16::from_be_bytes([data[offset], data[offset + 1]]); + offset += 2; + y += dy; + } + y_coords.push(y); + } + + // Build contours from endpoints. + let mut contours = Vec::with_capacity(n_contours); + let mut start = 0; + for &end in &end_pts { + let mut points = Vec::with_capacity(end - start + 1); + for i in start..=end { + points.push(Point { + x: x_coords[i], + y: y_coords[i], + on_curve: flags[i] & ON_CURVE_POINT != 0, + }); + } + contours.push(Contour { points }); + start = end + 1; + } + + Ok(GlyphOutline { + x_min: header.x_min, + y_min: header.y_min, + x_max: header.x_max, + y_max: header.y_max, + contours, + }) +} + +/// Parse a compound/composite glyph, flattening component glyphs into a +/// single outline. +/// +/// `glyf_data` is the entire glyf table; `loca` provides offsets for +/// sub-glyph lookups. +fn parse_compound_glyph( + data: &[u8], + header: &GlyphHeader, + glyf_data: &[u8], + loca: &LocaTable, + depth: u8, +) -> Result { + // Guard against infinite recursion from malformed fonts. + if depth > 16 { + return Err(FontError::MalformedTable( + "glyf: compound glyph recursion too deep", + )); + } + + let mut contours = Vec::new(); + let mut offset = 0; + + loop { + if offset + 4 > data.len() { + return Err(FontError::MalformedTable( + "glyf: compound component truncated", + )); + } + let flags = u16::from_be_bytes([data[offset], data[offset + 1]]); + let glyph_index = u16::from_be_bytes([data[offset + 2], data[offset + 3]]); + offset += 4; + + // Read translation arguments. + let (arg1, arg2): (i16, i16); + if flags & ARG_1_AND_2_ARE_WORDS != 0 { + if offset + 4 > data.len() { + return Err(FontError::MalformedTable("glyf: compound args truncated")); + } + arg1 = i16::from_be_bytes([data[offset], data[offset + 1]]); + arg2 = i16::from_be_bytes([data[offset + 2], data[offset + 3]]); + offset += 4; + } else { + if offset + 2 > data.len() { + return Err(FontError::MalformedTable("glyf: compound args truncated")); + } + if flags & ARGS_ARE_XY_VALUES != 0 { + arg1 = data[offset] as i8 as i16; + arg2 = data[offset + 1] as i8 as i16; + } else { + arg1 = data[offset] as i16; + arg2 = data[offset + 1] as i16; + } + offset += 2; + } + + // Read optional scale/transform. + let (scale_x, scale_01, scale_10, scale_y): (f32, f32, f32, f32); + if flags & WE_HAVE_A_SCALE != 0 { + if offset + 2 > data.len() { + return Err(FontError::MalformedTable("glyf: compound scale truncated")); + } + let s = f2dot14(data[offset], data[offset + 1]); + scale_x = s; + scale_y = s; + scale_01 = 0.0; + scale_10 = 0.0; + offset += 2; + } else if flags & WE_HAVE_AN_X_AND_Y_SCALE != 0 { + if offset + 4 > data.len() { + return Err(FontError::MalformedTable( + "glyf: compound xy-scale truncated", + )); + } + scale_x = f2dot14(data[offset], data[offset + 1]); + scale_y = f2dot14(data[offset + 2], data[offset + 3]); + scale_01 = 0.0; + scale_10 = 0.0; + offset += 4; + } else if flags & WE_HAVE_A_TWO_BY_TWO != 0 { + if offset + 8 > data.len() { + return Err(FontError::MalformedTable( + "glyf: compound 2x2 matrix truncated", + )); + } + scale_x = f2dot14(data[offset], data[offset + 1]); + scale_01 = f2dot14(data[offset + 2], data[offset + 3]); + scale_10 = f2dot14(data[offset + 4], data[offset + 5]); + scale_y = f2dot14(data[offset + 6], data[offset + 7]); + offset += 8; + } else { + scale_x = 1.0; + scale_y = 1.0; + scale_01 = 0.0; + scale_10 = 0.0; + } + + let (dx, dy) = if flags & ARGS_ARE_XY_VALUES != 0 { + (arg1, arg2) + } else { + // Point matching — treat as (0,0) offset (rare, complex to implement fully). + (0i16, 0i16) + }; + + // Recursively parse the component glyph. + if let Some(component_outline) = parse_glyph_inner(glyph_index, glyf_data, loca, depth + 1)? + { + let has_transform = + scale_x != 1.0 || scale_y != 1.0 || scale_01 != 0.0 || scale_10 != 0.0; + + for contour in &component_outline.contours { + let points = contour + .points + .iter() + .map(|p| { + let (px, py) = if has_transform { + let fx = p.x as f32; + let fy = p.y as f32; + let tx = fx * scale_x + fy * scale_10; + let ty = fx * scale_01 + fy * scale_y; + (tx.round() as i16, ty.round() as i16) + } else { + (p.x, p.y) + }; + Point { + x: px.saturating_add(dx), + y: py.saturating_add(dy), + on_curve: p.on_curve, + } + }) + .collect(); + contours.push(Contour { points }); + } + } + + if flags & MORE_COMPONENTS == 0 { + break; + } + } + + Ok(GlyphOutline { + x_min: header.x_min, + y_min: header.y_min, + x_max: header.x_max, + y_max: header.y_max, + contours, + }) +} + +/// Decode a 2.14 fixed-point number to f32. +fn f2dot14(hi: u8, lo: u8) -> f32 { + let raw = i16::from_be_bytes([hi, lo]); + raw as f32 / 16384.0 +} + +/// Internal glyph parser that handles both simple and compound glyphs. +/// +/// Returns `None` for glyphs with no outline data (e.g., space). +fn parse_glyph_inner( + glyph_id: u16, + glyf_data: &[u8], + loca: &LocaTable, + depth: u8, +) -> Result, FontError> { + let (start, end) = match loca.glyph_range(glyph_id) { + Some(range) => range, + None => return Ok(None), // No outline (e.g., space). + }; + + let start = start as usize; + let end = end as usize; + if end > glyf_data.len() || start >= end { + return Ok(None); + } + + let glyph_bytes = &glyf_data[start..end]; + if glyph_bytes.len() < 10 { + return Err(FontError::MalformedTable("glyf: glyph header too short")); + } + + let r = Reader::new(glyph_bytes); + let number_of_contours = r.i16(0)?; + let header = GlyphHeader { + x_min: r.i16(2)?, + y_min: r.i16(4)?, + x_max: r.i16(6)?, + y_max: r.i16(8)?, + }; + + if number_of_contours >= 0 { + // Simple glyph. + let outline = parse_simple_glyph(&glyph_bytes[10..], number_of_contours, &header)?; + Ok(Some(outline)) + } else { + // Compound glyph (number_of_contours == -1). + let outline = parse_compound_glyph(&glyph_bytes[10..], &header, glyf_data, loca, depth)?; + Ok(Some(outline)) + } +} + +/// Parse a glyph outline from the `glyf` table. +/// +/// Returns `None` for glyphs with no outline data (e.g., space character). +/// Returns `Err` for malformed data. +pub fn parse_glyph( + glyph_id: u16, + glyf_data: &[u8], + loca: &LocaTable, +) -> Result, FontError> { + parse_glyph_inner(glyph_id, glyf_data, loca, 0) +} diff --git a/crates/text/src/font/tables/mod.rs b/crates/text/src/font/tables/mod.rs index 8a74647..4d5334c 100644 --- a/crates/text/src/font/tables/mod.rs +++ b/crates/text/src/font/tables/mod.rs @@ -1,9 +1,11 @@ //! Individual font table parsers. pub mod cmap; +pub mod glyf; pub mod head; pub mod hhea; pub mod hmtx; pub mod loca; pub mod maxp; pub mod name; +pub mod os2; diff --git a/crates/text/src/font/tables/os2.rs b/crates/text/src/font/tables/os2.rs new file mode 100644 index 0000000..ce0125f --- /dev/null +++ b/crates/text/src/font/tables/os2.rs @@ -0,0 +1,97 @@ +//! `OS/2` — OS/2 and Windows Metrics table. +//! +//! Contains font-wide metrics used for line spacing, weight classification, +//! and typographic alignment. +//! Reference: + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed `OS/2` table. +#[derive(Debug)] +pub struct Os2Table { + /// Table version (0–5). + pub version: u16, + /// Average weighted advance width of lowercase letters and space. + pub x_avg_char_width: i16, + /// Visual weight class (100–900). + pub us_weight_class: u16, + /// Visual width class (1–9). + pub us_width_class: u16, + /// Font embedding licensing rights. + pub fs_type: u16, + /// Typographic ascender. + pub s_typo_ascender: i16, + /// Typographic descender (typically negative). + pub s_typo_descender: i16, + /// Typographic line gap. + pub s_typo_line_gap: i16, + /// Strikeout stroke size in font design units. + pub y_strikeout_size: i16, + /// Strikeout stroke position above baseline. + pub y_strikeout_position: i16, + /// Subscript vertical offset. + pub y_subscript_y_offset: i16, + /// Superscript vertical offset. + pub y_superscript_y_offset: i16, + /// Height of lowercase 'x' (version >= 2, else 0). + pub sx_height: i16, + /// Height of uppercase letters (version >= 2, else 0). + pub s_cap_height: i16, +} + +impl Os2Table { + /// Parse the `OS/2` table from raw bytes. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + + // Minimum OS/2 table is 78 bytes (version 0). + if r.len() < 78 { + return Err(FontError::MalformedTable("OS/2: too short")); + } + + let version = r.u16(0)?; + let x_avg_char_width = r.i16(2)?; + let us_weight_class = r.u16(4)?; + let us_width_class = r.u16(6)?; + let fs_type = r.u16(8)?; + + // Subscript/superscript offsets: offsets 14 and 18 respectively + // ySubscriptXSize(10), ySubscriptYSize(12), ySubscriptXOffset(14) + let y_subscript_y_offset = r.i16(16)?; + // ySuperscriptXSize(18), ySuperscriptYSize(20), ySuperscriptXOffset(22) + let y_superscript_y_offset = r.i16(24)?; + + let y_strikeout_size = r.i16(26)?; + let y_strikeout_position = r.i16(28)?; + + // sTypoAscender is at offset 68, sTypoDescender at 70, sTypoLineGap at 72 + let s_typo_ascender = r.i16(68)?; + let s_typo_descender = r.i16(70)?; + let s_typo_line_gap = r.i16(72)?; + + // sxHeight and sCapHeight are available in version >= 2 at offsets 86 and 88. + let (sx_height, s_cap_height) = if version >= 2 && r.len() >= 96 { + (r.i16(86)?, r.i16(88)?) + } else { + (0, 0) + }; + + Ok(Os2Table { + version, + x_avg_char_width, + us_weight_class, + us_width_class, + fs_type, + s_typo_ascender, + s_typo_descender, + s_typo_line_gap, + y_strikeout_size, + y_strikeout_position, + y_subscript_y_offset, + y_superscript_y_offset, + sx_height, + s_cap_height, + }) + } +} -- 2.51.2