diff --git a/.isu/issues.json b/.isu/issues.json index ffbbf70..6d3fc30 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 409, + "next_id": 410, "issues": [ { "id": 1, @@ -5006,6 +5006,20 @@ "author": "piefev", "state": "open", "created_at": "2026-07-18T10:37:20Z" + }, + { + "id": 409, + "repo": "we", + "title": "Opera parity remains blocked after GSUB ligature shaping", + "body": "Parent: isu issue 281.\n\nThis pass added default OpenType GSUB ligature shaping for `liga`, `clig`, and `rlig` features, including lookup type 4 LigatureSubst and type 7 extension lookups. Focused `we-text` tests now cover parsing and shaping a synthetic ligature.\n\nFocused repro:\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\n\nCurrent result after GSUB ligature shaping:\n- desktop L96: 85.17% match (182150/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile L111: 69.85% match (99257/329160 px differ, tol=4, max_diff=0.1000%)\n- DOM and interactivity assertions pass; console captures are empty except expected offline GTM fetch misses.\n\nRelevant artifacts:\n- crates/e2e/artifacts/real-web/opera.com/desktop.png\n- crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt\n- crates/e2e/artifacts/real-web/opera.com/mobile.png\n- crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt\n\nRemaining blockers are unchanged from the latest Opera follow-ups: first-viewport WebP/image parity, text rasterization and antialiasing fidelity, and cookie-panel shadow/radius/text fidelity. Keep crates/e2e/scenarios/real-web/opera.com.we xfailed until both Chromium screenshot assertions pass within the committed threshold.", + "labels": [ + "real-web", + "text" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T10:48:35Z" } ] } diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index 7ae690d..1124f8e 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -20,6 +20,7 @@ pub use registry::{FontEntry, FontRegistry, WebFontEntry, WebFontState}; pub use tables::cmap::CmapTable; pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::gpos::GposTable; +pub use tables::gsub::GsubTable; pub use tables::head::HeadTable; pub use tables::hhea::HheaTable; pub use tables::hmtx::HmtxTable; @@ -310,6 +311,16 @@ impl Font { } } + /// Parse the `GSUB` table, if present. + /// + /// Returns an empty `GsubTable` if the font has no GSUB table. + pub fn gsub(&self) -> Result { + match self.table_data(b"GSUB") { + Some(data) => GsubTable::parse(data), + None => Ok(GsubTable::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. @@ -375,6 +386,7 @@ impl Font { resolved.push(hit.unwrap_or((0, 0))); } + let resolved = apply_default_ligatures(&resolved, &fonts); let mut result = Vec::with_capacity(resolved.len()); let mut cursor_x: f32 = 0.0; let mut cursor_y: f32 = 0.0; @@ -589,6 +601,7 @@ struct ShapeFont { hmtx: HmtxTable, kern_table: KernTable, gpos_table: GposTable, + gsub_table: GsubTable, } impl ShapeFont { @@ -605,6 +618,7 @@ impl ShapeFont { hmtx: font.hmtx().ok()?, kern_table: font.kern().unwrap_or_else(|_| KernTable::empty()), gpos_table: font.gpos().unwrap_or_else(|_| GposTable::empty()), + gsub_table: font.gsub().unwrap_or_else(|_| GsubTable::empty()), }) } @@ -618,6 +632,37 @@ impl ShapeFont { } } +fn apply_default_ligatures(resolved: &[(usize, u16)], fonts: &[ShapeFont]) -> Vec<(usize, u16)> { + let mut output = Vec::with_capacity(resolved.len()); + let mut i = 0; + + while i < resolved.len() { + let (font_idx, glyph_id) = resolved[i]; + let mut run = Vec::new(); + let mut j = i; + while let Some(&(next_font_idx, next_glyph_id)) = resolved.get(j) { + if next_font_idx != font_idx { + break; + } + run.push(next_glyph_id); + j += 1; + } + + if let Some(font) = fonts.get(font_idx) { + if let Some((ligature_glyph, consumed)) = font.gsub_table.ligature_match(&run, 0) { + output.push((font_idx, ligature_glyph)); + i += consumed; + continue; + } + } + + output.push((font_idx, glyph_id)); + i += 1; + } + + output +} + thread_local! { /// Lazily-built system fallback chain, shared across all shaping on this /// thread. Built on first use; `None` until then. @@ -727,6 +772,14 @@ mod tests { } fn synthetic_font_with_tables(kern: Option>, gpos: Option>) -> Font { + synthetic_font_with_optional_tables(kern, gpos, None) + } + + fn synthetic_font_with_optional_tables( + kern: Option>, + gpos: Option>, + gsub: Option>, + ) -> Font { let mut head = vec![0; 54]; write_u16(&mut head, 0, 1); write_u16(&mut head, 18, 1000); @@ -735,16 +788,16 @@ mod tests { let mut maxp = Vec::new(); push_u32(&mut maxp, 0x00010000); - push_u16(&mut maxp, 3); + push_u16(&mut maxp, 4); let mut hhea = vec![0; 36]; write_i16(&mut hhea, 4, 800); write_i16(&mut hhea, 6, -200); - write_u16(&mut hhea, 10, 700); - write_u16(&mut hhea, 34, 3); + write_u16(&mut hhea, 10, 800); + write_u16(&mut hhea, 34, 4); let mut hmtx = Vec::new(); - for advance in [500, 600, 700] { + for advance in [500, 600, 700, 800] { push_u16(&mut hmtx, advance); push_i16(&mut hmtx, 0); } @@ -777,6 +830,9 @@ mod tests { if let Some(gpos) = gpos { tables.push((*b"GPOS", gpos)); } + if let Some(gsub) = gsub { + tables.push((*b"GSUB", gsub)); + } let num_tables = tables.len(); let directory_len = 12 + num_tables * 16; @@ -876,6 +932,51 @@ mod tests { data } + fn gsub_ligature_subst_format1() -> Vec { + let feature_list_offset = 10usize; + let lookup_list_offset = 24usize; + let lookup_offset = 28usize; + let ligature_subst_offset = 36usize; + + let mut data = Vec::new(); + push_u16(&mut data, 1); // GSUB major + push_u16(&mut data, 0); // GSUB minor + push_u16(&mut data, 0); // scriptListOffset + push_u16(&mut data, feature_list_offset as u16); + push_u16(&mut data, lookup_list_offset as u16); + + push_u16(&mut data, 1); // featureCount + data.extend_from_slice(b"liga"); + push_u16(&mut data, 8); // feature table offset + push_u16(&mut data, 0); // featureParams + push_u16(&mut data, 1); // lookupIndexCount + push_u16(&mut data, 0); // lookup index 0 + + push_u16(&mut data, 1); // lookupCount + push_u16(&mut data, (lookup_offset - lookup_list_offset) as u16); + + push_u16(&mut data, 4); // lookupType LigatureSubst + push_u16(&mut data, 0); // lookupFlag + push_u16(&mut data, 1); // subTableCount + push_u16(&mut data, (ligature_subst_offset - lookup_offset) as u16); + + push_u16(&mut data, 1); // LigatureSubst format 1 + push_u16(&mut data, 8); // coverageOffset + push_u16(&mut data, 1); // ligatureSetCount + push_u16(&mut data, 14); // ligatureSetOffset[0] + + push_u16(&mut data, 1); // coverage format 1 + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // first glyph for 'A' + + push_u16(&mut data, 1); // ligatureCount + push_u16(&mut data, 4); // ligatureOffset[0] + push_u16(&mut data, 3); // ligatureGlyph + push_u16(&mut data, 2); // componentCount: first + one component + push_u16(&mut data, 2); // second glyph for 'B' + data + } + #[test] fn parse_table_directory() { let font = test_font(); @@ -1368,6 +1469,18 @@ mod tests { assert_eq!(shaped[1].y_offset, 0.0); } + #[test] + fn shape_text_applies_default_gsub_ligatures() { + let font = + synthetic_font_with_optional_tables(None, None, Some(gsub_ligature_subst_format1())); + let shaped = font.shape_text("AB", 10.0); + + assert_eq!(shaped.len(), 1); + assert_eq!(shaped[0].glyph_id, 3); + assert_eq!(shaped[0].x_offset, 0.0); + assert_eq!(shaped[0].x_advance, 8.0); + } + #[test] fn shape_text_applies_cross_stream_kern_pairs() { let font = synthetic_font(Some(kern_format0(0x0005, 100))); diff --git a/crates/text/src/font/tables/gsub.rs b/crates/text/src/font/tables/gsub.rs new file mode 100644 index 0000000..22d915b --- /dev/null +++ b/crates/text/src/font/tables/gsub.rs @@ -0,0 +1,510 @@ +//! `GSUB` — OpenType glyph substitution. +//! +//! This parser implements the default ligature subset used by web fonts: +//! lookup type 4 Ligature Substitution, plus type 7 extension lookups that wrap +//! type 4. It selects common default ligature features (`liga`, `clig`, +//! `rlig`) and leaves discretionary features disabled. + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed GSUB ligature-substitution data. +#[derive(Debug)] +pub struct GsubTable { + subtables: Vec, +} + +#[derive(Debug)] +struct LigatureSubtable { + coverage: Coverage, + ligature_sets: Vec>, +} + +#[derive(Debug)] +struct Ligature { + glyph: u16, + components: Vec, +} + +#[derive(Debug)] +enum Coverage { + Format1(Vec), + Format2(Vec), +} + +#[derive(Debug)] +struct CoverageRange { + start: u16, + end: u16, + start_index: u16, +} + +impl GsubTable { + /// Create an empty GSUB table. + pub fn empty() -> GsubTable { + GsubTable { + subtables: Vec::new(), + } + } + + /// Parse a GSUB table. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 10 { + return Err(FontError::MalformedTable("GSUB")); + } + + let major = r.u16(0)?; + if major != 1 { + return Ok(GsubTable::empty()); + } + + let feature_list_offset = r.u16(6)? as usize; + let lookup_list_offset = r.u16(8)? as usize; + let selected = parse_ligature_feature_lookup_indices(data, feature_list_offset) + .unwrap_or_else(|_| Vec::new()); + if selected.is_empty() { + return Ok(GsubTable::empty()); + } + + let mut subtables = Vec::new(); + parse_lookup_list(data, lookup_list_offset, &selected, &mut subtables)?; + + Ok(GsubTable { subtables }) + } + + /// Find the longest ligature match starting at `start`. + /// + /// `glyphs` is a same-font glyph-id run. On match, returns the replacement + /// glyph id and the number of input glyphs consumed. + pub(crate) fn ligature_match(&self, glyphs: &[u16], start: usize) -> Option<(u16, usize)> { + let first = *glyphs.get(start)?; + self.subtables.iter().find_map(|subtable| { + subtable.ligature_match(first, glyphs.get(start + 1..).unwrap_or(&[])) + }) + } + + /// Number of parsed ligature-substitution subtables. + pub fn num_subtables(&self) -> usize { + self.subtables.len() + } +} + +fn parse_ligature_feature_lookup_indices( + data: &[u8], + feature_list_offset: usize, +) -> Result, FontError> { + if feature_list_offset == 0 { + return Ok(Vec::new()); + } + + let r = Reader::new(data); + if feature_list_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let feature_count = r.u16(feature_list_offset)? as usize; + let mut indices = Vec::new(); + + for i in 0..feature_count { + let record = feature_list_offset + 2 + i * 6; + if record + 6 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let tag = r.tag(record)?; + if &tag != b"liga" && &tag != b"clig" && &tag != b"rlig" { + continue; + } + + let feature_offset = checked_offset(feature_list_offset, r.u16(record + 4)? as usize)?; + if feature_offset + 4 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let lookup_count = r.u16(feature_offset + 2)? as usize; + for j in 0..lookup_count { + let offset = feature_offset + 4 + j * 2; + if offset + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + indices.push(r.u16(offset)? as usize); + } + } + + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} + +fn parse_lookup_list( + data: &[u8], + lookup_list_offset: usize, + selected: &[usize], + subtables: &mut Vec, +) -> Result<(), FontError> { + if lookup_list_offset == 0 { + return Ok(()); + } + + let r = Reader::new(data); + if lookup_list_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let lookup_count = r.u16(lookup_list_offset)? as usize; + for i in 0..lookup_count { + if !selected.is_empty() && selected.binary_search(&i).is_err() { + continue; + } + + let offset_pos = lookup_list_offset + 2 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let lookup_offset = checked_offset(lookup_list_offset, r.u16(offset_pos)? as usize)?; + parse_lookup(data, lookup_offset, subtables)?; + } + + Ok(()) +} + +fn parse_lookup( + data: &[u8], + lookup_offset: usize, + subtables: &mut Vec, +) -> Result<(), FontError> { + let r = Reader::new(data); + if lookup_offset + 6 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let lookup_type = r.u16(lookup_offset)?; + let subtable_count = r.u16(lookup_offset + 4)? as usize; + for i in 0..subtable_count { + let offset_pos = lookup_offset + 6 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let subtable_offset = checked_offset(lookup_offset, r.u16(offset_pos)? as usize)?; + match lookup_type { + 4 => { + if let Some(subtable) = parse_ligature_subst(data, subtable_offset)? { + subtables.push(subtable); + } + } + 7 => parse_extension_lookup(data, subtable_offset, subtables)?, + _ => {} + } + } + + Ok(()) +} + +fn parse_extension_lookup( + data: &[u8], + subtable_offset: usize, + subtables: &mut Vec, +) -> Result<(), FontError> { + let r = Reader::new(data); + if subtable_offset + 8 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let format = r.u16(subtable_offset)?; + let extension_lookup_type = r.u16(subtable_offset + 2)?; + if format != 1 || extension_lookup_type != 4 { + return Ok(()); + } + + let extension_offset = r.u32(subtable_offset + 4)? as usize; + let ligature_offset = checked_offset(subtable_offset, extension_offset)?; + if let Some(subtable) = parse_ligature_subst(data, ligature_offset)? { + subtables.push(subtable); + } + + Ok(()) +} + +fn parse_ligature_subst( + data: &[u8], + subtable_offset: usize, +) -> Result, FontError> { + let r = Reader::new(data); + if subtable_offset + 6 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let format = r.u16(subtable_offset)?; + if format != 1 { + return Ok(None); + } + + let coverage_offset = checked_offset(subtable_offset, r.u16(subtable_offset + 2)? as usize)?; + let ligature_set_count = r.u16(subtable_offset + 4)? as usize; + let coverage = Coverage::parse(data, coverage_offset)?; + + let mut ligature_sets = Vec::with_capacity(ligature_set_count); + for i in 0..ligature_set_count { + let offset_pos = subtable_offset + 6 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + let set_offset = checked_offset(subtable_offset, r.u16(offset_pos)? as usize)?; + let mut set = parse_ligature_set(data, set_offset)?; + set.sort_by_key(|ligature| std::cmp::Reverse(ligature.components.len())); + ligature_sets.push(set); + } + + Ok(Some(LigatureSubtable { + coverage, + ligature_sets, + })) +} + +fn parse_ligature_set(data: &[u8], set_offset: usize) -> Result, FontError> { + let r = Reader::new(data); + if set_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let ligature_count = r.u16(set_offset)? as usize; + let mut ligatures = Vec::with_capacity(ligature_count); + for i in 0..ligature_count { + let offset_pos = set_offset + 2 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let ligature_offset = checked_offset(set_offset, r.u16(offset_pos)? as usize)?; + ligatures.push(parse_ligature(data, ligature_offset)?); + } + Ok(ligatures) +} + +fn parse_ligature(data: &[u8], ligature_offset: usize) -> Result { + let r = Reader::new(data); + if ligature_offset + 4 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + let glyph = r.u16(ligature_offset)?; + let component_count = r.u16(ligature_offset + 2)? as usize; + if component_count == 0 { + return Err(FontError::MalformedTable("GSUB")); + } + + let mut components = Vec::with_capacity(component_count.saturating_sub(1)); + for i in 0..component_count.saturating_sub(1) { + let offset = ligature_offset + 4 + i * 2; + if offset + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + components.push(r.u16(offset)?); + } + + Ok(Ligature { glyph, components }) +} + +fn checked_offset(base: usize, relative: usize) -> Result { + base.checked_add(relative) + .ok_or(FontError::MalformedTable("GSUB")) +} + +impl LigatureSubtable { + fn ligature_match(&self, first: u16, rest: &[u16]) -> Option<(u16, usize)> { + let index = self.coverage.index(first)?; + let ligatures = self.ligature_sets.get(index)?; + ligatures.iter().find_map(|ligature| { + if rest.len() < ligature.components.len() { + return None; + } + if rest[..ligature.components.len()] == ligature.components[..] { + Some((ligature.glyph, ligature.components.len() + 1)) + } else { + None + } + }) + } +} + +impl Coverage { + fn parse(data: &[u8], offset: usize) -> Result { + let r = Reader::new(data); + if offset + 4 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + + match r.u16(offset)? { + 1 => { + let glyph_count = r.u16(offset + 2)? as usize; + let mut glyphs = Vec::with_capacity(glyph_count); + for i in 0..glyph_count { + let pos = offset + 4 + i * 2; + if pos + 2 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + glyphs.push(r.u16(pos)?); + } + Ok(Coverage::Format1(glyphs)) + } + 2 => { + let range_count = r.u16(offset + 2)? as usize; + let mut ranges = Vec::with_capacity(range_count); + for i in 0..range_count { + let pos = offset + 4 + i * 6; + if pos + 6 > r.len() { + return Err(FontError::MalformedTable("GSUB")); + } + ranges.push(CoverageRange { + start: r.u16(pos)?, + end: r.u16(pos + 2)?, + start_index: r.u16(pos + 4)?, + }); + } + Ok(Coverage::Format2(ranges)) + } + _ => Err(FontError::MalformedTable("GSUB")), + } + } + + fn index(&self, glyph: u16) -> Option { + match self { + Coverage::Format1(glyphs) => glyphs.binary_search(&glyph).ok(), + Coverage::Format2(ranges) => ranges.iter().find_map(|range| { + if glyph >= range.start && glyph <= range.end { + Some((range.start_index + (glyph - range.start)) as usize) + } else { + None + } + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn gsub_with_single_lookup(ligature_subst: Vec) -> Vec { + let feature_list_offset = 10usize; + let feature_list_len = 14usize; + let lookup_list_offset = feature_list_offset + feature_list_len; + let lookup_offset = lookup_list_offset + 4; + let ligature_subst_offset = lookup_offset + 8; + + let mut data = Vec::new(); + push_u16(&mut data, 1); // major + push_u16(&mut data, 0); // minor + push_u16(&mut data, 0); // scriptListOffset (unused) + push_u16(&mut data, feature_list_offset as u16); + push_u16(&mut data, lookup_list_offset as u16); + + push_u16(&mut data, 1); // featureCount + data.extend_from_slice(b"liga"); + push_u16(&mut data, 8); // feature table offset + push_u16(&mut data, 0); // featureParams + push_u16(&mut data, 1); // lookupIndexCount + push_u16(&mut data, 0); // lookupListIndex + + push_u16(&mut data, 1); // lookupCount + push_u16(&mut data, (lookup_offset - lookup_list_offset) as u16); + + push_u16(&mut data, 4); // lookupType LigatureSubst + push_u16(&mut data, 0); // lookupFlag + push_u16(&mut data, 1); // subTableCount + push_u16(&mut data, (ligature_subst_offset - lookup_offset) as u16); + data.extend_from_slice(&ligature_subst); + data + } + + fn ligature_subst_format1() -> Vec { + let mut data = Vec::new(); + push_u16(&mut data, 1); // substFormat + push_u16(&mut data, 8); // coverageOffset + push_u16(&mut data, 1); // ligatureSetCount + push_u16(&mut data, 14); // ligatureSetOffset[0] + + push_u16(&mut data, 1); // coverage format 1 + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // first glyph + + push_u16(&mut data, 1); // ligatureCount + push_u16(&mut data, 4); // ligatureOffset[0] + push_u16(&mut data, 3); // ligatureGlyph + push_u16(&mut data, 2); // componentCount: first + one component + push_u16(&mut data, 2); // second glyph + data + } + + #[test] + fn parses_default_ligature_substitution() { + let gsub = GsubTable::parse(&gsub_with_single_lookup(ligature_subst_format1())).unwrap(); + + assert_eq!(gsub.num_subtables(), 1); + assert_eq!(gsub.ligature_match(&[1, 2], 0), Some((3, 2))); + assert_eq!(gsub.ligature_match(&[1, 4], 0), None); + } + + #[test] + fn ignores_discretionary_ligature_feature() { + let mut data = gsub_with_single_lookup(ligature_subst_format1()); + data[12..16].copy_from_slice(b"dlig"); + + let gsub = GsubTable::parse(&data).unwrap(); + assert_eq!(gsub.num_subtables(), 0); + assert_eq!(gsub.ligature_match(&[1, 2], 0), None); + } + + #[test] + fn parses_extension_lookup_wrapping_ligature_substitution() { + let ligature = ligature_subst_format1(); + let feature_list_offset = 10usize; + let lookup_list_offset = 24usize; + let lookup_offset = 28usize; + let extension_subtable_offset = lookup_offset + 8usize; + let ligature_offset = extension_subtable_offset + 8usize; + + let mut data = Vec::new(); + push_u16(&mut data, 1); + push_u16(&mut data, 0); + push_u16(&mut data, 0); + push_u16(&mut data, feature_list_offset as u16); + push_u16(&mut data, lookup_list_offset as u16); + push_u16(&mut data, 1); + data.extend_from_slice(b"liga"); + push_u16(&mut data, 8); + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16(&mut data, (lookup_offset - lookup_list_offset) as u16); + push_u16(&mut data, 7); // extension substitution lookup + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16( + &mut data, + (extension_subtable_offset - lookup_offset) as u16, + ); + push_u16(&mut data, 1); // extension format + push_u16(&mut data, 4); // wrapped LigatureSubst lookup + push_u32( + &mut data, + (ligature_offset - extension_subtable_offset) as u32, + ); + data.extend_from_slice(&ligature); + + let gsub = GsubTable::parse(&data).unwrap(); + assert_eq!(gsub.ligature_match(&[1, 2], 0), Some((3, 2))); + } +} diff --git a/crates/text/src/font/tables/mod.rs b/crates/text/src/font/tables/mod.rs index 0597e41..7f537d8 100644 --- a/crates/text/src/font/tables/mod.rs +++ b/crates/text/src/font/tables/mod.rs @@ -3,6 +3,7 @@ pub mod cmap; pub mod glyf; pub mod gpos; +pub mod gsub; pub mod head; pub mod hhea; pub mod hmtx;