Skip to main content

fmd_font/
lib.rs

1//! Clean-room TrueType / OpenType font reader.
2//!
3//! Parses the sfnt table directory plus the metric, character-map, outline, and
4//! layout tables we need to lay out and embed text: `head` (units per em),
5//! `maxp` (glyph count), `hhea`/`hmtx` (vertical metrics + advance widths),
6//! `cmap` (character β†’ glyph, formats 4 and 12), `glyf`/`loca` (TrueType
7//! outlines for subsetting), legacy `kern` format-0 pair kerning, focused GPOS
8//! pair positioning, and GSUB standard ligatures. Latin-first, zero-dependency,
9//! and free of `unsafe`/`unwrap`/`panic` β€” every read is bounds-checked.
10//!
11//! CFF/OpenType outline subsetting and broader script shaping are still future
12//! increments. The current module is enough for bundled TrueType fonts, real
13//! PDF metrics, deterministic subset embedding, kerning, ligatures, and
14//! selectable `ToUnicode` output.
15//!
16//! Factored out of `franken_markdown`'s `src/text.rs` into this standalone
17//! `fmd-font` workspace crate so the wider Franken suite can consume the
18//! font subsystem directly (franken_manim's Scribe is the first external
19//! consumer). The [`outline`] module is the piece added with the factoring:
20//! a decoder from `glyf` point data to quadratic-BΓ©zier contours with
21//! phantom-point-correct metrics.
22#![forbid(unsafe_code)]
23
24#[cfg(feature = "bundled-faces")]
25pub mod bundled;
26pub mod outline;
27
28/// Hard ceiling on how many glyphs a single OpenType layout structure may
29/// enumerate. A font cannot contain more than 65 536 glyphs, so a well-formed
30/// Coverage / ligature / pair table never exceeds this. It bounds the work an
31/// untrusted host font can drive: without it, a tiny malicious table (aliased
32/// offsets or a 6-byte range claiming 65 536 ids) amplifies into billions of
33/// iterations or gigabytes of retained state β€” a CPU-hang / OOM-kill DoS.
34const MAX_LAYOUT_GLYPHS: usize = 65_536;
35
36/// Alias of the layout ceiling used specifically for Coverage-table expansion.
37const MAX_COVERAGE_GLYPHS: usize = MAX_LAYOUT_GLYPHS;
38const MISSING_GLYPH_REMAP: u16 = u16::MAX;
39
40#[derive(Debug, Clone)]
41struct Cmap4Segment {
42    start: u16,
43    end: u16,
44    id_delta: u16,
45    id_range_offset: u16,
46    id_range_offset_pos: usize,
47}
48
49#[derive(Debug, Clone)]
50struct Cmap4Cache {
51    segments: Vec<Cmap4Segment>,
52    sorted_by_end: bool,
53}
54
55/// A parsed font, owning its backing bytes.
56#[derive(Debug, Clone)]
57pub struct Font {
58    data: Vec<u8>,
59    /// Font design units per em (the coordinate scale; advances are in these).
60    pub units_per_em: u16,
61    /// Number of glyphs in the font.
62    pub num_glyphs: u16,
63    /// Typographic ascender (design units).
64    pub ascent: i16,
65    /// Typographic descender (design units, usually negative).
66    pub descent: i16,
67    /// Recommended extra line gap (design units).
68    pub line_gap: i16,
69    num_h_metrics: u16,
70    hmtx_off: usize,
71    cmap_off: usize,
72    cmap_format: u16,
73    cmap4_cache: Option<Cmap4Cache>,
74    /// `(offset, length)` of the `glyf` table, when the font has TrueType
75    /// outlines. Absent for CFF/OpenType (`OTTO`) fonts.
76    glyf: Option<(usize, usize)>,
77    /// Offset of the `loca` table (glyph offsets into `glyf`).
78    loca_off: Option<usize>,
79    /// True when `loca` uses the 32-bit (long) offset format.
80    loca_long: bool,
81    /// `(pair_record_offset, pair_count)` for a legacy `kern` format-0 table.
82    kern0: Option<(usize, u16)>,
83}
84
85/// Why a font failed to parse.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum FontError {
88    /// Not a recognized sfnt (`0x00010000`, `true`, or `OTTO`).
89    BadMagic,
90    /// A required table was absent.
91    MissingTable(&'static str),
92    /// The file ended before a required field could be read.
93    Truncated,
94    /// No usable Unicode `cmap` subtable (format 4 or 12) was found.
95    NoUnicodeCmap,
96}
97
98impl core::fmt::Display for FontError {
99    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100        match self {
101            Self::BadMagic => write!(f, "not a TrueType/OpenType font"),
102            Self::MissingTable(t) => write!(f, "missing required font table: {t}"),
103            Self::Truncated => write!(f, "font data is truncated"),
104            Self::NoUnicodeCmap => write!(f, "no usable Unicode cmap (format 4/12)"),
105        }
106    }
107}
108
109impl std::error::Error for FontError {}
110
111pub(crate) fn be_u16(d: &[u8], o: usize) -> Option<u16> {
112    let bytes = d.get(o..o.checked_add(2)?)?;
113    Some(u16::from_be_bytes([bytes[0], bytes[1]]))
114}
115pub(crate) fn be_i16(d: &[u8], o: usize) -> Option<i16> {
116    be_u16(d, o).map(|v| v as i16)
117}
118fn be_u32(d: &[u8], o: usize) -> Option<u32> {
119    let bytes = d.get(o..o.checked_add(4)?)?;
120    Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
121}
122
123fn off(base: usize, delta: usize) -> Option<usize> {
124    base.checked_add(delta)
125}
126
127fn off_mul(base: usize, index: usize, stride: usize) -> Option<usize> {
128    base.checked_add(index.checked_mul(stride)?)
129}
130
131fn be_u16_at(d: &[u8], base: usize, delta: usize) -> Option<u16> {
132    be_u16(d, off(base, delta)?)
133}
134
135fn be_u32_at(d: &[u8], base: usize, delta: usize) -> Option<u32> {
136    be_u32(d, off(base, delta)?)
137}
138
139fn bytes_at(d: &[u8], base: usize, len: usize) -> Option<&[u8]> {
140    d.get(base..off(base, len)?)
141}
142
143/// Write a big-endian `u16` at `off` into a mutable buffer, bounds-checked.
144fn write_u16(d: &mut [u8], off: usize, v: u16) -> Option<()> {
145    let b = v.to_be_bytes();
146    let dst = d.get_mut(off..off.checked_add(2)?)?;
147    dst.copy_from_slice(&b);
148    Some(())
149}
150
151/// Write a big-endian `u32` at `off` into a mutable buffer, bounds-checked.
152fn write_u32(d: &mut [u8], off: usize, v: u32) -> Option<()> {
153    let b = v.to_be_bytes();
154    let dst = d.get_mut(off..off.checked_add(4)?)?;
155    dst.copy_from_slice(&b);
156    Some(())
157}
158
159/// sfnt table checksum: the wrapping `u32` sum of the table's bytes read as
160/// big-endian 32-bit words, with the final partial word zero-padded.
161fn table_checksum(d: &[u8]) -> u32 {
162    let mut sum: u32 = 0;
163    let mut chunks = d.chunks_exact(4);
164    for c in &mut chunks {
165        sum = sum.wrapping_add(u32::from_be_bytes([c[0], c[1], c[2], c[3]]));
166    }
167    let rem = chunks.remainder();
168    if !rem.is_empty() {
169        let mut buf = [0u8; 4];
170        buf[..rem.len()].copy_from_slice(rem);
171        sum = sum.wrapping_add(u32::from_be_bytes(buf));
172    }
173    sum
174}
175
176fn find_table(d: &[u8], tag: &[u8; 4]) -> Option<usize> {
177    find_table_full(d, tag).map(|(off, _)| off)
178}
179
180/// Locate a table by tag, returning `(offset, length)`.
181fn find_table_full(d: &[u8], tag: &[u8; 4]) -> Option<(usize, usize)> {
182    let num_tables = be_u16(d, 4)? as usize;
183    for i in 0..num_tables {
184        let rec = off_mul(12, i, 16)?;
185        if bytes_at(d, rec, 4)? == tag {
186            return Some((
187                be_u32_at(d, rec, 8)? as usize,
188                be_u32_at(d, rec, 12)? as usize,
189            ));
190        }
191    }
192    None
193}
194
195/// Locate a legacy TrueType `kern` v0 format-0 horizontal pair table.
196fn find_kern0(d: &[u8]) -> Option<(usize, u16)> {
197    let (kern, kern_len) = find_table_full(d, b"kern")?;
198    let table_end = kern.checked_add(kern_len)?;
199    let version = be_u16(d, kern)?;
200    let n_tables = be_u16_at(d, kern, 2)? as usize;
201    if version != 0 {
202        return None;
203    }
204
205    let mut sub = off(kern, 4)?;
206    for _ in 0..n_tables {
207        if sub.checked_add(6)? > table_end {
208            return None;
209        }
210        let length = be_u16_at(d, sub, 2)? as usize;
211        let coverage = be_u16_at(d, sub, 4)?;
212        let format = coverage >> 8;
213        let horizontal = coverage & 0x0001 != 0;
214        let minimum = coverage & 0x0002 != 0;
215        let pairs = off(sub, 14)?;
216        if format == 0 && horizontal && !minimum && length >= 14 {
217            let sub_end = sub.checked_add(length)?;
218            if sub_end > table_end {
219                return None;
220            }
221            let n_pairs = be_u16_at(d, sub, 6)?;
222            let bytes_needed = (n_pairs as usize).checked_mul(6)?;
223            if pairs.checked_add(bytes_needed)? <= sub_end {
224                return Some((pairs, n_pairs));
225            }
226            return None;
227        }
228        if length == 0 {
229            return None;
230        }
231        sub = sub.checked_add(length)?;
232    }
233    None
234}
235
236impl Font {
237    /// Parse a font from its raw bytes (e.g. an `include_bytes!` blob).
238    ///
239    /// # Errors
240    /// Returns a [`FontError`] for a non-sfnt file, a missing required table, a
241    /// truncated file, or the absence of a usable Unicode `cmap`.
242    pub fn parse(data: Vec<u8>) -> Result<Self, FontError> {
243        let d = data.as_slice();
244        let magic = be_u32(d, 0).ok_or(FontError::Truncated)?;
245        // 0x00010000 = TrueType outlines; "true"; "OTTO" = CFF/OpenType.
246        if magic != 0x0001_0000 && magic != 0x7472_7565 && magic != 0x4F54_544F {
247            return Err(FontError::BadMagic);
248        }
249
250        let head = find_table(d, b"head").ok_or(FontError::MissingTable("head"))?;
251        let maxp = find_table(d, b"maxp").ok_or(FontError::MissingTable("maxp"))?;
252        let hhea = find_table(d, b"hhea").ok_or(FontError::MissingTable("hhea"))?;
253        let hmtx = find_table(d, b"hmtx").ok_or(FontError::MissingTable("hmtx"))?;
254        let cmap = find_table(d, b"cmap").ok_or(FontError::MissingTable("cmap"))?;
255
256        let units_per_em =
257            be_u16(d, off(head, 18).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
258        let num_glyphs =
259            be_u16(d, off(maxp, 4).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
260        let ascent =
261            be_i16(d, off(hhea, 4).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
262        let descent =
263            be_i16(d, off(hhea, 6).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
264        let line_gap =
265            be_i16(d, off(hhea, 8).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
266        let num_h_metrics =
267            be_u16(d, off(hhea, 34).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
268
269        let (cmap_off, cmap_format) = select_cmap(d, cmap).ok_or(FontError::NoUnicodeCmap)?;
270        let cmap4_cache = if cmap_format == 4 {
271            parse_cmap4_cache(d, cmap_off)
272        } else {
273            None
274        };
275
276        // Outline tables are optional: present for TrueType (glyf) fonts, absent
277        // for CFF/OpenType. Their absence is not an error here.
278        let loca_long = off(head, 50)
279            .and_then(|offset| be_i16(d, offset))
280            .unwrap_or(0)
281            != 0;
282        let loca_off = find_table(d, b"loca");
283        let glyf = find_table_full(d, b"glyf");
284        let kern0 = find_kern0(d);
285
286        Ok(Self {
287            data,
288            units_per_em,
289            num_glyphs,
290            ascent,
291            descent,
292            line_gap,
293            num_h_metrics,
294            hmtx_off: hmtx,
295            cmap_off,
296            cmap_format,
297            cmap4_cache,
298            glyf,
299            loca_off,
300            loca_long,
301            kern0,
302        })
303    }
304
305    /// True when the font carries TrueType (`glyf`) outlines we can read/subset.
306    #[must_use]
307    pub fn has_glyf_outlines(&self) -> bool {
308        self.glyf.is_some() && self.loca_off.is_some()
309    }
310
311    /// The `[start, end)` byte range of glyph `gid` within the `glyf` table.
312    /// Returns `None` if the font has no `glyf`/`loca`, or `Some((s, s))` for an
313    /// empty glyph (e.g. space).
314    fn glyph_range(&self, gid: u16) -> Option<(usize, usize)> {
315        let loca = self.loca_off?;
316        let (glyf_off, glyf_len) = self.glyf?;
317        let i = gid as usize;
318        let (start, end) = if self.loca_long {
319            (
320                be_u32(&self.data, off_mul(loca, i, 4)?)? as usize,
321                be_u32(&self.data, off_mul(loca, i.checked_add(1)?, 4)?)? as usize,
322            )
323        } else {
324            // Short loca stores offsets / 2.
325            (
326                be_u16(&self.data, off_mul(loca, i, 2)?)? as usize * 2,
327                be_u16(&self.data, off_mul(loca, i.checked_add(1)?, 2)?)? as usize * 2,
328            )
329        };
330        if end < start || end > glyf_len {
331            return None;
332        }
333        Some((off(glyf_off, start)?, off(glyf_off, end)?))
334    }
335
336    /// Raw `glyf` bytes for glyph `gid` (for subset embedding), or `None`.
337    /// An empty (zero-length) glyph yields `Some(&[])`.
338    #[must_use]
339    pub fn glyph_data(&self, gid: u16) -> Option<&[u8]> {
340        let (s, e) = self.glyph_range(gid)?;
341        self.data.get(s..e)
342    }
343
344    /// Glyph bounding box `[xMin, yMin, xMax, yMax]` (design units), or `None`
345    /// for an empty glyph / no outlines.
346    #[must_use]
347    pub fn glyph_bbox(&self, gid: u16) -> Option<[i16; 4]> {
348        let (s, e) = self.glyph_range(gid)?;
349        if e <= s {
350            return None; // empty glyph (no contours)
351        }
352        Some([
353            be_i16(&self.data, off(s, 2)?)?,
354            be_i16(&self.data, off(s, 4)?)?,
355            be_i16(&self.data, off(s, 6)?)?,
356            be_i16(&self.data, off(s, 8)?)?,
357        ])
358    }
359
360    /// True when glyph `gid` is a composite (built from component glyphs).
361    #[must_use]
362    pub fn is_composite(&self, gid: u16) -> bool {
363        match self.glyph_range(gid) {
364            Some((s, e)) if e > s => be_i16(&self.data, s).is_some_and(|n| n < 0),
365            _ => false,
366        }
367    }
368
369    /// Component glyph ids referenced by a composite glyph (for transitive
370    /// subsetting). Empty for simple or empty glyphs.
371    #[must_use]
372    pub fn glyph_components(&self, gid: u16) -> Vec<u16> {
373        const ARG_WORDS: u16 = 0x0001;
374        const WE_HAVE_SCALE: u16 = 0x0008;
375        const MORE: u16 = 0x0020;
376        const X_Y_SCALE: u16 = 0x0040;
377        const TWO_BY_TWO: u16 = 0x0080;
378
379        let mut out = Vec::new();
380        let Some((s, e)) = self.glyph_range(gid) else {
381            return out;
382        };
383        if e <= s || be_i16(&self.data, s).is_none_or(|n| n >= 0) {
384            return out;
385        }
386        let Some(mut p) = off(s, 10) else {
387            return out;
388        };
389        while let Some(component_record_end) = off(p, 4) {
390            if component_record_end > e {
391                break;
392            }
393            let Some(flags) = be_u16(&self.data, p) else {
394                break;
395            };
396            let Some(comp) = off(p, 2).and_then(|offset| be_u16(&self.data, offset)) else {
397                break;
398            };
399            let mut step = 4usize + if flags & ARG_WORDS != 0 { 4 } else { 2 };
400            step += if flags & WE_HAVE_SCALE != 0 {
401                2
402            } else if flags & X_Y_SCALE != 0 {
403                4
404            } else if flags & TWO_BY_TWO != 0 {
405                8
406            } else {
407                0
408            };
409            let Some(next) = off(p, step) else {
410                break;
411            };
412            if next > e {
413                break;
414            }
415            out.push(comp);
416            p = next;
417            if flags & MORE == 0 || p >= e {
418                break;
419            }
420        }
421        out
422    }
423
424    /// The advance width of glyph `gid` in design units. Glyphs past the
425    /// `hmtx` metric run share the last advance (monospaced trailing run).
426    #[must_use]
427    pub fn advance_width(&self, gid: u16) -> u16 {
428        let last = self.num_h_metrics.saturating_sub(1);
429        let idx = gid.min(last) as usize;
430        off_mul(self.hmtx_off, idx, 4)
431            .and_then(|offset| be_u16(&self.data, offset))
432            .unwrap_or(0)
433    }
434
435    /// The left side bearing of glyph `gid` in design units. Glyphs past the
436    /// long-metric run share the last advance but keep their own trailing LSB.
437    #[must_use]
438    pub fn left_side_bearing(&self, gid: u16) -> i16 {
439        if self.num_h_metrics == 0 {
440            return 0;
441        }
442        let gid = gid as usize;
443        let num_h_metrics = self.num_h_metrics as usize;
444        let offset = if gid < num_h_metrics {
445            off_mul(self.hmtx_off, gid, 4).and_then(|base| off(base, 2))
446        } else {
447            off_mul(self.hmtx_off, num_h_metrics, 4)
448                .and_then(|base| off_mul(base, gid - num_h_metrics, 2))
449        };
450        offset
451            .and_then(|offset| be_i16(&self.data, offset))
452            .unwrap_or(0)
453    }
454
455    /// The glyph id for a character, or `0` (`.notdef`) if unmapped.
456    #[must_use]
457    pub fn glyph_index(&self, ch: char) -> u16 {
458        let cp = ch as u32;
459        match self.cmap_format {
460            4 => self.cmap4_lookup(cp).unwrap_or(0),
461            12 => self.cmap12_lookup(cp).unwrap_or(0),
462            _ => 0,
463        }
464    }
465
466    /// Advance width of `ch` in 1/1000 em (PDF text-space units). An unmapped
467    /// `ch` resolves to glyph 0 (`.notdef`) and reserves that glyph's advance
468    /// (so a tofu box still occupies its natural width); only an unparsable face
469    /// with `units_per_em == 0` yields `0`.
470    #[must_use]
471    pub fn advance_1000(&self, ch: char) -> u32 {
472        if self.units_per_em == 0 {
473            return 0;
474        }
475        let aw = self.advance_width(self.glyph_index(ch)) as u32;
476        aw * 1000 / self.units_per_em as u32
477    }
478
479    /// Kerning adjustment between two glyph ids in design units.
480    ///
481    /// Unsupported or absent kerning tables return zero. This currently supports
482    /// legacy TrueType/Microsoft `kern` table version 0, format 0, horizontal
483    /// pairs. GPOS pair positioning is tracked separately.
484    #[must_use]
485    pub fn kerning_between_glyphs(&self, left: u16, right: u16) -> i16 {
486        let Some((pairs, n_pairs)) = self.kern0 else {
487            return 0;
488        };
489        let target = ((left as u32) << 16) | right as u32;
490        let mut lo = 0usize;
491        let mut hi = n_pairs as usize;
492        while lo < hi {
493            let mid = lo + (hi - lo) / 2;
494            let Some(rec) = off_mul(pairs, mid, 6) else {
495                return 0;
496            };
497            let Some(l) = be_u16(&self.data, rec) else {
498                return 0;
499            };
500            let Some(r) = off(rec, 2).and_then(|offset| be_u16(&self.data, offset)) else {
501                return 0;
502            };
503            let key = ((l as u32) << 16) | r as u32;
504            if key == target {
505                return off(rec, 4)
506                    .and_then(|offset| be_i16(&self.data, offset))
507                    .unwrap_or(0);
508            }
509            if key < target {
510                lo = mid + 1;
511            } else {
512                hi = mid;
513            }
514        }
515        0
516    }
517
518    /// Kerning adjustment between two characters in design units.
519    #[must_use]
520    pub fn kerning(&self, left: char, right: char) -> i16 {
521        self.kerning_between_glyphs(self.glyph_index(left), self.glyph_index(right))
522    }
523
524    /// Kerning adjustment between two characters in 1/1000 em units.
525    #[must_use]
526    pub fn kerning_1000(&self, left: char, right: char) -> i32 {
527        if self.units_per_em == 0 {
528            return 0;
529        }
530        self.kerning(left, right) as i32 * 1000 / self.units_per_em as i32
531    }
532
533    fn cmap4_lookup(&self, cp: u32) -> Option<u16> {
534        if cp > 0xFFFF {
535            return Some(0);
536        }
537        let c = cp as u16;
538        if let Some(cache) = &self.cmap4_cache {
539            return self.cmap4_cached_lookup(c, cache);
540        }
541        self.cmap4_uncached_lookup(c)
542    }
543
544    fn cmap4_cached_lookup(&self, c: u16, cache: &Cmap4Cache) -> Option<u16> {
545        let segment = if cache.sorted_by_end {
546            let idx = cache.segments.partition_point(|seg| seg.end < c);
547            cache.segments.get(idx)
548        } else {
549            cache.segments.iter().find(|seg| c <= seg.end)
550        }?;
551
552        if c < segment.start {
553            return Some(0);
554        }
555        if segment.id_range_offset == 0 {
556            return Some(c.wrapping_add(segment.id_delta));
557        }
558        let gi_addr = off(
559            off(
560                segment.id_range_offset_pos,
561                segment.id_range_offset as usize,
562            )?,
563            2usize.checked_mul((c - segment.start) as usize)?,
564        )?;
565        let g = be_u16(&self.data, gi_addr)?;
566        Some(if g == 0 {
567            0
568        } else {
569            g.wrapping_add(segment.id_delta)
570        })
571    }
572
573    fn cmap4_uncached_lookup(&self, c: u16) -> Option<u16> {
574        let d = &self.data;
575        let base = self.cmap_off;
576        let seg_x2 = be_u16(d, off(base, 6)?)? as usize;
577        let seg_count = seg_x2 / 2;
578        let end_codes = off(base, 14)?;
579        let start_codes = off(off(end_codes, seg_x2)?, 2)?; // +2 for reservedPad
580        let id_deltas = off(start_codes, seg_x2)?;
581        let id_range_offsets = off(id_deltas, seg_x2)?;
582        for i in 0..seg_count {
583            let end = be_u16(d, off_mul(end_codes, i, 2)?)?;
584            if c > end {
585                continue;
586            }
587            let start = be_u16(d, off_mul(start_codes, i, 2)?)?;
588            if c < start {
589                return Some(0);
590            }
591            let id_delta = be_u16(d, off_mul(id_deltas, i, 2)?)?;
592            let iro_pos = off_mul(id_range_offsets, i, 2)?;
593            let id_range_offset = be_u16(d, iro_pos)?;
594            if id_range_offset == 0 {
595                return Some(c.wrapping_add(id_delta));
596            }
597            let gi_addr = off(
598                off(iro_pos, id_range_offset as usize)?,
599                2usize.checked_mul((c - start) as usize)?,
600            )?;
601            let g = be_u16(d, gi_addr)?;
602            return Some(if g == 0 { 0 } else { g.wrapping_add(id_delta) });
603        }
604        Some(0)
605    }
606
607    /// Build a new, minimal, valid TrueType (`glyf`) font containing glyph 0
608    /// (`.notdef`) plus exactly the glyphs needed to render `keep` (mapped
609    /// through the original `cmap`), transitively closing over composite
610    /// components. Returns a fresh sfnt (`0x00010000`) suitable for a PDF
611    /// `FontFile2`, or `None` on any failure (missing `glyf`/`loca`/required
612    /// table, or a malformed read).
613    #[must_use]
614    pub fn subset(&self, keep: &[char]) -> Option<Vec<u8>> {
615        let seed: Vec<u16> = keep.iter().map(|&c| self.glyph_index(c)).collect();
616        // Web-embedding path: include `OS/2` (see `subset_core`) so browser
617        // OpenType sanitizers (Chromium's OTS) accept the font instead of
618        // silently falling back to system fonts.
619        self.subset_core(&seed, keep, true).map(|(bytes, _)| bytes)
620    }
621
622    /// Subset to an explicit glyph set (the closure still pulls in composite
623    /// components), building the `cmap` from `cmap_chars`. Returns the font bytes
624    /// plus the old->new glyph id remap β€” for callers that pre-shaped a glyph
625    /// sequence (e.g. GSUB ligatures) and must emit the renumbered ids.
626    ///
627    /// # Errors
628    /// Returns `None` for a font without `glyf`/`loca` outlines or on a malformed
629    /// read (same conditions as [`Font::subset`]).
630    pub fn subset_glyphs(
631        &self,
632        glyphs: &[u16],
633        cmap_chars: &[char],
634    ) -> Option<(Vec<u8>, std::collections::BTreeMap<u16, u16>)> {
635        // PDF font programs do not require `OS/2`; leaving it out keeps the
636        // embedded font streams (and existing golden PDF bytes) unchanged.
637        self.subset_core(glyphs, cmap_chars, false)
638    }
639
640    fn subset_core(
641        &self,
642        seed_glyphs: &[u16],
643        cmap_chars: &[char],
644        include_os2: bool,
645    ) -> Option<(Vec<u8>, std::collections::BTreeMap<u16, u16>)> {
646        // --- 1. Glyph closure ------------------------------------------------
647        // Require TrueType outlines; CFF/`OTTO` fonts cannot be subset here.
648        if !self.has_glyf_outlines() {
649            return None;
650        }
651        let mut set: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
652        set.insert(0);
653        for &gid in seed_glyphs {
654            if gid != 0 && gid < self.num_glyphs {
655                set.insert(gid);
656            }
657        }
658        // Transitively pull in composite components until the set is stable.
659        // A worklist expands each glyph's components exactly once, so a chain of
660        // composites (glyph k referencing k-1 referencing ...) is O(n) instead of
661        // the O(n^2) that re-scanning the whole growing set each round would cost.
662        // `BTreeSet::insert` returns false for an already-present component, which
663        // also terminates cyclic/self-referential composites. The final set β€” and
664        // hence the ascending `old_gids` and the whole subset β€” is identical.
665        let mut worklist: Vec<u16> = set.iter().copied().collect();
666        while let Some(gid) = worklist.pop() {
667            if self.is_composite(gid) {
668                for c in self.glyph_components(gid) {
669                    if c < self.num_glyphs && set.insert(c) {
670                        worklist.push(c);
671                    }
672                }
673            }
674        }
675        let old_gids: Vec<u16> = set.into_iter().collect(); // ascending, 0 first
676
677        // --- 2. Renumber old -> new -----------------------------------------
678        let mut new_of: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
679        let mut new_of_lookup = vec![MISSING_GLYPH_REMAP; usize::from(self.num_glyphs).max(1)];
680        for (i, &g) in old_gids.iter().enumerate() {
681            let new_gid = u16::try_from(i).ok()?;
682            new_of.insert(g, new_gid);
683            *new_of_lookup.get_mut(usize::from(g))? = new_gid;
684        }
685        let n = old_gids.len();
686        let n_u16 = u16::try_from(n).ok()?;
687
688        // --- 3. Rebuild glyf + loca (long offsets) --------------------------
689        let mut glyf_bytes: Vec<u8> = Vec::new();
690        let mut loca: Vec<u32> = Vec::with_capacity(n.checked_add(1)?);
691        for &old in &old_gids {
692            loca.push(u32::try_from(glyf_bytes.len()).ok()?);
693            let gb = self.subset_glyph_bytes(old, &new_of_lookup)?;
694            glyf_bytes.extend_from_slice(&gb);
695            // Pad each glyph to a 4-byte multiple so the next glyph (and every
696            // long-loca offset) is word-aligned.
697            while glyf_bytes.len() % 4 != 0 {
698                glyf_bytes.push(0);
699            }
700        }
701        loca.push(u32::try_from(glyf_bytes.len()).ok()?);
702        let mut loca_bytes: Vec<u8> = Vec::with_capacity(loca.len().checked_mul(4)?);
703        for o in &loca {
704            loca_bytes.extend_from_slice(&o.to_be_bytes());
705        }
706
707        // --- 4. Metric/meta tables ------------------------------------------
708        // maxp: original bytes with numGlyphs (u16 @ +4) set to n.
709        let (maxp_off, maxp_len) = find_table_full(&self.data, b"maxp")?;
710        let mut maxp = self.data.get(maxp_off..off(maxp_off, maxp_len)?)?.to_vec();
711        write_u16(&mut maxp, 4, n_u16)?;
712
713        // hhea: original bytes with numberOfHMetrics (u16 @ +34) set to n.
714        let (hhea_off, hhea_len) = find_table_full(&self.data, b"hhea")?;
715        let mut hhea = self.data.get(hhea_off..off(hhea_off, hhea_len)?)?.to_vec();
716        write_u16(&mut hhea, 34, n_u16)?;
717
718        // hmtx: n long metrics (advanceWidth + true lsb), no trailing run.
719        let mut hmtx: Vec<u8> = Vec::with_capacity(n.checked_mul(4)?);
720        for &old in &old_gids {
721            hmtx.extend_from_slice(&self.advance_width(old).to_be_bytes());
722            hmtx.extend_from_slice(&self.left_side_bearing(old).to_be_bytes());
723        }
724
725        // head: original bytes; zero checkSumAdjustment (@ +8), force long loca.
726        let (head_off, head_len) = find_table_full(&self.data, b"head")?;
727        let mut head = self.data.get(head_off..off(head_off, head_len)?)?.to_vec();
728        write_u32(&mut head, 8, 0)?;
729        write_u16(&mut head, 50, 1)?; // indexToLocFormat = 1 (long)
730
731        // cmap: fresh single format-4 (3,1) subtable.
732        let cmap = self.build_cmap4(cmap_chars, &new_of_lookup)?;
733
734        // name: minimal valid table (format 0, count 0, stringOffset 6).
735        let mut name: Vec<u8> = Vec::with_capacity(6);
736        name.extend_from_slice(&0u16.to_be_bytes());
737        name.extend_from_slice(&0u16.to_be_bytes());
738        name.extend_from_slice(&6u16.to_be_bytes());
739
740        // post: format 3.0, 32 bytes, all metric fields zero.
741        let mut post: Vec<u8> = Vec::with_capacity(32);
742        post.extend_from_slice(&0x0003_0000u32.to_be_bytes()); // version 3.0
743        post.extend_from_slice(&0u32.to_be_bytes()); // italicAngle
744        post.extend_from_slice(&0u16.to_be_bytes()); // underlinePosition
745        post.extend_from_slice(&0u16.to_be_bytes()); // underlineThickness
746        post.extend_from_slice(&0u32.to_be_bytes()); // isFixedPitch
747        post.extend_from_slice(&0u32.to_be_bytes()); // minMemType42
748        post.extend_from_slice(&0u32.to_be_bytes()); // maxMemType42
749        post.extend_from_slice(&0u32.to_be_bytes()); // minMemType1
750        post.extend_from_slice(&0u32.to_be_bytes()); // maxMemType1
751
752        // OS/2: copied verbatim from the source face when requested and
753        // present. Browsers' OpenType sanitizer (Chromium's OTS) rejects web
754        // fonts without an `OS/2` table ("OS/2: missing required table"), so
755        // the HTML embedding path opts in. The aggregate fields (average
756        // width, Unicode ranges, win metrics) remain those of the full face,
757        // which is valid if conservative for a subset. A source face without
758        // `OS/2` subsets as before and is rejected by OTS either way.
759        let os2: Option<Vec<u8>> = if include_os2 {
760            find_table_full(&self.data, b"OS/2")
761                .and_then(|(o, l)| Some(self.data.get(o..off(o, l)?)?.to_vec()))
762        } else {
763            None
764        };
765
766        // --- 5. Assemble the sfnt -------------------------------------------
767        let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![
768            (b"head", head),
769            (b"hhea", hhea),
770            (b"maxp", maxp),
771            (b"hmtx", hmtx),
772            (b"loca", loca_bytes),
773            (b"glyf", glyf_bytes),
774            (b"cmap", cmap),
775            (b"name", name),
776            (b"post", post),
777        ];
778        if let Some(os2) = os2 {
779            tables.push((b"OS/2", os2));
780        }
781        tables.sort_by(|a, b| a.0.cmp(b.0)); // ascending by tag
782
783        let num_tables = tables.len();
784        // searchRange = (2^floor(log2(n)))*16, entrySelector = floor(log2(n)).
785        let mut pw: usize = 1;
786        let mut es: u16 = 0;
787        while pw * 2 <= num_tables {
788            pw *= 2;
789            es += 1;
790        }
791        let search_range = (pw as u16).wrapping_mul(16);
792        let entry_selector = es;
793        let range_shift = (num_tables as u16)
794            .wrapping_mul(16)
795            .wrapping_sub(search_range);
796
797        let dir_size = 12 + num_tables * 16;
798        let mut body: Vec<u8> = Vec::new();
799        // (tag, checksum, offset, length)
800        let mut records: Vec<([u8; 4], u32, u32, u32)> = Vec::with_capacity(num_tables);
801        let mut head_offset: usize = 0;
802        for (tag, bytes) in &tables {
803            // Align each table's file start to a 4-byte boundary.
804            while (dir_size + body.len()) % 4 != 0 {
805                body.push(0);
806            }
807            let table_offset = dir_size + body.len();
808            if *tag == b"head" {
809                head_offset = table_offset;
810            }
811            let checksum = table_checksum(bytes);
812            records.push((
813                **tag,
814                checksum,
815                u32::try_from(table_offset).ok()?,
816                u32::try_from(bytes.len()).ok()?,
817            ));
818            body.extend_from_slice(bytes);
819        }
820        while body.len() % 4 != 0 {
821            body.push(0);
822        }
823
824        let mut out: Vec<u8> = Vec::with_capacity(dir_size + body.len());
825        out.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // sfntVersion
826        out.extend_from_slice(&(num_tables as u16).to_be_bytes());
827        out.extend_from_slice(&search_range.to_be_bytes());
828        out.extend_from_slice(&entry_selector.to_be_bytes());
829        out.extend_from_slice(&range_shift.to_be_bytes());
830        for (tag, checksum, toff, tlen) in &records {
831            out.extend_from_slice(tag);
832            out.extend_from_slice(&checksum.to_be_bytes());
833            out.extend_from_slice(&toff.to_be_bytes());
834            out.extend_from_slice(&tlen.to_be_bytes());
835        }
836        out.extend_from_slice(&body);
837
838        // checkSumAdjustment: 0xB1B0AFBA - checksum(whole file with field zeroed).
839        let file_checksum = table_checksum(&out);
840        let adj = 0xB1B0_AFBAu32.wrapping_sub(file_checksum);
841        write_u32(&mut out, off(head_offset, 8)?, adj)?;
842
843        Some((out, new_of))
844    }
845
846    /// Glyph bytes for the subset: simple glyphs are copied without hinting
847    /// instructions; composite glyphs are copied with each component `glyphIndex`
848    /// (u16) rewritten from its old gid to its new gid and any trailing
849    /// instructions removed. Empty glyphs yield an empty `Vec`.
850    fn subset_glyph_bytes(&self, old: u16, new_of: &[u16]) -> Option<Vec<u8>> {
851        const ARG_WORDS: u16 = 0x0001;
852        const WE_HAVE_SCALE: u16 = 0x0008;
853        const MORE: u16 = 0x0020;
854        const X_Y_SCALE: u16 = 0x0040;
855        const TWO_BY_TWO: u16 = 0x0080;
856        const WE_HAVE_INSTRUCTIONS: u16 = 0x0100;
857
858        let data = self.glyph_data(old).unwrap_or(&[]);
859        if data.is_empty() {
860            return Some(Vec::new());
861        }
862        let num_contours = be_i16(data, 0)?;
863        if num_contours >= 0 {
864            return strip_simple_glyph_instructions(data, num_contours as usize);
865        }
866        // Composite: walk component records, rewriting each glyphIndex.
867        let mut out = data.to_vec();
868        let mut p = 10usize; // skip numberOfContours + 4x i16 bbox
869        let mut instruction_flags_positions = Vec::new();
870        loop {
871            let flags = be_u16(&out, p)?;
872            if flags & WE_HAVE_INSTRUCTIONS != 0 {
873                instruction_flags_positions.push(p);
874            }
875            let comp_old = be_u16_at(&out, p, 2)?;
876            // A component that fell outside the subset (e.g. a component gid
877            // >= numGlyphs in a malformed font β€” the closure never reaches it)
878            // is substituted with `.notdef` (new gid 0, always present) rather
879            // than failing the whole font. The composite still renders, minus
880            // the one bad component.
881            let comp_new = remapped_gid(new_of, comp_old).unwrap_or(0);
882            let nb = comp_new.to_be_bytes();
883            *out.get_mut(off(p, 2)?)? = nb[0];
884            *out.get_mut(off(p, 3)?)? = nb[1];
885            p = off(p, 4)?;
886            p = off(p, if flags & ARG_WORDS != 0 { 4 } else { 2 })?;
887            if flags & WE_HAVE_SCALE != 0 {
888                p = off(p, 2)?;
889            } else if flags & X_Y_SCALE != 0 {
890                p = off(p, 4)?;
891            } else if flags & TWO_BY_TWO != 0 {
892                p = off(p, 8)?;
893            }
894            if flags & MORE == 0 {
895                break;
896            }
897        }
898        if !instruction_flags_positions.is_empty() {
899            for flags_pos in instruction_flags_positions {
900                let flags = be_u16(&out, flags_pos)?;
901                write_u16(&mut out, flags_pos, flags & !WE_HAVE_INSTRUCTIONS)?;
902            }
903            let instruction_len = be_u16(&out, p)? as usize;
904            let instruction_start = off(p, 2)?;
905            let instruction_end = off(instruction_start, instruction_len)?;
906            if instruction_end > out.len() {
907                return None;
908            }
909            out.drain(p..instruction_end);
910        }
911        Some(out)
912    }
913
914    /// Build a complete `cmap` table holding a single format-4 `(3,1)` subtable
915    /// mapping every BMP char in `keep` to its NEW gid (one 1-char segment each,
916    /// plus the mandatory final `0xFFFF` segment).
917    fn build_cmap4(&self, keep: &[char], new_of: &[u16]) -> Option<Vec<u8>> {
918        // Unique, ascending code -> new gid (0xFFFF reserved for the final seg).
919        let mut codes: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
920        for &ch in keep {
921            let cp = ch as u32;
922            if cp >= 0xFFFF {
923                continue;
924            }
925            let old = self.glyph_index(ch);
926            // Skip a char whose glyph is not in the subset (a malformed source
927            // cmap, or a glyph the closure could not reach) instead of failing the
928            // whole font; it falls back to `.notdef` at render time.
929            let Some(ng) = remapped_gid(new_of, old) else {
930                continue;
931            };
932            codes.insert(cp as u16, ng);
933        }
934        let entries: Vec<(u16, u16)> = codes.into_iter().collect();
935        let seg_count = entries.len().checked_add(1)?; // + final 0xFFFF segment
936        let sub_len = 16usize.checked_add(seg_count.checked_mul(8)?)?;
937        let sub_len_u16 = u16::try_from(sub_len).ok()?;
938        let seg_count_x2 = u16::try_from(seg_count.checked_mul(2)?).ok()?;
939
940        let mut pw: usize = 1;
941        let mut es: u16 = 0;
942        while pw * 2 <= seg_count {
943            pw *= 2;
944            es += 1;
945        }
946        let search_range = u16::try_from(pw.checked_mul(2)?).ok()?;
947        let entry_selector = es;
948        let range_shift = seg_count_x2.checked_sub(search_range)?;
949
950        let mut sub: Vec<u8> = Vec::with_capacity(sub_len);
951        sub.extend_from_slice(&4u16.to_be_bytes()); // format
952        sub.extend_from_slice(&sub_len_u16.to_be_bytes()); // length
953        sub.extend_from_slice(&0u16.to_be_bytes()); // language
954        sub.extend_from_slice(&seg_count_x2.to_be_bytes()); // segCountX2
955        sub.extend_from_slice(&search_range.to_be_bytes());
956        sub.extend_from_slice(&entry_selector.to_be_bytes());
957        sub.extend_from_slice(&range_shift.to_be_bytes());
958        // endCode[]
959        for &(code, _) in &entries {
960            sub.extend_from_slice(&code.to_be_bytes());
961        }
962        sub.extend_from_slice(&0xFFFFu16.to_be_bytes());
963        // reservedPad
964        sub.extend_from_slice(&0u16.to_be_bytes());
965        // startCode[]
966        for &(code, _) in &entries {
967            sub.extend_from_slice(&code.to_be_bytes());
968        }
969        sub.extend_from_slice(&0xFFFFu16.to_be_bytes());
970        // idDelta[]: (code + idDelta) & 0xFFFF == new gid.
971        for &(code, ng) in &entries {
972            sub.extend_from_slice(&ng.wrapping_sub(code).to_be_bytes());
973        }
974        // Final segment idDelta = 1.
975        sub.extend_from_slice(&1u16.to_be_bytes());
976        // idRangeOffset[] (all zero, glyphIdArray empty).
977        for _ in &entries {
978            sub.extend_from_slice(&0u16.to_be_bytes());
979        }
980        sub.extend_from_slice(&0u16.to_be_bytes());
981
982        let mut cmap: Vec<u8> = Vec::with_capacity(12 + sub.len());
983        cmap.extend_from_slice(&0u16.to_be_bytes()); // version
984        cmap.extend_from_slice(&1u16.to_be_bytes()); // numTables
985        cmap.extend_from_slice(&3u16.to_be_bytes()); // platformID (Windows)
986        cmap.extend_from_slice(&1u16.to_be_bytes()); // encodingID (Unicode BMP)
987        cmap.extend_from_slice(&12u32.to_be_bytes()); // subtable offset
988        cmap.extend_from_slice(&sub);
989        Some(cmap)
990    }
991
992    fn cmap12_lookup(&self, cp: u32) -> Option<u16> {
993        let d = &self.data;
994        let base = self.cmap_off;
995        let num_groups = be_u32(d, off(base, 12)?)? as usize;
996        for i in 0..num_groups {
997            let g = off_mul(off(base, 16)?, i, 12)?;
998            let start = be_u32(d, g)?;
999            let end = be_u32(d, off(g, 4)?)?;
1000            if cp >= start && cp <= end {
1001                let start_gid = be_u32(d, off(g, 8)?)?;
1002                let gid = start_gid.checked_add(cp - start)?;
1003                return Some((gid & 0xFFFF) as u16);
1004            }
1005        }
1006        Some(0)
1007    }
1008}
1009
1010fn remapped_gid(new_of: &[u16], old: u16) -> Option<u16> {
1011    match new_of.get(usize::from(old)).copied()? {
1012        MISSING_GLYPH_REMAP => None,
1013        gid => Some(gid),
1014    }
1015}
1016
1017fn strip_simple_glyph_instructions(data: &[u8], contour_count: usize) -> Option<Vec<u8>> {
1018    let instruction_len_offset = off(10, contour_count.checked_mul(2)?)?;
1019    let instruction_len = be_u16(data, instruction_len_offset)? as usize;
1020    let instruction_start = off(instruction_len_offset, 2)?;
1021    let instruction_end = off(instruction_start, instruction_len)?;
1022    if instruction_end > data.len() {
1023        return None;
1024    }
1025
1026    let mut out = Vec::with_capacity(data.len().saturating_sub(instruction_len));
1027    out.extend_from_slice(data.get(..instruction_len_offset)?);
1028    out.extend_from_slice(&0u16.to_be_bytes());
1029    out.extend_from_slice(data.get(instruction_end..)?);
1030    Some(out)
1031}
1032
1033fn parse_cmap4_cache(d: &[u8], base: usize) -> Option<Cmap4Cache> {
1034    let seg_x2 = be_u16(d, off(base, 6)?)? as usize;
1035    let seg_count = seg_x2 / 2;
1036    let end_codes = off(base, 14)?;
1037    let start_codes = off(off(end_codes, seg_x2)?, 2)?;
1038    let id_deltas = off(start_codes, seg_x2)?;
1039    let id_range_offsets = off(id_deltas, seg_x2)?;
1040
1041    let mut segments = Vec::with_capacity(seg_count);
1042    let mut sorted_by_end = true;
1043    let mut prev_end: Option<u16> = None;
1044    for i in 0..seg_count {
1045        let end = be_u16(d, off_mul(end_codes, i, 2)?)?;
1046        let start = be_u16(d, off_mul(start_codes, i, 2)?)?;
1047        let id_delta = be_u16(d, off_mul(id_deltas, i, 2)?)?;
1048        let id_range_offset_pos = off_mul(id_range_offsets, i, 2)?;
1049        let id_range_offset = be_u16(d, id_range_offset_pos)?;
1050        if prev_end.is_some_and(|prev| end < prev) {
1051            sorted_by_end = false;
1052        }
1053        prev_end = Some(end);
1054        segments.push(Cmap4Segment {
1055            start,
1056            end,
1057            id_delta,
1058            id_range_offset,
1059            id_range_offset_pos,
1060        });
1061    }
1062
1063    Some(Cmap4Cache {
1064        segments,
1065        sorted_by_end,
1066    })
1067}
1068
1069/// Choose the best Unicode `cmap` subtable, returning its absolute offset and
1070/// format. Prefers a full-repertoire format-12 `(3,10)`/`(0,*)` table, then a
1071/// BMP format-4 `(3,1)`/`(0,*)` table.
1072fn select_cmap(d: &[u8], cmap: usize) -> Option<(usize, u16)> {
1073    let num = be_u16(d, off(cmap, 2)?)? as usize;
1074    let mut best: Option<(usize, u16, u8)> = None; // (offset, format, rank)
1075    for i in 0..num {
1076        let rec = off_mul(off(cmap, 4)?, i, 8)?;
1077        let platform = be_u16(d, rec)?;
1078        let encoding = be_u16(d, off(rec, 2)?)?;
1079        let sub = off(cmap, be_u32(d, off(rec, 4)?)? as usize)?;
1080        let format = be_u16(d, sub)?;
1081        let unicode = matches!((platform, encoding), (0, _) | (3, 1) | (3, 10));
1082        if !unicode {
1083            continue;
1084        }
1085        let rank = match format {
1086            12 => 3,
1087            4 => {
1088                if (platform, encoding) == (3, 1) || platform == 0 {
1089                    2
1090                } else {
1091                    1
1092                }
1093            }
1094            _ => continue,
1095        };
1096        if best.is_none_or(|(_, _, r)| rank > r) {
1097            best = Some((sub, format, rank));
1098        }
1099    }
1100    best.map(|(off, fmt, _)| (off, fmt))
1101}
1102
1103// ===========================================================================
1104// OpenType GPOS pair-kerning parser (clean-room). Corrected version.
1105//
1106// Reuses existing module helpers be_u16/be_i16/be_u32/find_table_full.
1107// No unsafe, no unwrap/expect/panic; every read AND every allocation is
1108// bounds-checked against the font data.
1109// ===========================================================================
1110
1111/// A class-definition table (`ClassDef`), used by Pair Adjustment format 2.
1112#[derive(Clone, Debug)]
1113enum ClassDef {
1114    /// `startGlyphID` + dense `classValueArray`.
1115    Format1 { start: u16, classes: Vec<u16> },
1116    /// Sorted `(startGlyphID, endGlyphID, class)` ranges.
1117    Format2 { ranges: Vec<(u16, u16, u16)> },
1118}
1119
1120impl ClassDef {
1121    /// Class of `g`; glyphs not covered by any entry are class 0.
1122    fn class(&self, g: u16) -> u16 {
1123        match self {
1124            ClassDef::Format1 { start, classes } => {
1125                if g >= *start {
1126                    let i = (g - *start) as usize;
1127                    if i < classes.len() {
1128                        return classes[i];
1129                    }
1130                }
1131                0
1132            }
1133            ClassDef::Format2 { ranges } => {
1134                for &(s, e, c) in ranges {
1135                    if g >= s && g <= e {
1136                        return c;
1137                    }
1138                }
1139                0
1140            }
1141        }
1142    }
1143}
1144
1145/// One parsed Pair Adjustment subtable (`lookupType` 2), reduced to the
1146/// `xAdvance` of `valueRecord1` (the only field we apply).
1147#[derive(Clone, Debug)]
1148enum KernSubtable {
1149    /// Specific-pair kerning: `(leftGlyph, rightGlyph) -> xAdvance`.
1150    Format1 {
1151        pairs: std::collections::BTreeMap<(u16, u16), i16>,
1152    },
1153    /// Class-based kerning.
1154    Format2 {
1155        /// First-glyph coverage, sorted ascending for `binary_search`.
1156        coverage: Vec<u16>,
1157        class1: ClassDef,
1158        class2: ClassDef,
1159        /// Declared matrix dimensions; needed to reject out-of-range class
1160        /// values that would otherwise index a wrong matrix cell.
1161        class1_count: u16,
1162        class2_count: u16,
1163        /// Row-major `xAdvance` matrix: `matrix[c1 * class2_count + c2]`.
1164        /// Empty iff both value formats are empty (all adjustments are 0).
1165        matrix: Vec<i16>,
1166    },
1167}
1168
1169impl KernSubtable {
1170    /// Returns `Some(xAdvance)` if this subtable defines `(left, right)`.
1171    ///
1172    /// For format 2 a `Some(0)` is returned when `left` is covered but the
1173    /// resolved record is zero or the classes fall outside the declared
1174    /// dimensions β€” that still counts as a defined (first) match.
1175    fn lookup(&self, left: u16, right: u16) -> Option<i16> {
1176        match self {
1177            KernSubtable::Format1 { pairs } => pairs.get(&(left, right)).copied(),
1178            KernSubtable::Format2 {
1179                coverage,
1180                class1,
1181                class2,
1182                class1_count,
1183                class2_count,
1184                matrix,
1185            } => {
1186                // Format 2 only applies when `left` is in coverage.
1187                if coverage.binary_search(&left).is_err() {
1188                    return None;
1189                }
1190                let c1 = class1.class(left) as usize;
1191                let c2 = class2.class(right) as usize;
1192                let c1_count = *class1_count as usize;
1193                let c2_count = *class2_count as usize;
1194                // Out-of-range class values must NOT wrap into another row.
1195                if c1 >= c1_count || c2 >= c2_count {
1196                    return Some(0);
1197                }
1198                // Zero-length value records => every adjustment is 0.
1199                if matrix.is_empty() {
1200                    return Some(0);
1201                }
1202                let idx = c1.checked_mul(c2_count)?.checked_add(c2)?;
1203                // idx is guaranteed < matrix.len() given the bounds above, but
1204                // fall back to 0 defensively rather than ever returning None.
1205                Some(matrix.get(idx).copied().unwrap_or(0))
1206            }
1207        }
1208    }
1209}
1210
1211/// Parsed GPOS `kern`-feature pair positioning for a font.
1212///
1213/// Built once via [`Font::gpos_kerning`]; [`Kerning::pair`] is a cheap,
1214/// allocation-free lookup. An empty `Kerning` (no GPOS / no kern feature /
1215/// malformed) makes every `pair()` return 0.
1216#[derive(Clone, Debug, Default)]
1217pub struct Kerning {
1218    subtables: Vec<KernSubtable>,
1219}
1220
1221impl Kerning {
1222    /// x-advance adjustment (font design units) applied between `left` and
1223    /// `right` glyph ids; 0 if no kern pair applies. First matching subtable
1224    /// wins.
1225    #[must_use]
1226    pub fn pair(&self, left: u16, right: u16) -> i16 {
1227        for st in &self.subtables {
1228            if let Some(v) = st.lookup(left, right) {
1229                return v;
1230            }
1231        }
1232        0
1233    }
1234}
1235
1236/// ValueRecord byte size = popcount(valueFormat) * 2.
1237fn value_record_size(value_format: u16) -> usize {
1238    value_format.count_ones() as usize * 2
1239}
1240
1241/// Reads the `xAdvance` (0x0004) i16 of a ValueRecord starting at `off`.
1242///
1243/// Returns `Some(0)` when X_ADVANCE is not present, `None` only when the bytes
1244/// are missing. The field offset within the record is `2 * popcount(vf & 0x0003)`
1245/// (skip X/Y placement if set).
1246fn value_record_x_advance(d: &[u8], off: usize, value_format: u16) -> Option<i16> {
1247    const X_ADVANCE: u16 = 0x0004;
1248    if value_format & X_ADVANCE == 0 {
1249        return Some(0);
1250    }
1251    let skip = (value_format & 0x0003).count_ones() as usize * 2;
1252    be_i16(d, off.checked_add(skip)?)
1253}
1254
1255/// Parses a Coverage table at `cov`, returning glyph ids ordered by coverage
1256/// index (index `i` -> returned vec position `i`).
1257fn parse_coverage_glyphs(d: &[u8], cov: usize) -> Option<Vec<u16>> {
1258    let format = be_u16(d, cov)?;
1259    match format {
1260        1 => {
1261            let count = be_u16_at(d, cov, 2)? as usize;
1262            let mut v = Vec::with_capacity(count.min(d.len() / 2 + 1));
1263            for i in 0..count {
1264                v.push(be_u16(d, off_mul(off(cov, 4)?, i, 2)?)?);
1265            }
1266            Some(v)
1267        }
1268        2 => {
1269            let range_count = be_u16_at(d, cov, 2)? as usize;
1270            // Key by coverage index so the result is correctly ordered even if
1271            // ranges are listed out of order.
1272            let mut by_index: std::collections::BTreeMap<u32, u16> =
1273                std::collections::BTreeMap::new();
1274            // A well-formed Coverage cannot enumerate more glyphs than exist in a
1275            // font (<= 65536). Each 6-byte RangeRecord can otherwise claim up to
1276            // 65536 ids, so without a cap a small malicious table drives billions
1277            // of iterations (a CPU-hang DoS on an untrusted host font). Cap the
1278            // total span and bail before expanding an over-claiming table.
1279            let mut total: usize = 0;
1280            for i in 0..range_count {
1281                let rec = off_mul(off(cov, 4)?, i, 6)?;
1282                let start = be_u16(d, rec)? as u32;
1283                let end = be_u16_at(d, rec, 2)? as u32;
1284                let start_idx = be_u16_at(d, rec, 4)? as u32;
1285                if end < start {
1286                    continue;
1287                }
1288                total = total.checked_add((end - start + 1) as usize)?;
1289                if total > MAX_COVERAGE_GLYPHS {
1290                    return None;
1291                }
1292                let mut g = start;
1293                let mut idx = start_idx;
1294                while g <= end {
1295                    by_index.insert(idx, g as u16);
1296                    g += 1;
1297                    idx += 1;
1298                }
1299            }
1300            Some(by_index.into_values().collect())
1301        }
1302        _ => None,
1303    }
1304}
1305
1306/// Parses a ClassDef table at `cd`.
1307fn parse_class_def(d: &[u8], cd: usize) -> Option<ClassDef> {
1308    let format = be_u16(d, cd)?;
1309    match format {
1310        1 => {
1311            let start = be_u16_at(d, cd, 2)?;
1312            let count = be_u16_at(d, cd, 4)? as usize;
1313            let mut classes = Vec::with_capacity(count.min(d.len() / 2 + 1));
1314            for i in 0..count {
1315                classes.push(be_u16(d, off_mul(off(cd, 6)?, i, 2)?)?);
1316            }
1317            Some(ClassDef::Format1 { start, classes })
1318        }
1319        2 => {
1320            let range_count = be_u16_at(d, cd, 2)? as usize;
1321            let mut ranges = Vec::with_capacity(range_count.min(d.len() / 6 + 1));
1322            for i in 0..range_count {
1323                let rec = off_mul(off(cd, 4)?, i, 6)?;
1324                let s = be_u16(d, rec)?;
1325                let e = be_u16_at(d, rec, 2)?;
1326                let c = be_u16_at(d, rec, 4)?;
1327                ranges.push((s, e, c));
1328            }
1329            Some(ClassDef::Format2 { ranges })
1330        }
1331        _ => None,
1332    }
1333}
1334
1335/// Parses a Pair Adjustment subtable (`lookupType` 2) whose start is `sub`.
1336fn parse_pair_subtable(d: &[u8], sub: usize) -> Option<KernSubtable> {
1337    let pos_format = be_u16(d, sub)?;
1338    match pos_format {
1339        1 => parse_pair_format1(d, sub),
1340        2 => parse_pair_format2(d, sub),
1341        _ => None,
1342    }
1343}
1344
1345/// Pair Adjustment format 1 (specific pairs).
1346fn parse_pair_format1(d: &[u8], sub: usize) -> Option<KernSubtable> {
1347    let cov_off = be_u16_at(d, sub, 2)? as usize;
1348    let vf1 = be_u16_at(d, sub, 4)?;
1349    let vf2 = be_u16_at(d, sub, 6)?;
1350    let pair_set_count = be_u16_at(d, sub, 8)? as usize;
1351
1352    let rec1_size = value_record_size(vf1);
1353    let rec2_size = value_record_size(vf2);
1354    // Each PairValueRecord: secondGlyph(2) + valueRecord1 + valueRecord2.
1355    let pair_rec_size = off(2, off(rec1_size, rec2_size)?)?;
1356
1357    let coverage = parse_coverage_glyphs(d, off(sub, cov_off)?)?;
1358
1359    let mut pairs: std::collections::BTreeMap<(u16, u16), i16> = std::collections::BTreeMap::new();
1360
1361    // Bound total work: PairSet offsets may all alias one target, so a font of
1362    // O(pair_set_count + pair_value_count) bytes can otherwise drive their product
1363    // in iterations β€” a CPU-hang DoS on an untrusted host font.
1364    let mut work: usize = 0;
1365    for i in 0..pair_set_count {
1366        work += 1;
1367        if work > MAX_LAYOUT_GLYPHS {
1368            break;
1369        }
1370        // PairSet for coverage-index i is for coverage glyph at position i.
1371        let Some(left_glyph) = coverage.get(i).copied() else {
1372            continue;
1373        };
1374        let Some(ps_off) = off_mul(off(sub, 10)?, i, 2).and_then(|slot| be_u16(d, slot)) else {
1375            continue;
1376        };
1377        let Some(ps) = off(sub, ps_off as usize) else {
1378            continue;
1379        };
1380        let Some(pair_value_count) = be_u16(d, ps) else {
1381            continue;
1382        };
1383        let Some(mut p) = off(ps, 2) else {
1384            continue;
1385        };
1386        for _ in 0..pair_value_count {
1387            work += 1;
1388            if work > MAX_LAYOUT_GLYPHS {
1389                break;
1390            }
1391            let Some(second) = be_u16(d, p) else {
1392                break;
1393            };
1394            let x_adv = off(p, 2)
1395                .and_then(|value_off| value_record_x_advance(d, value_off, vf1))
1396                .unwrap_or(0);
1397            // First subtable / first record wins for a given pair.
1398            pairs.entry((left_glyph, second)).or_insert(x_adv);
1399            let Some(np) = p.checked_add(pair_rec_size) else {
1400                break;
1401            };
1402            p = np;
1403        }
1404    }
1405
1406    Some(KernSubtable::Format1 { pairs })
1407}
1408
1409/// Pair Adjustment format 2 (class-based).
1410fn parse_pair_format2(d: &[u8], sub: usize) -> Option<KernSubtable> {
1411    let cov_off = be_u16_at(d, sub, 2)? as usize;
1412    let vf1 = be_u16_at(d, sub, 4)?;
1413    let vf2 = be_u16_at(d, sub, 6)?;
1414    let class_def1_off = be_u16_at(d, sub, 8)? as usize;
1415    let class_def2_off = be_u16_at(d, sub, 10)? as usize;
1416    let class1_count = be_u16_at(d, sub, 12)? as usize;
1417    let class2_count = be_u16_at(d, sub, 14)? as usize;
1418
1419    let rec1_size = value_record_size(vf1);
1420    let rec2_size = value_record_size(vf2);
1421    let class_rec_size = off(rec1_size, rec2_size)?;
1422
1423    // Class1Record[]: each holds class2_count Class2Records (record[c1][c2]).
1424    let matrix_base = off(sub, 16)?;
1425    let cell_count = class1_count.checked_mul(class2_count)?;
1426
1427    // Never allocate/iterate based on untrusted class counts unless the
1428    // declared matrix actually fits within the font data.
1429    let matrix: Vec<i16> = if class_rec_size == 0 {
1430        // Both value formats empty => every xAdvance is 0; store nothing.
1431        Vec::new()
1432    } else {
1433        let needed = cell_count.checked_mul(class_rec_size)?;
1434        let end = matrix_base.checked_add(needed)?;
1435        if end > d.len() {
1436            // Matrix cannot fit -> malformed; drop this subtable.
1437            return None;
1438        }
1439        let mut m = Vec::with_capacity(cell_count);
1440        for idx in 0..cell_count {
1441            let cell = off_mul(matrix_base, idx, class_rec_size)?;
1442            // In-bounds by the check above; reads only xAdvance of record1.
1443            let x_adv = value_record_x_advance(d, cell, vf1).unwrap_or(0);
1444            m.push(x_adv);
1445        }
1446        m
1447    };
1448
1449    let mut coverage = parse_coverage_glyphs(d, off(sub, cov_off)?)?;
1450    coverage.sort_unstable();
1451
1452    let class1 = parse_class_def(d, off(sub, class_def1_off)?)?;
1453    let class2 = parse_class_def(d, off(sub, class_def2_off)?)?;
1454
1455    Some(KernSubtable::Format2 {
1456        coverage,
1457        class1,
1458        class2,
1459        class1_count: class1_count as u16,
1460        class2_count: class2_count as u16,
1461        matrix,
1462    })
1463}
1464
1465/// Resolves an Extension Positioning subtable (`lookupType` 9), returning
1466/// `(extensionLookupType, realSubtableOffset)`.
1467fn resolve_extension(d: &[u8], sub: usize) -> Option<(u16, usize)> {
1468    let pos_format = be_u16(d, sub)?;
1469    if pos_format != 1 {
1470        return None;
1471    }
1472    let ext_type = be_u16_at(d, sub, 2)?;
1473    let ext_off = be_u32_at(d, sub, 4)? as usize;
1474    Some((ext_type, sub.checked_add(ext_off)?))
1475}
1476
1477impl Font {
1478    /// Parses the GPOS `kern` feature once into a [`Kerning`] structure.
1479    ///
1480    /// Returns an empty `Kerning` (every `pair()` -> 0) when the font has no
1481    /// GPOS table, no `kern` feature, or the relevant offsets are malformed.
1482    #[must_use]
1483    pub fn gpos_kerning(&self) -> Kerning {
1484        self.parse_gpos_kerning().unwrap_or_default()
1485    }
1486
1487    fn parse_gpos_kerning(&self) -> Option<Kerning> {
1488        let d = &self.data;
1489        let (gpos, _gpos_len) = find_table_full(d, b"GPOS")?;
1490
1491        // GPOS header: major(0) minor(2) scriptList(4) featureList(6) lookupList(8).
1492        let feature_list_off = be_u16_at(d, gpos, 6)? as usize;
1493        let lookup_list_off = be_u16_at(d, gpos, 8)? as usize;
1494        let feature_list = off(gpos, feature_list_off)?;
1495        let lookup_list = off(gpos, lookup_list_off)?;
1496
1497        // --- Collect every 'kern' feature's lookup indices (deduplicated). ---
1498        let feature_count = be_u16(d, feature_list)? as usize;
1499        let mut lookup_indices: Vec<u16> = Vec::new();
1500        for i in 0..feature_count {
1501            // FeatureRecord: tag[4] + featureOffset(2), from FeatureList.
1502            let rec = off_mul(off(feature_list, 2)?, i, 6)?;
1503            let Some(tag) = bytes_at(d, rec, 4) else {
1504                break;
1505            };
1506            if tag != b"kern" {
1507                continue;
1508            }
1509            let Some(feat_off) = be_u16_at(d, rec, 4) else {
1510                continue;
1511            };
1512            let Some(feat) = off(feature_list, feat_off as usize) else {
1513                continue;
1514            };
1515            // Feature: featureParams(0) lookupIndexCount(2) lookupIndices(4..).
1516            let Some(lookup_index_count) = be_u16_at(d, feat, 2) else {
1517                continue;
1518            };
1519            for j in 0..lookup_index_count as usize {
1520                if let Some(idx) = off_mul(off(feat, 4)?, j, 2).and_then(|slot| be_u16(d, slot)) {
1521                    if !lookup_indices.contains(&idx) {
1522                        lookup_indices.push(idx);
1523                    }
1524                }
1525            }
1526        }
1527
1528        // --- Walk the gathered lookups, collecting pair subtables. ---
1529        let lookup_count = be_u16(d, lookup_list)? as usize;
1530        let mut subtables: Vec<KernSubtable> = Vec::new();
1531
1532        for &li in &lookup_indices {
1533            let li = li as usize;
1534            if li >= lookup_count {
1535                continue;
1536            }
1537            let Some(lookup_off) =
1538                off_mul(off(lookup_list, 2)?, li, 2).and_then(|slot| be_u16(d, slot))
1539            else {
1540                continue;
1541            };
1542            let Some(lookup) = off(lookup_list, lookup_off as usize) else {
1543                continue;
1544            };
1545            // Lookup: lookupType(0) lookupFlag(2) subTableCount(4) offsets(6..).
1546            let Some(lookup_type) = be_u16(d, lookup) else {
1547                continue;
1548            };
1549            let Some(sub_count) = be_u16_at(d, lookup, 4) else {
1550                continue;
1551            };
1552
1553            for s in 0..sub_count as usize {
1554                let Some(sub_off) = off_mul(off(lookup, 6)?, s, 2).and_then(|slot| be_u16(d, slot))
1555                else {
1556                    continue;
1557                };
1558                let Some(sub) = off(lookup, sub_off as usize) else {
1559                    continue;
1560                };
1561
1562                match lookup_type {
1563                    2 => {
1564                        if let Some(st) = parse_pair_subtable(d, sub) {
1565                            subtables.push(st);
1566                        }
1567                    }
1568                    9 => {
1569                        // Extension: resolve, then handle a real type-2 subtable.
1570                        if let Some((ext_type, real_sub)) = resolve_extension(d, sub) {
1571                            if ext_type == 2 {
1572                                if let Some(st) = parse_pair_subtable(d, real_sub) {
1573                                    subtables.push(st);
1574                                }
1575                            }
1576                        }
1577                    }
1578                    _ => {}
1579                }
1580            }
1581        }
1582
1583        Some(Kerning { subtables })
1584    }
1585}
1586
1587// ===========================================================================
1588// GSUB ligature substitution (vxi.3). Reuses parse_coverage_glyphs +
1589// resolve_extension + be_u16 + find_table_full. No unsafe/unwrap/panic.
1590// ===========================================================================
1591
1592/// One ligature rule: a first glyph (the map key) followed by `components`
1593/// (the remaining component glyph ids) substitutes to `ligature`.
1594#[derive(Clone, Debug)]
1595struct LigRule {
1596    components: Vec<u16>,
1597    ligature: u16,
1598}
1599
1600/// Parsed GSUB `liga` standard ligatures for a font. Built once via
1601/// [`Font::gsub_ligatures`]; [`Ligatures::substitute`] applies them.
1602#[derive(Clone, Debug, Default)]
1603pub struct Ligatures {
1604    /// first glyph id -> rules, sorted longest-component-run first.
1605    rules: std::collections::BTreeMap<u16, Vec<LigRule>>,
1606}
1607
1608impl Ligatures {
1609    /// True when the font defines no standard ligatures.
1610    #[must_use]
1611    pub fn is_empty(&self) -> bool {
1612        self.rules.is_empty()
1613    }
1614
1615    /// Apply ligature substitution to a glyph-id sequence (greedy longest match),
1616    /// returning the shaped sequence (which may contain ligature glyph ids that
1617    /// no single character maps to).
1618    #[must_use]
1619    pub fn substitute(&self, gids: &[u16]) -> Vec<u16> {
1620        self.substitute_with_spans(gids)
1621            .into_iter()
1622            .map(|(g, _)| g)
1623            .collect()
1624    }
1625
1626    /// Like [`Ligatures::substitute`] but pairs each output glyph with the number
1627    /// of input glyphs it consumed (1 for a pass-through, N for an N-component
1628    /// ligature) β€” so callers can map a ligature back to its source characters
1629    /// (e.g. to build a `ToUnicode` entry).
1630    #[must_use]
1631    pub fn substitute_with_spans(&self, gids: &[u16]) -> Vec<(u16, usize)> {
1632        let mut out = Vec::with_capacity(gids.len());
1633        let mut i = 0;
1634        while i < gids.len() {
1635            let mut applied = false;
1636            if let Some(rules) = self.rules.get(&gids[i]) {
1637                for r in rules {
1638                    let n = r.components.len();
1639                    if i + 1 + n <= gids.len() && gids[i + 1..i + 1 + n] == r.components[..] {
1640                        out.push((r.ligature, n + 1));
1641                        i += n + 1;
1642                        applied = true;
1643                        break;
1644                    }
1645                }
1646            }
1647            if !applied {
1648                out.push((gids[i], 1));
1649                i += 1;
1650            }
1651        }
1652        out
1653    }
1654}
1655
1656impl Font {
1657    /// Parse the GSUB `liga` standard-ligature substitutions once.
1658    ///
1659    /// Returns empty [`Ligatures`] when the font has no GSUB / no `liga` feature
1660    /// or the relevant offsets are malformed.
1661    #[must_use]
1662    pub fn gsub_ligatures(&self) -> Ligatures {
1663        self.parse_gsub_ligatures().unwrap_or_default()
1664    }
1665
1666    fn parse_gsub_ligatures(&self) -> Option<Ligatures> {
1667        let d = &self.data;
1668        let (gsub, _) = find_table_full(d, b"GSUB")?;
1669        let feature_list = off(gsub, be_u16_at(d, gsub, 6)? as usize)?;
1670        let lookup_list = off(gsub, be_u16_at(d, gsub, 8)? as usize)?;
1671
1672        // Collect every 'liga' feature's lookup indices.
1673        let feature_count = be_u16(d, feature_list)? as usize;
1674        let mut lookup_indices: Vec<u16> = Vec::new();
1675        for i in 0..feature_count {
1676            let rec = off_mul(off(feature_list, 2)?, i, 6)?;
1677            let Some(tag) = bytes_at(d, rec, 4) else {
1678                break;
1679            };
1680            if tag != b"liga" {
1681                continue;
1682            }
1683            let Some(feat_off) = be_u16_at(d, rec, 4) else {
1684                continue;
1685            };
1686            let Some(feat) = off(feature_list, feat_off as usize) else {
1687                continue;
1688            };
1689            let Some(n) = be_u16_at(d, feat, 2) else {
1690                continue;
1691            };
1692            for j in 0..n as usize {
1693                if let Some(idx) = off_mul(off(feat, 4)?, j, 2).and_then(|slot| be_u16(d, slot)) {
1694                    if !lookup_indices.contains(&idx) {
1695                        lookup_indices.push(idx);
1696                    }
1697                }
1698            }
1699        }
1700
1701        let lookup_count = be_u16(d, lookup_list)? as usize;
1702        let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
1703            std::collections::BTreeMap::new();
1704        for &li in &lookup_indices {
1705            let li = li as usize;
1706            if li >= lookup_count {
1707                continue;
1708            }
1709            let Some(lookup_off) =
1710                off_mul(off(lookup_list, 2)?, li, 2).and_then(|slot| be_u16(d, slot))
1711            else {
1712                continue;
1713            };
1714            let Some(lookup) = off(lookup_list, lookup_off as usize) else {
1715                continue;
1716            };
1717            let Some(lookup_type) = be_u16(d, lookup) else {
1718                continue;
1719            };
1720            let Some(sub_count) = be_u16_at(d, lookup, 4) else {
1721                continue;
1722            };
1723            for s in 0..sub_count as usize {
1724                let Some(sub_off) = off_mul(off(lookup, 6)?, s, 2).and_then(|slot| be_u16(d, slot))
1725                else {
1726                    continue;
1727                };
1728                let Some(sub) = off(lookup, sub_off as usize) else {
1729                    continue;
1730                };
1731                match lookup_type {
1732                    4 => parse_ligature_subst(d, sub, &mut rules),
1733                    // Extension Substitution -> a real type-4 subtable.
1734                    7 => {
1735                        if let Some((ext_type, real)) = resolve_extension(d, sub) {
1736                            if ext_type == 4 {
1737                                parse_ligature_subst(d, real, &mut rules);
1738                            }
1739                        }
1740                    }
1741                    _ => {}
1742                }
1743            }
1744        }
1745        // Greedy longest match: try the longest ligature first.
1746        for v in rules.values_mut() {
1747            v.sort_by_key(|r| std::cmp::Reverse(r.components.len()));
1748        }
1749        Some(Ligatures { rules })
1750    }
1751}
1752
1753/// Parse one Ligature Substitution subtable (GSUB `lookupType` 4) at `sub`.
1754fn parse_ligature_subst(
1755    d: &[u8],
1756    sub: usize,
1757    rules: &mut std::collections::BTreeMap<u16, Vec<LigRule>>,
1758) {
1759    let Some(format) = be_u16(d, sub) else {
1760        return;
1761    };
1762    if format != 1 {
1763        return;
1764    }
1765    let Some(cov_off) = be_u16_at(d, sub, 2) else {
1766        return;
1767    };
1768    let Some(set_count) = be_u16_at(d, sub, 4) else {
1769        return;
1770    };
1771    let Some(coverage) = off(sub, cov_off as usize).and_then(|cov| parse_coverage_glyphs(d, cov))
1772    else {
1773        return;
1774    };
1775    let Some(set_offsets) = off(sub, 6) else {
1776        return;
1777    };
1778    // Bound total work: LigatureSet/Ligature offsets may all alias one target, so
1779    // a font of O(set_count + lig_count) bytes can otherwise drive set_count *
1780    // lig_count iterations (and retained `LigRule`s) β€” an OOM-kill DoS. A valid
1781    // font has far fewer ligature entries than the glyph ceiling.
1782    let mut work: usize = 0;
1783    for i in 0..set_count as usize {
1784        work += 1;
1785        if work > MAX_LAYOUT_GLYPHS {
1786            return;
1787        }
1788        // LigatureSet i is for coverage glyph i (the ligature's first component).
1789        let Some(first) = coverage.get(i).copied() else {
1790            continue;
1791        };
1792        let Some(set_off) = off_mul(set_offsets, i, 2).and_then(|slot| be_u16(d, slot)) else {
1793            continue;
1794        };
1795        let Some(lig_set) = off(sub, set_off as usize) else {
1796            continue;
1797        };
1798        let Some(lig_count) = be_u16(d, lig_set) else {
1799            continue;
1800        };
1801        let Some(lig_offsets) = off(lig_set, 2) else {
1802            continue;
1803        };
1804        for j in 0..lig_count as usize {
1805            work += 1;
1806            if work > MAX_LAYOUT_GLYPHS {
1807                return;
1808            }
1809            let Some(lig_off) = off_mul(lig_offsets, j, 2).and_then(|slot| be_u16(d, slot)) else {
1810                continue;
1811            };
1812            let Some(lig) = off(lig_set, lig_off as usize) else {
1813                continue;
1814            };
1815            let Some(lig_glyph) = be_u16(d, lig) else {
1816                continue;
1817            };
1818            let Some(comp_count) = be_u16_at(d, lig, 2) else {
1819                continue;
1820            };
1821            if comp_count == 0 {
1822                continue;
1823            }
1824            // componentGlyphIDs holds comp_count-1 entries (the first is `first`).
1825            let mut components = Vec::with_capacity(comp_count as usize - 1);
1826            let mut ok = true;
1827            let Some(component_base) = off(lig, 4) else {
1828                continue;
1829            };
1830            for k in 0..(comp_count as usize - 1) {
1831                match off_mul(component_base, k, 2).and_then(|slot| be_u16(d, slot)) {
1832                    Some(g) => components.push(g),
1833                    None => {
1834                        ok = false;
1835                        break;
1836                    }
1837                }
1838            }
1839            if ok {
1840                rules.entry(first).or_default().push(LigRule {
1841                    components,
1842                    ligature: lig_glyph,
1843                });
1844            }
1845        }
1846    }
1847}
1848
1849#[cfg(test)]
1850#[cfg_attr(coverage_nightly, coverage(off))]
1851#[allow(clippy::indexing_slicing, clippy::unwrap_used)]
1852mod dos_tests {
1853    use super::{MAX_COVERAGE_GLYPHS, parse_coverage_glyphs};
1854
1855    fn be(v: u16) -> [u8; 2] {
1856        v.to_be_bytes()
1857    }
1858
1859    #[test]
1860    fn coverage_format2_valid_range_expands() {
1861        // format=2, rangeCount=1, range [10..=20] at coverage index 0.
1862        let mut d = Vec::new();
1863        d.extend_from_slice(&be(2));
1864        d.extend_from_slice(&be(1));
1865        d.extend_from_slice(&be(10)); // start
1866        d.extend_from_slice(&be(20)); // end
1867        d.extend_from_slice(&be(0)); // startCoverageIndex
1868        let got = parse_coverage_glyphs(&d, 0).unwrap();
1869        assert_eq!(got, (10u16..=20).collect::<Vec<_>>());
1870    }
1871
1872    #[test]
1873    fn coverage_format2_overclaiming_table_is_rejected_not_expanded() {
1874        // Two ranges each spanning 0..=65535 => total 131072 > the glyph ceiling,
1875        // so the parser must bail (None) instead of grinding billions of inserts.
1876        let mut d = Vec::new();
1877        d.extend_from_slice(&be(2));
1878        d.extend_from_slice(&be(2)); // rangeCount = 2
1879        for _ in 0..2 {
1880            d.extend_from_slice(&be(0)); // start
1881            d.extend_from_slice(&be(0xFFFF)); // end
1882            d.extend_from_slice(&be(0)); // startCoverageIndex
1883        }
1884        assert!(parse_coverage_glyphs(&d, 0).is_none());
1885        // Sanity: the ceiling is the font-wide glyph limit.
1886        assert_eq!(MAX_COVERAGE_GLYPHS, 65_536);
1887    }
1888}
1889
1890#[cfg(test)]
1891#[cfg_attr(coverage_nightly, coverage(off))]
1892#[allow(clippy::unwrap_used, clippy::expect_used)]
1893mod subset_degradation_tests {
1894    use super::{
1895        Font, MISSING_GLYPH_REMAP, be_i16, be_u16, find_table_full, strip_simple_glyph_instructions,
1896    };
1897
1898    // The bundled faces ship in-crate under `fmd-font/fonts/`.
1899    fn cm_regular() -> Font {
1900        let bytes = std::fs::read(concat!(
1901            env!("CARGO_MANIFEST_DIR"),
1902            "/fonts/computer-modern/cmunrm.ttf"
1903        ))
1904        .expect("read bundled font");
1905        Font::parse(bytes).expect("parse bundled font")
1906    }
1907
1908    fn all_faces() -> Vec<Font> {
1909        let base = env!("CARGO_MANIFEST_DIR");
1910        [
1911            "/fonts/computer-modern/cmunrm.ttf",
1912            "/fonts/computer-modern/cmunbx.ttf",
1913            "/fonts/computer-modern/cmunti.ttf",
1914            "/fonts/computer-modern/cmunbi.ttf",
1915            "/fonts/computer-modern/cmuntt.ttf",
1916            "/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
1917            "/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf",
1918            "/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf",
1919            "/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf",
1920        ]
1921        .iter()
1922        .filter_map(|p| Font::parse(std::fs::read(format!("{base}{p}")).ok()?).ok())
1923        .collect()
1924    }
1925
1926    fn test_remap(font: &Font, pairs: &[(u16, u16)]) -> Vec<u16> {
1927        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs).max(1)];
1928        for &(old, new) in pairs {
1929            if let Some(slot) = new_of.get_mut(usize::from(old)) {
1930                *slot = new;
1931            }
1932        }
1933        new_of
1934    }
1935
1936    fn simple_instruction_len(data: &[u8]) -> Option<usize> {
1937        let contours = be_i16(data, 0)?;
1938        if contours < 0 {
1939            return None;
1940        }
1941        let instruction_len_offset = 10usize.checked_add((contours as usize).checked_mul(2)?)?;
1942        be_u16(data, instruction_len_offset).map(usize::from)
1943    }
1944
1945    #[test]
1946    fn simple_glyph_instruction_stripper_zeroes_length_and_removes_bytes() {
1947        let mut glyph = Vec::new();
1948        glyph.extend_from_slice(&1i16.to_be_bytes()); // one contour
1949        glyph.extend_from_slice(&[0u8; 8]); // bbox
1950        glyph.extend_from_slice(&0u16.to_be_bytes()); // endPtsOfContours[0]
1951        glyph.extend_from_slice(&3u16.to_be_bytes()); // instructionLength
1952        glyph.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // instructions
1953        glyph.extend_from_slice(&[0x11, 0x22, 0x33]); // flag/coordinate payload
1954
1955        let stripped = strip_simple_glyph_instructions(&glyph, 1).expect("valid simple glyph");
1956        assert_eq!(simple_instruction_len(&stripped), Some(0));
1957        assert_eq!(stripped.len(), glyph.len() - 3);
1958        assert_eq!(&stripped[stripped.len() - 3..], &[0x11, 0x22, 0x33]);
1959    }
1960
1961    #[test]
1962    fn subset_glyph_bytes_strips_simple_instructions_when_present() {
1963        let Some((font, gid, original_len)) = all_faces().into_iter().find_map(|font| {
1964            (1..font.num_glyphs).find_map(|gid| {
1965                let data = font.glyph_data(gid)?;
1966                let len = simple_instruction_len(data)?;
1967                (len > 0).then_some((font.clone(), gid, len))
1968            })
1969        }) else {
1970            eprintln!("skipping: bundled fonts have no hinted simple glyphs");
1971            return;
1972        };
1973
1974        let new_of = test_remap(&font, &[(0u16, 0u16), (gid, 1u16)]);
1975
1976        let stripped = font
1977            .subset_glyph_bytes(gid, &new_of)
1978            .expect("hinted simple glyph should subset");
1979        assert_eq!(simple_instruction_len(&stripped), Some(0));
1980        assert_eq!(
1981            stripped.len(),
1982            font.glyph_data(gid).expect("original glyph").len() - original_len
1983        );
1984    }
1985
1986    #[test]
1987    fn subset_hmtx_preserves_true_left_side_bearings() {
1988        let (font, ch, old_gid, old_lsb) = all_faces()
1989            .into_iter()
1990            .find_map(|font| {
1991                (33u8..=126).find_map(|byte| {
1992                    let ch = char::from(byte);
1993                    let gid = font.glyph_index(ch);
1994                    let lsb = font.left_side_bearing(gid);
1995                    (gid != 0 && lsb != 0).then_some((font.clone(), ch, gid, lsb))
1996                })
1997            })
1998            .expect("at least one bundled printable glyph has a nonzero lsb");
1999
2000        let (bytes, remap) = font
2001            .subset_glyphs(&[old_gid], &[ch])
2002            .expect("subset with nonzero-lsb glyph");
2003        let subset = Font::parse(bytes).expect("subset re-parses");
2004        let new_gid = remap[&old_gid];
2005        assert_eq!(subset.left_side_bearing(new_gid), old_lsb);
2006    }
2007
2008    #[test]
2009    fn html_subset_carries_verbatim_os2_while_pdf_subset_stays_lean() {
2010        // Chromium's OpenType sanitizer (OTS) rejects web fonts without an
2011        // `OS/2` table ("OS/2: missing required table"), silently downgrading
2012        // HTML previews to system fonts. The HTML path (`subset`) must carry
2013        // the source table verbatim; the PDF path (`subset_glyphs`) must keep
2014        // omitting it so embedded font streams and golden PDFs stay identical.
2015        for font in all_faces() {
2016            let (src_off, src_len) = find_table_full(&font.data, b"OS/2")
2017                .expect("every bundled face carries an OS/2 table");
2018            let src_os2 = font.data[src_off..src_off + src_len].to_vec();
2019
2020            let html_bytes = font.subset(&['A', 'b']).expect("html subset");
2021            let html_font = Font::parse(html_bytes).expect("html subset re-parses");
2022            let (o, l) = find_table_full(&html_font.data, b"OS/2")
2023                .expect("html subset must keep OS/2 for browser sanitizers");
2024            assert_eq!(
2025                &html_font.data[o..o + l],
2026                &src_os2[..],
2027                "OS/2 must be copied verbatim"
2028            );
2029
2030            let gid = font.glyph_index('A');
2031            assert_ne!(gid, 0, "bundled faces must map 'A'");
2032            let (pdf_bytes, _) = font.subset_glyphs(&[gid], &['A']).expect("pdf subset");
2033            assert!(
2034                find_table_full(&pdf_bytes, b"OS/2").is_none(),
2035                "pdf subset must not grow an OS/2 table (golden bytes)"
2036            );
2037            assert!(Font::parse(pdf_bytes).is_ok());
2038        }
2039    }
2040
2041    #[test]
2042    fn subset_skips_cmap_char_whose_glyph_is_absent_from_the_set() {
2043        // `subset_glyphs` takes the glyph set explicitly but builds the cmap from
2044        // `cmap_chars`. A cmap char whose glyph is not in the set must be skipped,
2045        // not abort the whole subset (which would deny an otherwise-usable font).
2046        let font = cm_regular();
2047        let g_b = font.glyph_index('B');
2048        assert_ne!(g_b, 0, "test font must map 'B'");
2049        // Provide only B's glyph, but ask the cmap to also map 'A' (absent).
2050        let out = font.subset_glyphs(&[g_b], &['A', 'B']);
2051        let (bytes, _) = out.expect("un-subsettable cmap char must be skipped, not abort");
2052        // The produced subset must still be a parseable font.
2053        assert!(Font::parse(bytes).is_ok());
2054    }
2055
2056    #[test]
2057    fn simple_instruction_len_rejects_composite_data() {
2058        // The helper reads numberOfContours first; a negative count (composite)
2059        // has no simple-glyph instruction stream to measure.
2060        assert_eq!(simple_instruction_len(&(-1i16).to_be_bytes()), None);
2061    }
2062
2063    #[test]
2064    fn subset_glyph_bytes_substitutes_notdef_for_a_missing_component() {
2065        // A composite whose component is not in `new_of` (a malformed out-of-range
2066        // component gid) must be substituted with `.notdef`, not abort.
2067        let (font, comp) = all_faces()
2068            .into_iter()
2069            .find_map(|f| {
2070                (1..f.num_glyphs)
2071                    .find(|&g| f.is_composite(g))
2072                    .map(|g| (f, g))
2073            })
2074            .expect("at least one bundled face has a composite glyph");
2075        // Map .notdef and the composite itself, but NONE of its components, so the
2076        // component lookup misses and must fall back to gid 0.
2077        let new_of = test_remap(&font, &[(0u16, 0u16), (comp, 1u16)]);
2078        let bytes = font
2079            .subset_glyph_bytes(comp, &new_of)
2080            .expect("missing component must be substituted, not abort");
2081        assert!(!bytes.is_empty(), "a composite glyph is non-empty");
2082    }
2083}
2084
2085#[cfg(test)]
2086#[cfg_attr(coverage_nightly, coverage(off))]
2087#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
2088mod synthetic_font_tests {
2089    use super::*;
2090
2091    // --- byte-level builders ------------------------------------------------
2092
2093    fn push16(out: &mut Vec<u8>, v: u16) {
2094        out.extend_from_slice(&v.to_be_bytes());
2095    }
2096
2097    fn push_i16(out: &mut Vec<u8>, v: i16) {
2098        out.extend_from_slice(&v.to_be_bytes());
2099    }
2100
2101    fn push32(out: &mut Vec<u8>, v: u32) {
2102        out.extend_from_slice(&v.to_be_bytes());
2103    }
2104
2105    /// Assemble an sfnt file. The directory records each table's real length;
2106    /// truncation tests chop bytes off the end of the returned file afterwards
2107    /// (the directory keeps claiming the full length, exactly like a damaged
2108    /// or malicious font would).
2109    fn sfnt(magic: u32, tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
2110        let mut out = Vec::new();
2111        push32(&mut out, magic);
2112        push16(&mut out, u16::try_from(tables.len()).unwrap());
2113        out.extend_from_slice(&[0u8; 6]); // search fields: unread by the parser
2114        let mut offset = 12 + tables.len() * 16;
2115        let mut body = Vec::new();
2116        for (tag, bytes) in tables {
2117            out.extend_from_slice(&tag[..]);
2118            push32(&mut out, 0); // checksum: unread by the parser
2119            push32(&mut out, u32::try_from(offset).unwrap());
2120            push32(&mut out, u32::try_from(bytes.len()).unwrap());
2121            offset += bytes.len();
2122            body.extend_from_slice(bytes);
2123        }
2124        out.extend_from_slice(&body);
2125        out
2126    }
2127
2128    fn head_table(upem: u16, loca_long: bool) -> Vec<u8> {
2129        let mut t = vec![0u8; 54];
2130        t[18..20].copy_from_slice(&upem.to_be_bytes());
2131        t[50..52].copy_from_slice(&u16::from(loca_long).to_be_bytes());
2132        t
2133    }
2134
2135    fn maxp_table(num_glyphs: u16) -> Vec<u8> {
2136        let mut t = vec![0u8; 6];
2137        t[4..6].copy_from_slice(&num_glyphs.to_be_bytes());
2138        t
2139    }
2140
2141    fn hhea_table(num_h_metrics: u16) -> Vec<u8> {
2142        let mut t = vec![0u8; 36];
2143        t[4..6].copy_from_slice(&700i16.to_be_bytes());
2144        t[6..8].copy_from_slice(&(-200i16).to_be_bytes());
2145        t[8..10].copy_from_slice(&50i16.to_be_bytes());
2146        t[34..36].copy_from_slice(&num_h_metrics.to_be_bytes());
2147        t
2148    }
2149
2150    fn hmtx_long(metrics: &[(u16, i16)]) -> Vec<u8> {
2151        let mut t = Vec::new();
2152        for &(aw, lsb) in metrics {
2153            push16(&mut t, aw);
2154            push_i16(&mut t, lsb);
2155        }
2156        t
2157    }
2158
2159    /// A complete `cmap` table holding one format-4 `(3,1)` subtable built from
2160    /// raw `(endCode, startCode, idDelta, idRangeOffset)` segments, followed by
2161    /// `glyph_id_array` bytes.
2162    fn cmap4_table(segs: &[(u16, u16, u16, u16)], glyph_id_array: &[u8]) -> Vec<u8> {
2163        let seg_count = segs.len();
2164        let mut t = Vec::new();
2165        push16(&mut t, 0); // version
2166        push16(&mut t, 1); // numTables
2167        push16(&mut t, 3); // platformID (Windows)
2168        push16(&mut t, 1); // encodingID (Unicode BMP)
2169        push32(&mut t, 12); // subtable offset
2170        push16(&mut t, 4); // format
2171        push16(
2172            &mut t,
2173            u16::try_from(16 + seg_count * 8 + glyph_id_array.len()).unwrap(),
2174        );
2175        push16(&mut t, 0); // language
2176        push16(&mut t, u16::try_from(seg_count * 2).unwrap()); // segCountX2
2177        push16(&mut t, 0); // searchRange (unread)
2178        push16(&mut t, 0); // entrySelector (unread)
2179        push16(&mut t, 0); // rangeShift (unread)
2180        for &(end, _, _, _) in segs {
2181            push16(&mut t, end);
2182        }
2183        push16(&mut t, 0); // reservedPad
2184        for &(_, start, _, _) in segs {
2185            push16(&mut t, start);
2186        }
2187        for &(_, _, delta, _) in segs {
2188            push16(&mut t, delta);
2189        }
2190        for &(_, _, _, iro) in segs {
2191            push16(&mut t, iro);
2192        }
2193        t.extend_from_slice(glyph_id_array);
2194        t
2195    }
2196
2197    /// A format-4 cmap mapping each ascending `(code, gid)` pair via its own
2198    /// delta-only segment, plus the mandatory final 0xFFFF segment.
2199    fn cmap4_simple(map: &[(u16, u16)]) -> Vec<u8> {
2200        let mut segs: Vec<(u16, u16, u16, u16)> = map
2201            .iter()
2202            .map(|&(code, gid)| (code, code, gid.wrapping_sub(code), 0))
2203            .collect();
2204        segs.push((0xFFFF, 0xFFFF, 1, 0));
2205        cmap4_table(&segs, &[])
2206    }
2207
2208    /// A complete `cmap` table holding one format-12 `(3,10)` subtable.
2209    fn cmap12_table(groups: &[(u32, u32, u32)]) -> Vec<u8> {
2210        let mut t = Vec::new();
2211        push16(&mut t, 0); // version
2212        push16(&mut t, 1); // numTables
2213        push16(&mut t, 3); // platformID (Windows)
2214        push16(&mut t, 10); // encodingID (Unicode full repertoire)
2215        push32(&mut t, 12); // subtable offset
2216        push16(&mut t, 12); // format
2217        push16(&mut t, 0); // reserved
2218        push32(&mut t, u32::try_from(16 + groups.len() * 12).unwrap());
2219        push32(&mut t, 0); // language
2220        push32(&mut t, u32::try_from(groups.len()).unwrap());
2221        for &(start, end, gid) in groups {
2222            push32(&mut t, start);
2223            push32(&mut t, end);
2224            push32(&mut t, gid);
2225        }
2226        t
2227    }
2228
2229    fn base_tables(
2230        num_glyphs: u16,
2231        num_h_metrics: u16,
2232        upem: u16,
2233        hmtx: Vec<u8>,
2234        cmap: Vec<u8>,
2235    ) -> Vec<(&'static [u8; 4], Vec<u8>)> {
2236        vec![
2237            (b"head", head_table(upem, false)),
2238            (b"maxp", maxp_table(num_glyphs)),
2239            (b"hhea", hhea_table(num_h_metrics)),
2240            (b"hmtx", hmtx),
2241            (b"cmap", cmap),
2242        ]
2243    }
2244
2245    fn parse(tables: &[(&[u8; 4], Vec<u8>)]) -> Font {
2246        Font::parse(sfnt(0x0001_0000, tables)).expect("synthetic font parses")
2247    }
2248
2249    // --- glyph builders -------------------------------------------------
2250
2251    const ARGW: u16 = 0x0001; // ARG_1_AND_2_ARE_WORDS
2252    const WHS: u16 = 0x0008; // WE_HAVE_A_SCALE
2253    const MORE: u16 = 0x0020; // MORE_COMPONENTS
2254    const XYS: u16 = 0x0040; // WE_HAVE_AN_X_AND_Y_SCALE
2255    const TWO: u16 = 0x0080; // WE_HAVE_A_TWO_BY_TWO
2256    const INSTR: u16 = 0x0100; // WE_HAVE_INSTRUCTIONS
2257
2258    /// A composite glyph: each record is `(flags, component gid, arg/scale
2259    /// payload)`; `trailer` bytes follow the last record.
2260    fn composite_glyph(bbox: [i16; 4], records: &[(u16, u16, &[u8])], trailer: &[u8]) -> Vec<u8> {
2261        let mut g = Vec::new();
2262        push_i16(&mut g, -1);
2263        for v in bbox {
2264            push_i16(&mut g, v);
2265        }
2266        for &(flags, gid, payload) in records {
2267            push16(&mut g, flags);
2268            push16(&mut g, gid);
2269            g.extend_from_slice(payload);
2270        }
2271        g.extend_from_slice(trailer);
2272        g
2273    }
2274
2275    /// A minimal valid hint-free simple glyph (16 bytes).
2276    fn simple_glyph16() -> Vec<u8> {
2277        let mut g = Vec::new();
2278        push_i16(&mut g, 1); // numberOfContours
2279        g.extend_from_slice(&[0u8; 8]); // bbox
2280        push16(&mut g, 0); // endPtsOfContours[0]
2281        push16(&mut g, 0); // instructionLength
2282        g.extend_from_slice(&[0x01, 0x00]); // flag + coordinate payload
2283        g
2284    }
2285
2286    /// Glyph zoo: 0 empty, 1 bare simple stub, 2/3/4 composites using the three
2287    /// transform payload sizes, 5 valid simple, 6 word-args + MORE chain,
2288    /// 7 MORE record ending exactly at the glyph end, 8 overlong instruction
2289    /// claim, 9 component gid past numGlyphs, 10 trailing junk after the last
2290    /// record, 11 transform payload overrunning the glyph, 12 valid composite
2291    /// instructions.
2292    fn zoo_font() -> Font {
2293        let glyphs: Vec<Vec<u8>> = vec![
2294            Vec::new(),
2295            1i16.to_be_bytes().to_vec(),
2296            composite_glyph([1, 2, 3, 4], &[(WHS, 5, &[0, 0, 0x40, 0])], &[]),
2297            composite_glyph([0; 4], &[(XYS, 5, &[0, 0, 0x40, 0, 0x40, 0])], &[]),
2298            composite_glyph(
2299                [0; 4],
2300                &[(TWO, 5, &[0, 0, 0x40, 0, 0, 0, 0, 0, 0x40, 0])],
2301                &[],
2302            ),
2303            simple_glyph16(),
2304            composite_glyph(
2305                [0; 4],
2306                &[(ARGW | MORE, 2, &[0, 0, 0, 0]), (0, 3, &[0, 0])],
2307                &[],
2308            ),
2309            composite_glyph([0; 4], &[(MORE, 5, &[0, 0])], &[]),
2310            composite_glyph([0; 4], &[(INSTR, 5, &[0, 0])], &[0xFF, 0xFF]),
2311            composite_glyph([0; 4], &[(0, 900, &[0, 0])], &[]),
2312            composite_glyph([0; 4], &[(MORE, 5, &[0, 0])], &[0, 0]),
2313            composite_glyph([0; 4], &[(TWO, 5, &[0, 0, 0x40, 0])], &[]),
2314            composite_glyph([0; 4], &[(INSTR, 5, &[0, 0])], &[0x00, 0x02, 0xAA, 0xBB]),
2315        ];
2316        let mut glyf = Vec::new();
2317        let mut loca = Vec::new();
2318        push16(&mut loca, 0);
2319        for g in &glyphs {
2320            glyf.extend_from_slice(g);
2321            push16(&mut loca, u16::try_from(glyf.len() / 2).unwrap());
2322        }
2323        let metrics: Vec<(u16, i16)> = (0..13u16).map(|g| (500 + g, g as i16)).collect();
2324        let mut tables = base_tables(13, 13, 1000, hmtx_long(&metrics), cmap4_simple(&[]));
2325        tables.push((b"loca", loca));
2326        tables.push((b"glyf", glyf));
2327        parse(&tables)
2328    }
2329
2330    /// One composite glyph whose loca/glyf directory claims 16 bytes while the
2331    /// file physically ends after `keep` of them.
2332    fn truncated_composite_font(keep: usize) -> Font {
2333        let glyph = composite_glyph([0; 4], &[(0, 5, &[0, 0])], &[]);
2334        assert_eq!(glyph.len(), 16);
2335        let mut loca = Vec::new();
2336        push16(&mut loca, 0);
2337        push16(&mut loca, 8);
2338        let mut tables = base_tables(1, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_simple(&[]));
2339        tables.push((b"loca", loca));
2340        tables.push((b"glyf", glyph));
2341        let mut bytes = sfnt(0x0001_0000, &tables);
2342        bytes.truncate(bytes.len() - (16 - keep));
2343        Font::parse(bytes).expect("glyf payload is lazily read")
2344    }
2345
2346    /// A `kern` table with one version-0 format-0 horizontal subtable.
2347    fn kern0_table(pairs: &[(u16, u16, i16)]) -> Vec<u8> {
2348        let mut t = Vec::new();
2349        push16(&mut t, 0); // version
2350        push16(&mut t, 1); // nTables
2351        push16(&mut t, 0); // subtable version
2352        push16(&mut t, u16::try_from(14 + pairs.len() * 6).unwrap()); // length
2353        push16(&mut t, 0x0001); // coverage: horizontal, format 0
2354        push16(&mut t, u16::try_from(pairs.len()).unwrap()); // nPairs
2355        t.extend_from_slice(&[0u8; 6]); // search fields (unread)
2356        for &(l, r, v) in pairs {
2357            push16(&mut t, l);
2358            push16(&mut t, r);
2359            push_i16(&mut t, v);
2360        }
2361        t
2362    }
2363
2364    /// Raw GPOS table: a `kern` feature routing through an Extension (type 9)
2365    /// lookup to a Pair Adjustment format-1 subtable holding (5, 6) -> -40.
2366    fn gpos_table(ext_format: u16, ext_type: u16, lookup_index: u16, pos_format: u16) -> Vec<u8> {
2367        let mut g = Vec::new();
2368        push32(&mut g, 0x0001_0000); // version
2369        push16(&mut g, 0); // scriptList (unread)
2370        push16(&mut g, 10); // featureList
2371        push16(&mut g, 24); // lookupList
2372        // FeatureList @10
2373        push16(&mut g, 1); // featureCount
2374        g.extend_from_slice(b"kern");
2375        push16(&mut g, 8); // feature @ featureList+8
2376        // Feature @18
2377        push16(&mut g, 0); // featureParams
2378        push16(&mut g, 1); // lookupIndexCount
2379        push16(&mut g, lookup_index);
2380        // LookupList @24
2381        push16(&mut g, 1); // lookupCount
2382        push16(&mut g, 4); // lookup @ lookupList+4
2383        // Lookup @28: Extension Positioning
2384        push16(&mut g, 9); // lookupType
2385        push16(&mut g, 0); // lookupFlag
2386        push16(&mut g, 1); // subTableCount
2387        push16(&mut g, 8); // subtable @ lookup+8
2388        // Extension @36
2389        push16(&mut g, ext_format);
2390        push16(&mut g, ext_type);
2391        push32(&mut g, 8); // wrapped subtable @ 36+8
2392        // PairPos @44
2393        push16(&mut g, pos_format);
2394        push16(&mut g, 18); // coverage @ 44+18
2395        push16(&mut g, 0x0004); // valueFormat1: X_ADVANCE
2396        push16(&mut g, 0); // valueFormat2
2397        push16(&mut g, 1); // pairSetCount
2398        push16(&mut g, 12); // pair set @ 44+12
2399        // PairSet @56
2400        push16(&mut g, 1); // pairValueCount
2401        push16(&mut g, 6); // secondGlyph
2402        push_i16(&mut g, -40); // xAdvance
2403        // Coverage @62
2404        push16(&mut g, 1);
2405        push16(&mut g, 1);
2406        push16(&mut g, 5);
2407        assert_eq!(g.len(), 68);
2408        g
2409    }
2410
2411    /// Attach raw GPOS bytes (as the last table, so shortened tables truncate
2412    /// the file) and parse its kerning.
2413    fn gpos_kerning_of(table: Vec<u8>) -> Kerning {
2414        let mut tables = base_tables(
2415            2,
2416            2,
2417            1000,
2418            hmtx_long(&[(600, 0), (600, 0)]),
2419            cmap4_simple(&[]),
2420        );
2421        tables.push((b"GPOS", table));
2422        parse(&tables).gpos_kerning()
2423    }
2424
2425    fn gpos_font(ext_format: u16, ext_type: u16, lookup_index: u16, pos_format: u16) -> Kerning {
2426        gpos_kerning_of(gpos_table(ext_format, ext_type, lookup_index, pos_format))
2427    }
2428
2429    /// Raw GSUB table: a `liga` feature routing through an Extension (type 7)
2430    /// lookup to a LigatureSubst with (10,11,12)->99 and (10,11)->77.
2431    fn gsub_table(ext_format: u16, ext_type: u16, lookup_index: u16) -> Vec<u8> {
2432        let mut g = Vec::new();
2433        push32(&mut g, 0x0001_0000);
2434        push16(&mut g, 0); // scriptList (unread)
2435        push16(&mut g, 10); // featureList
2436        push16(&mut g, 24); // lookupList
2437        // FeatureList @10
2438        push16(&mut g, 1);
2439        g.extend_from_slice(b"liga");
2440        push16(&mut g, 8); // feature @18
2441        // Feature @18
2442        push16(&mut g, 0);
2443        push16(&mut g, 1);
2444        push16(&mut g, lookup_index);
2445        // LookupList @24
2446        push16(&mut g, 1);
2447        push16(&mut g, 4); // lookup @28
2448        // Lookup @28: Extension Substitution
2449        push16(&mut g, 7);
2450        push16(&mut g, 0);
2451        push16(&mut g, 1);
2452        push16(&mut g, 8); // subtable @36
2453        // Extension @36
2454        push16(&mut g, ext_format);
2455        push16(&mut g, ext_type);
2456        push32(&mut g, 8); // wrapped subtable @44
2457        // LigatureSubst @44
2458        push16(&mut g, 1); // substFormat
2459        push16(&mut g, 28); // coverage @ 44+28
2460        push16(&mut g, 1); // ligSetCount
2461        push16(&mut g, 8); // ligature set @ 44+8
2462        // LigatureSet @52
2463        push16(&mut g, 2); // ligatureCount
2464        push16(&mut g, 6); // ligature @ 52+6
2465        push16(&mut g, 14); // ligature @ 52+14
2466        // Ligature @58: components (10, 11, 12) -> 99
2467        push16(&mut g, 99);
2468        push16(&mut g, 3);
2469        push16(&mut g, 11);
2470        push16(&mut g, 12);
2471        // Ligature @66: components (10, 11) -> 77
2472        push16(&mut g, 77);
2473        push16(&mut g, 2);
2474        push16(&mut g, 11);
2475        // Coverage @72
2476        push16(&mut g, 1);
2477        push16(&mut g, 1);
2478        push16(&mut g, 10);
2479        assert_eq!(g.len(), 78);
2480        g
2481    }
2482
2483    /// Attach raw GSUB bytes (as the last table) and parse its ligatures.
2484    fn gsub_ligatures_of(table: Vec<u8>) -> Ligatures {
2485        let mut tables = base_tables(
2486            2,
2487            2,
2488            1000,
2489            hmtx_long(&[(600, 0), (600, 0)]),
2490            cmap4_simple(&[]),
2491        );
2492        tables.push((b"GSUB", table));
2493        parse(&tables).gsub_ligatures()
2494    }
2495
2496    fn gsub_font(ext_format: u16, ext_type: u16, lookup_index: u16) -> Ligatures {
2497        gsub_ligatures_of(gsub_table(ext_format, ext_type, lookup_index))
2498    }
2499
2500    // --- parse errors and magics ---------------------------------------
2501
2502    #[test]
2503    fn parse_error_variants_and_display_messages() {
2504        assert_eq!(Font::parse(Vec::new()).err(), Some(FontError::Truncated));
2505        assert_eq!(
2506            Font::parse(vec![0x00, 0x02, 0x00, 0x00]).err(),
2507            Some(FontError::BadMagic)
2508        );
2509
2510        // Required tables are demanded in a fixed order.
2511        let mut tables: Vec<(&[u8; 4], Vec<u8>)> = Vec::new();
2512        let steps: [(&'static [u8; 4], &'static str, Vec<u8>); 4] = [
2513            (b"head", "head", head_table(1000, false)),
2514            (b"maxp", "maxp", maxp_table(1)),
2515            (b"hhea", "hhea", hhea_table(1)),
2516            (b"hmtx", "hmtx", hmtx_long(&[(500, 0)])),
2517        ];
2518        for (tag, name, table) in steps {
2519            assert_eq!(
2520                Font::parse(sfnt(0x0001_0000, &tables)).err(),
2521                Some(FontError::MissingTable(name))
2522            );
2523            tables.push((tag, table));
2524        }
2525        assert_eq!(
2526            Font::parse(sfnt(0x0001_0000, &tables)).err(),
2527            Some(FontError::MissingTable("cmap"))
2528        );
2529
2530        // A cmap with only a Mac record and an unsupported-format Windows
2531        // record has no usable Unicode subtable.
2532        let mut bad_cmap = Vec::new();
2533        push16(&mut bad_cmap, 0);
2534        push16(&mut bad_cmap, 2);
2535        push16(&mut bad_cmap, 1); // platform 1 (Macintosh): not Unicode
2536        push16(&mut bad_cmap, 0);
2537        push32(&mut bad_cmap, 20);
2538        push16(&mut bad_cmap, 3); // (3,1) but pointing at a format-6 subtable
2539        push16(&mut bad_cmap, 1);
2540        push32(&mut bad_cmap, 20);
2541        push16(&mut bad_cmap, 6); // subtable @20: format 6 (unsupported)
2542        tables.push((b"cmap", bad_cmap));
2543        assert_eq!(
2544            Font::parse(sfnt(0x0001_0000, &tables)).err(),
2545            Some(FontError::NoUnicodeCmap)
2546        );
2547
2548        // All tables found, but the file ends before head's unitsPerEm.
2549        let short_head: Vec<(&[u8; 4], Vec<u8>)> = vec![
2550            (b"maxp", maxp_table(1)),
2551            (b"hhea", hhea_table(1)),
2552            (b"hmtx", hmtx_long(&[(500, 0)])),
2553            (b"cmap", cmap4_simple(&[])),
2554            (b"head", vec![0u8; 10]),
2555        ];
2556        assert_eq!(
2557            Font::parse(sfnt(0x0001_0000, &short_head)).err(),
2558            Some(FontError::Truncated)
2559        );
2560
2561        assert_eq!(
2562            FontError::BadMagic.to_string(),
2563            "not a TrueType/OpenType font"
2564        );
2565        assert_eq!(
2566            FontError::MissingTable("hhea").to_string(),
2567            "missing required font table: hhea"
2568        );
2569        assert_eq!(FontError::Truncated.to_string(), "font data is truncated");
2570        assert_eq!(
2571            FontError::NoUnicodeCmap.to_string(),
2572            "no usable Unicode cmap (format 4/12)"
2573        );
2574    }
2575
2576    #[test]
2577    fn parse_accepts_true_and_otto_magics() {
2578        let tables = base_tables(
2579            3,
2580            3,
2581            2048,
2582            hmtx_long(&[(500, 1), (510, 2), (520, 3)]),
2583            cmap4_simple(&[(0x41, 1)]),
2584        );
2585        let t = Font::parse(sfnt(0x7472_7565, &tables)).expect("'true' magic parses");
2586        assert_eq!(t.units_per_em, 2048);
2587        assert_eq!(t.num_glyphs, 3);
2588        assert_eq!(t.ascent, 700);
2589        assert_eq!(t.descent, -200);
2590        assert_eq!(t.line_gap, 50);
2591        assert_eq!(t.glyph_index('A'), 1);
2592
2593        // CFF-flavored fonts parse for metrics but expose no glyf outlines.
2594        let o = Font::parse(sfnt(0x4F54_544F, &tables)).expect("'OTTO' magic parses");
2595        assert!(!o.has_glyf_outlines());
2596        assert_eq!(o.subset(&['A']), None);
2597        assert_eq!(o.glyph_bbox(1), None);
2598        assert_eq!(o.glyph_data(1), None);
2599        assert!(!o.is_composite(1));
2600        assert!(o.glyph_components(1).is_empty());
2601    }
2602
2603    // --- hmtx edges -------------------------------------------------------
2604
2605    #[test]
2606    fn left_side_bearing_reads_trailing_run_and_zero_metrics() {
2607        // 3 glyphs, 1 long metric: gid 0 keeps (advance, lsb); gids 1..2 share
2608        // the last advance but read their own trailing i16 lsb.
2609        let mut hmtx = hmtx_long(&[(500, 50)]);
2610        push_i16(&mut hmtx, -7);
2611        push_i16(&mut hmtx, 33);
2612        let font = parse(&base_tables(3, 1, 1000, hmtx, cmap4_simple(&[])));
2613        assert_eq!(font.left_side_bearing(0), 50);
2614        assert_eq!(font.left_side_bearing(1), -7);
2615        assert_eq!(font.left_side_bearing(2), 33);
2616        assert_eq!(font.advance_width(0), 500);
2617        assert_eq!(font.advance_width(2), 500);
2618
2619        // A face declaring zero hMetrics reports zero bearings.
2620        let font0 = parse(&base_tables(1, 0, 1000, Vec::new(), cmap4_simple(&[])));
2621        assert_eq!(font0.left_side_bearing(0), 0);
2622    }
2623
2624    // --- legacy kern --------------------------------------------------------
2625
2626    #[test]
2627    fn legacy_kern_pair_and_char_kerning() {
2628        let pairs = [(1u16, 2u16, -30i16), (1, 3, 15), (4, 1, 7)];
2629        let metrics: Vec<(u16, i16)> = (0..8u16).map(|g| (600 + g, 0)).collect();
2630        let mut tables = base_tables(
2631            8,
2632            8,
2633            1000,
2634            hmtx_long(&metrics),
2635            cmap4_simple(&[(0x41, 1), (0x56, 2)]),
2636        );
2637        tables.push((b"kern", kern0_table(&pairs)));
2638        let font = parse(&tables);
2639        assert_eq!(font.kerning_between_glyphs(1, 2), -30);
2640        assert_eq!(font.kerning_between_glyphs(1, 3), 15);
2641        assert_eq!(font.kerning_between_glyphs(4, 1), 7);
2642        assert_eq!(font.kerning_between_glyphs(2, 1), 0);
2643        assert_eq!(font.kerning_between_glyphs(1, 4), 0);
2644        assert_eq!(font.kerning('A', 'V'), -30);
2645        assert_eq!(font.kerning_1000('A', 'V'), -30); // upem == 1000
2646        assert_eq!(font.advance_1000('A'), 601);
2647
2648        // unitsPerEm == 0 short-circuits both per-mille scalers.
2649        let mut zero = base_tables(
2650            8,
2651            8,
2652            0,
2653            hmtx_long(&metrics),
2654            cmap4_simple(&[(0x41, 1), (0x56, 2)]),
2655        );
2656        zero.push((b"kern", kern0_table(&pairs)));
2657        let z = parse(&zero);
2658        assert_eq!(z.units_per_em, 0);
2659        assert_eq!(z.advance_1000('A'), 0);
2660        assert_eq!(z.kerning_1000('A', 'V'), 0);
2661    }
2662
2663    #[test]
2664    fn legacy_kern_skips_short_vertical_minimum_and_format2_subtables() {
2665        let mut k = Vec::new();
2666        push16(&mut k, 0); // version
2667        push16(&mut k, 5); // nTables
2668        // horizontal format 0 but length < 14: skipped
2669        push16(&mut k, 0);
2670        push16(&mut k, 10);
2671        push16(&mut k, 0x0001);
2672        k.extend_from_slice(&[0u8; 4]);
2673        // vertical (horizontal bit clear)
2674        push16(&mut k, 0);
2675        push16(&mut k, 14);
2676        push16(&mut k, 0x0000);
2677        k.extend_from_slice(&[0u8; 8]);
2678        // minimum-values bit set
2679        push16(&mut k, 0);
2680        push16(&mut k, 14);
2681        push16(&mut k, 0x0003);
2682        k.extend_from_slice(&[0u8; 8]);
2683        // format 2
2684        push16(&mut k, 0);
2685        push16(&mut k, 14);
2686        push16(&mut k, 0x0201);
2687        k.extend_from_slice(&[0u8; 8]);
2688        // the real horizontal format-0 subtable
2689        push16(&mut k, 0);
2690        push16(&mut k, 20);
2691        push16(&mut k, 0x0001);
2692        push16(&mut k, 1); // nPairs
2693        k.extend_from_slice(&[0u8; 6]);
2694        push16(&mut k, 3);
2695        push16(&mut k, 4);
2696        push_i16(&mut k, -11);
2697
2698        let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
2699        tables.push((b"kern", k));
2700        let font = parse(&tables);
2701        assert_eq!(font.kerning_between_glyphs(3, 4), -11);
2702        assert_eq!(font.kerning_between_glyphs(3, 5), 0);
2703    }
2704
2705    #[test]
2706    fn legacy_kern_rejects_malformed_table_headers() {
2707        fn kern_font(kern: Vec<u8>) -> Font {
2708            let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
2709            tables.push((b"kern", kern));
2710            parse(&tables)
2711        }
2712        // Table version != 0 (e.g. AAT kern 1.0): ignored entirely.
2713        let mut v1 = kern0_table(&[(1, 2, -30)]);
2714        v1[0..2].copy_from_slice(&1u16.to_be_bytes());
2715        assert_eq!(kern_font(v1).kerning_between_glyphs(1, 2), 0);
2716
2717        // A zero-length subtable would never advance: bail.
2718        let mut zero_len = Vec::new();
2719        push16(&mut zero_len, 0);
2720        push16(&mut zero_len, 2);
2721        push16(&mut zero_len, 0); // subtable version
2722        push16(&mut zero_len, 0); // length 0
2723        push16(&mut zero_len, 0x0000); // vertical, so the format match misses
2724        zero_len.extend_from_slice(&[0u8; 8]);
2725        assert_eq!(kern_font(zero_len).kerning_between_glyphs(1, 2), 0);
2726
2727        // nTables claims a second subtable beyond the table end.
2728        let mut walk_off = Vec::new();
2729        push16(&mut walk_off, 0);
2730        push16(&mut walk_off, 2);
2731        push16(&mut walk_off, 0);
2732        push16(&mut walk_off, 14);
2733        push16(&mut walk_off, 0x0000); // vertical: skipped
2734        walk_off.extend_from_slice(&[0u8; 8]);
2735        assert_eq!(kern_font(walk_off).kerning_between_glyphs(1, 2), 0);
2736
2737        // Subtable length overrunning the kern table itself.
2738        let mut overlong = Vec::new();
2739        push16(&mut overlong, 0);
2740        push16(&mut overlong, 1);
2741        push16(&mut overlong, 0);
2742        push16(&mut overlong, 200); // sub_end > table_end
2743        push16(&mut overlong, 0x0001);
2744        overlong.extend_from_slice(&[0u8; 8]);
2745        assert_eq!(kern_font(overlong).kerning_between_glyphs(1, 2), 0);
2746
2747        // nPairs needing more bytes than the subtable declares.
2748        let mut hungry = Vec::new();
2749        push16(&mut hungry, 0);
2750        push16(&mut hungry, 1);
2751        push16(&mut hungry, 0);
2752        push16(&mut hungry, 20); // room for exactly one pair
2753        push16(&mut hungry, 0x0001);
2754        push16(&mut hungry, 3); // nPairs 3: needs 18 pair bytes, has 6
2755        hungry.extend_from_slice(&[0u8; 12]);
2756        assert_eq!(kern_font(hungry).kerning_between_glyphs(1, 2), 0);
2757
2758        // A single skipped subtable: the walk ends without a match.
2759        let mut vertical_only = Vec::new();
2760        push16(&mut vertical_only, 0);
2761        push16(&mut vertical_only, 1);
2762        push16(&mut vertical_only, 0);
2763        push16(&mut vertical_only, 14);
2764        push16(&mut vertical_only, 0x0000);
2765        vertical_only.extend_from_slice(&[0u8; 8]);
2766        assert_eq!(kern_font(vertical_only).kerning_between_glyphs(1, 2), 0);
2767    }
2768
2769    #[test]
2770    fn legacy_kern_truncated_pair_records_kern_to_zero() {
2771        // kern is the last table; its directory claims 4 pair records but the
2772        // file ends inside them, so binary-search probes hit EOF and yield 0.
2773        // chop 10 leaves record 2's left glyph readable (right glyph missing);
2774        // chop 16 removes even the left glyph of the probed record.
2775        let pairs = [(1u16, 2u16, -30i16), (1, 3, 15), (4, 1, 7), (5, 5, 9)];
2776        for chop in [10usize, 16] {
2777            let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
2778            tables.push((b"kern", kern0_table(&pairs)));
2779            let mut bytes = sfnt(0x0001_0000, &tables);
2780            bytes.truncate(bytes.len() - chop);
2781            let font = Font::parse(bytes).expect("kern pair payload is lazily read");
2782            assert_eq!(font.kerning_between_glyphs(1, 2), 0, "chop={chop}");
2783        }
2784    }
2785
2786    // --- glyf / loca edges ----------------------------------------------
2787
2788    #[test]
2789    fn glyph_range_rejects_inverted_and_overlong_loca_entries() {
2790        // loca (short) = [4, 2, 6]: glyph 0 is inverted (end < start); glyph 1
2791        // claims [2, 6) but the glyf table is only 4 bytes long.
2792        let mut loca = Vec::new();
2793        push16(&mut loca, 2);
2794        push16(&mut loca, 1);
2795        push16(&mut loca, 3);
2796        let mut tables = base_tables(
2797            2,
2798            2,
2799            1000,
2800            hmtx_long(&[(500, 0), (500, 0)]),
2801            cmap4_simple(&[]),
2802        );
2803        tables.push((b"loca", loca));
2804        tables.push((b"glyf", vec![0u8; 4]));
2805        let font = parse(&tables);
2806        assert!(font.has_glyf_outlines());
2807        assert_eq!(font.glyph_data(0), None);
2808        assert_eq!(font.glyph_data(1), None);
2809        assert_eq!(font.glyph_bbox(0), None);
2810        assert!(!font.is_composite(0));
2811    }
2812
2813    #[test]
2814    fn glyph_components_walk_all_transform_variants() {
2815        let font = zoo_font();
2816        assert!(font.glyph_components(0).is_empty()); // empty glyph
2817        assert!(font.glyph_components(1).is_empty()); // simple glyph
2818        assert_eq!(font.glyph_components(2), vec![5]); // WE_HAVE_A_SCALE
2819        assert_eq!(font.glyph_components(3), vec![5]); // X_AND_Y_SCALE
2820        assert_eq!(font.glyph_components(4), vec![5]); // TWO_BY_TWO
2821        assert_eq!(font.glyph_components(6), vec![2, 3]); // word args + MORE
2822        assert_eq!(font.glyph_components(7), vec![5]); // MORE, record ends at glyph end
2823        assert_eq!(font.glyph_components(10), vec![5]); // junk after the last record
2824        assert!(font.glyph_components(11).is_empty()); // 2x2 payload overruns glyph
2825        assert!(font.is_composite(2));
2826        assert!(!font.is_composite(1));
2827        assert!(!font.is_composite(0));
2828        assert_eq!(font.glyph_bbox(2), Some([1, 2, 3, 4]));
2829        assert_eq!(font.glyph_bbox(0), None);
2830        assert_eq!(font.glyph_data(0), Some(&[][..]));
2831    }
2832
2833    #[test]
2834    fn glyph_components_stop_at_truncated_component_records() {
2835        // keep = physically present bytes of the 16-byte composite: 1 cuts the
2836        // contour count, 10 cuts the first record's flags, 12 its glyph index.
2837        for keep in [1usize, 10, 12] {
2838            let font = truncated_composite_font(keep);
2839            assert!(font.glyph_components(0).is_empty(), "keep={keep}");
2840            assert_eq!(font.glyph_data(0), None, "keep={keep}");
2841        }
2842    }
2843
2844    // --- subsetting edges -------------------------------------------------
2845
2846    #[test]
2847    fn subset_rewrites_component_ids_across_transform_variants() {
2848        let font = zoo_font();
2849        let (bytes, remap) = font.subset_glyphs(&[2, 3, 4], &[]).expect("subset");
2850        let remap: Vec<(u16, u16)> = remap.into_iter().collect();
2851        assert_eq!(remap, vec![(0, 0), (2, 1), (3, 2), (4, 3), (5, 4)]);
2852        let sub = Font::parse(bytes).expect("subset re-parses");
2853        assert_eq!(sub.num_glyphs, 5);
2854        assert_eq!(sub.glyph_components(1), vec![4]);
2855        assert_eq!(sub.glyph_components(2), vec![4]);
2856        assert_eq!(sub.glyph_components(3), vec![4]);
2857        assert_eq!(sub.glyph_bbox(1), Some([1, 2, 3, 4]));
2858        assert_eq!(sub.advance_width(1), 502);
2859        assert_eq!(sub.left_side_bearing(1), 2);
2860        assert_eq!(sub.advance_width(4), 505);
2861        assert_eq!(sub.left_side_bearing(4), 5);
2862    }
2863
2864    #[test]
2865    fn subset_closure_skips_component_ids_past_num_glyphs() {
2866        let font = zoo_font();
2867        let (bytes, remap) = font.subset_glyphs(&[9], &[]).expect("subset");
2868        assert_eq!(remap.get(&9).copied(), Some(1));
2869        assert_eq!(remap.len(), 2); // .notdef + composite; gid 900 never joins
2870        let sub = Font::parse(bytes).expect("subset re-parses");
2871        assert_eq!(sub.num_glyphs, 2);
2872        // The out-of-range component was substituted with .notdef.
2873        assert_eq!(sub.glyph_components(1), vec![0]);
2874    }
2875
2876    #[test]
2877    fn subset_shares_a_component_between_two_composites() {
2878        let font = zoo_font();
2879        let (bytes, remap) = font.subset_glyphs(&[2, 4], &[]).expect("subset");
2880        let remap: Vec<(u16, u16)> = remap.into_iter().collect();
2881        assert_eq!(remap, vec![(0, 0), (2, 1), (4, 2), (5, 3)]);
2882        let sub = Font::parse(bytes).expect("subset re-parses");
2883        assert_eq!(sub.glyph_components(1), vec![3]);
2884        assert_eq!(sub.glyph_components(2), vec![3]);
2885    }
2886
2887    #[test]
2888    fn subset_aborts_on_composite_whose_last_record_dangles_more() {
2889        // `glyph_components` tolerates a final record with MORE_COMPONENTS set
2890        // and nothing after it (gid 7), but `subset_glyph_bytes` keeps walking,
2891        // fails the next bounds-checked read, and refuses the whole subset
2892        // rather than emit a corrupt composite.
2893        let font = zoo_font();
2894        assert_eq!(font.glyph_components(7), vec![5]);
2895        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
2896        new_of[0] = 0;
2897        new_of[5] = 1;
2898        new_of[7] = 2;
2899        assert_eq!(font.subset_glyph_bytes(7, &new_of), None);
2900        assert!(font.subset_glyphs(&[7], &[]).is_none());
2901    }
2902
2903    #[test]
2904    fn subset_strips_valid_composite_instructions_and_clears_the_flag() {
2905        let font = zoo_font();
2906        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
2907        new_of[0] = 0;
2908        new_of[5] = 1;
2909        new_of[12] = 2;
2910        let out = font
2911            .subset_glyph_bytes(12, &new_of)
2912            .expect("valid instructions strip");
2913        assert_eq!(out.len(), 16); // 20 minus the length field and 2 bytes
2914        assert_eq!(be_u16(&out, 10), Some(0)); // WE_HAVE_INSTRUCTIONS cleared
2915        assert_eq!(be_u16(&out, 12), Some(1)); // component 5 renumbered
2916        let (bytes, remap) = font.subset_glyphs(&[12], &[]).expect("subset");
2917        assert_eq!(remap.get(&12).copied(), Some(2));
2918        let sub = Font::parse(bytes).expect("subset re-parses");
2919        assert_eq!(sub.glyph_components(2), vec![1]);
2920    }
2921
2922    #[test]
2923    fn subset_cmap_skips_supplementary_plane_chars() {
2924        let font = zoo_font();
2925        let (bytes, _) = font.subset_glyphs(&[2], &['πŸ˜€']).expect("subset");
2926        let sub = Font::parse(bytes).expect("subset re-parses");
2927        assert_eq!(sub.glyph_index('πŸ˜€'), 0); // never entered the format-4 cmap
2928    }
2929
2930    #[test]
2931    fn subset_rejects_composite_with_overlong_instruction_claim() {
2932        let font = zoo_font();
2933        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
2934        new_of[0] = 0;
2935        new_of[5] = 1;
2936        new_of[8] = 2;
2937        assert_eq!(font.subset_glyph_bytes(8, &new_of), None);
2938        assert!(font.subset_glyphs(&[8], &[]).is_none());
2939    }
2940
2941    #[test]
2942    fn strip_simple_glyph_instructions_rejects_overlong_length() {
2943        let mut glyph = Vec::new();
2944        push_i16(&mut glyph, 1);
2945        glyph.extend_from_slice(&[0u8; 8]); // bbox
2946        push16(&mut glyph, 0); // endPtsOfContours[0]
2947        push16(&mut glyph, 255); // instructionLength reaching past the data
2948        assert_eq!(strip_simple_glyph_instructions(&glyph, 1), None);
2949    }
2950
2951    // --- cmap lookup paths --------------------------------------------------
2952
2953    #[test]
2954    fn cmap4_truncated_segment_arrays_fall_back_to_uncached_lookup() {
2955        // Six declared segments; idRangeOffset[5] is cut off by the file end,
2956        // so the parse-time cache fails and lookups walk the raw arrays.
2957        // idRangeOffsets of segments 1/2 alias later idRangeOffset entries as
2958        // their glyphIdArray storage.
2959        let segs = [
2960            (0x5Au16, 0x41u16, 1u16.wrapping_sub(0x41), 0u16), // 'A'..'Z' -> 1..26
2961            (0x61, 0x61, 1, 6),                                // 'a' -> array at iro[4]
2962            (0x62, 0x62, 0, 2),                                // 'b' -> array at iro[3] (0)
2963            (0x63, 0x63, 0, 0),                                // 'c' -> delta path
2964            (0x64, 0x64, 0, 7),                                // 'd' -> array past EOF
2965            (0x00FF, 0x00F0, 0, 0),                            // its iro entry is cut off
2966        ];
2967        let tables = base_tables(30, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_table(&segs, &[]));
2968        let mut bytes = sfnt(0x0001_0000, &tables);
2969        bytes.truncate(bytes.len() - 2); // drop idRangeOffset[5]
2970        let font = Font::parse(bytes).expect("cmap payload is lazily read");
2971        assert!(font.cmap4_cache.is_none());
2972        assert_eq!(font.glyph_index('A'), 1);
2973        assert_eq!(font.glyph_index('Z'), 26);
2974        assert_eq!(font.glyph_index('@'), 0); // below the first segment start
2975        assert_eq!(font.glyph_index('a'), 8); // glyphIdArray 7 + idDelta 1
2976        assert_eq!(font.glyph_index('b'), 0); // glyphIdArray slot holds 0
2977        assert_eq!(font.glyph_index('c'), 99); // idDelta 0 -> the code itself
2978        assert_eq!(font.glyph_index('d'), 0); // glyphIdArray slot beyond EOF
2979        assert_eq!(font.glyph_index('Γ΅'), 0); // idRangeOffset entry beyond EOF
2980        assert_eq!(font.glyph_index('Δ€'), 0); // above every segment
2981        assert_eq!(font.glyph_index('πŸ˜€'), 0); // beyond the BMP
2982    }
2983
2984    #[test]
2985    fn cmap4_cached_lookup_reads_glyph_id_array() {
2986        let segs = [(0x42u16, 0x41u16, 3u16, 4u16), (0xFFFF, 0xFFFF, 1, 0)];
2987        let mut array = Vec::new();
2988        push16(&mut array, 7);
2989        push16(&mut array, 0);
2990        let font = parse(&base_tables(
2991            20,
2992            1,
2993            1000,
2994            hmtx_long(&[(500, 0)]),
2995            cmap4_table(&segs, &array),
2996        ));
2997        let cache = font.cmap4_cache.as_ref().expect("valid table caches");
2998        assert!(cache.sorted_by_end);
2999        assert_eq!(font.glyph_index('A'), 10); // glyphIdArray 7 + idDelta 3
3000        assert_eq!(font.glyph_index('B'), 0); // glyphIdArray slot holds 0
3001        assert_eq!(font.glyph_index('C'), 0); // below the final segment's start
3002    }
3003
3004    #[test]
3005    fn cmap4_unsorted_segments_use_first_match_linear_scan() {
3006        let segs = [
3007            (0x61u16, 0x61u16, 2u16.wrapping_sub(0x61), 0u16),
3008            (0x5A, 0x41, 1u16.wrapping_sub(0x41), 0),
3009            (0xFFFF, 0xFFFF, 1, 0),
3010        ];
3011        let font = parse(&base_tables(
3012            30,
3013            1,
3014            1000,
3015            hmtx_long(&[(500, 0)]),
3016            cmap4_table(&segs, &[]),
3017        ));
3018        let cache = font
3019            .cmap4_cache
3020            .as_ref()
3021            .expect("caches even when unsorted");
3022        assert!(!cache.sorted_by_end);
3023        assert_eq!(font.glyph_index('a'), 2);
3024        // The linear scan takes the FIRST segment whose end covers the code,
3025        // so the out-of-order table shadows 'A' behind the 'a' segment.
3026        assert_eq!(font.glyph_index('A'), 0);
3027        assert_eq!(font.glyph_index('p'), 0);
3028    }
3029
3030    #[test]
3031    fn cmap4_unsorted_lookup_misses_when_no_segment_covers_the_code() {
3032        // Malformed table: no final 0xFFFF segment AND out-of-order ends, so
3033        // the cached linear scan can run off the end of the segment list.
3034        let segs = [
3035            (0x61u16, 0x61u16, 2u16.wrapping_sub(0x61), 0u16),
3036            (0x5A, 0x41, 1u16.wrapping_sub(0x41), 0),
3037        ];
3038        let font = parse(&base_tables(
3039            30,
3040            1,
3041            1000,
3042            hmtx_long(&[(500, 0)]),
3043            cmap4_table(&segs, &[]),
3044        ));
3045        assert!(!font.cmap4_cache.as_ref().expect("caches").sorted_by_end);
3046        assert_eq!(font.glyph_index('a'), 2);
3047        assert_eq!(font.glyph_index('p'), 0); // beyond every segment end
3048    }
3049
3050    #[test]
3051    fn select_cmap_accepts_format4_under_a_non_bmp_encoding_record() {
3052        // A (3,10) record pointing at a format-4 subtable ranks lowest but is
3053        // still selected when nothing better exists.
3054        let mut cmap = cmap4_simple(&[(0x41, 1)]);
3055        cmap[6..8].copy_from_slice(&10u16.to_be_bytes()); // encodingID 1 -> 10
3056        let font = parse(&base_tables(5, 1, 1000, hmtx_long(&[(500, 0)]), cmap));
3057        assert_eq!(font.cmap_format, 4);
3058        assert_eq!(font.glyph_index('A'), 1);
3059    }
3060
3061    #[test]
3062    fn cmap12_groups_map_across_planes_and_truncate_gids() {
3063        let cmap = cmap12_table(&[
3064            (0x41, 0x5A, 100),
3065            (0x2000, 0x2000, 0x0001_2345),
3066            (0x1F600, 0x1F601, 7),
3067        ]);
3068        let font = parse(&base_tables(200, 1, 1000, hmtx_long(&[(500, 0)]), cmap));
3069        assert_eq!(font.cmap_format, 12);
3070        assert!(font.cmap4_cache.is_none());
3071        assert_eq!(font.glyph_index('A'), 100);
3072        assert_eq!(font.glyph_index('Z'), 125);
3073        assert_eq!(font.glyph_index('\u{2000}'), 0x2345); // gid wraps to u16
3074        assert_eq!(font.glyph_index('πŸ˜€'), 7);
3075        assert_eq!(font.glyph_index('😁'), 8);
3076        assert_eq!(font.glyph_index('0'), 0); // in no group
3077    }
3078
3079    // --- GPOS -----------------------------------------------------------
3080
3081    #[test]
3082    fn gpos_extension_lookup_resolves_wrapped_pair_kerning() {
3083        let kern = gpos_font(1, 2, 0, 1);
3084        assert_eq!(kern.pair(5, 6), -40);
3085        assert_eq!(kern.pair(5, 7), 0);
3086        assert_eq!(kern.pair(6, 6), 0);
3087    }
3088
3089    #[test]
3090    fn gpos_skips_foreign_extensions_bad_formats_and_lookup_indices() {
3091        // Extension wrapping a non-pair lookup type is ignored.
3092        assert_eq!(gpos_font(1, 5, 0, 1).pair(5, 6), 0);
3093        // Extension subtable with an unknown format fails to resolve.
3094        assert_eq!(gpos_font(2, 2, 0, 1).pair(5, 6), 0);
3095        // Wrapped pair subtable with an unknown posFormat parses to nothing.
3096        assert_eq!(gpos_font(1, 2, 0, 3).pair(5, 6), 0);
3097        // A kern feature pointing past the lookup list is skipped.
3098        assert_eq!(gpos_font(1, 2, 9, 1).pair(5, 6), 0);
3099    }
3100
3101    #[test]
3102    fn gpos_truncated_structures_yield_empty_kerning() {
3103        // Each end point cuts the table just before a field the walker needs:
3104        // 16 the feature offset, 20 the lookup-index count, 22 the index slot,
3105        // 26 the lookup offset slot, 28 the lookup type, 32 the subtable
3106        // count, 34 the subtable offset slot.
3107        for end in [16usize, 20, 22, 26, 28, 32, 34] {
3108            let mut g = gpos_table(1, 2, 0, 1);
3109            g.truncate(end);
3110            assert_eq!(gpos_kerning_of(g).pair(5, 6), 0, "end={end}");
3111        }
3112        // featureCount over-claim: trailing phantom records read garbage tags
3113        // until the walk falls off the table; the real record still applies.
3114        let mut over = gpos_table(1, 2, 0, 1);
3115        over[10..12].copy_from_slice(&12u16.to_be_bytes());
3116        assert_eq!(gpos_kerning_of(over).pair(5, 6), -40);
3117        // A direct non-pair, non-extension lookup type is ignored.
3118        let mut direct = gpos_table(1, 2, 0, 1);
3119        direct[28..30].copy_from_slice(&1u16.to_be_bytes());
3120        assert_eq!(gpos_kerning_of(direct).pair(5, 6), 0);
3121    }
3122
3123    #[test]
3124    fn resolve_extension_requires_format_1() {
3125        let mut d = Vec::new();
3126        push16(&mut d, 2);
3127        push16(&mut d, 2);
3128        push32(&mut d, 8);
3129        assert_eq!(resolve_extension(&d, 0), None);
3130        d[0..2].copy_from_slice(&1u16.to_be_bytes());
3131        assert_eq!(resolve_extension(&d, 0), Some((2, 8)));
3132        // Unknown pair-subtable formats are rejected outright.
3133        assert!(parse_pair_subtable(&[0, 3], 0).is_none());
3134    }
3135
3136    #[test]
3137    fn value_record_x_advance_field_extraction() {
3138        // No X_ADVANCE bit: defined as zero without touching the data.
3139        assert_eq!(value_record_x_advance(&[], 0, 0), Some(0));
3140        // X/Y placement precede xAdvance: skip 4 bytes.
3141        let rec = [0, 0, 0, 0, 0x12, 0x34];
3142        assert_eq!(value_record_x_advance(&rec, 0, 0x0007), Some(0x1234));
3143        // Truncated record with the bit set: undecodable.
3144        assert_eq!(value_record_x_advance(&[0], 0, 0x0004), None);
3145    }
3146
3147    #[test]
3148    fn coverage_and_class_def_malformed_and_boundary_variants() {
3149        // Coverage format-2 range with end < start contributes nothing.
3150        let mut cov = Vec::new();
3151        push16(&mut cov, 2);
3152        push16(&mut cov, 1);
3153        push16(&mut cov, 20); // start
3154        push16(&mut cov, 10); // end < start
3155        push16(&mut cov, 0);
3156        assert_eq!(parse_coverage_glyphs(&cov, 0), Some(Vec::new()));
3157        // Coverage format 3 does not exist.
3158        assert_eq!(parse_coverage_glyphs(&[0, 3, 0, 0], 0), None);
3159        // ClassDef format 3 does not exist.
3160        assert!(parse_class_def(&[0, 3, 0, 0], 0).is_none());
3161
3162        // Format-1 class array: in-range indices map, everything else class 0.
3163        let mut cd = Vec::new();
3164        push16(&mut cd, 1);
3165        push16(&mut cd, 5); // startGlyphID
3166        push16(&mut cd, 2); // glyphCount
3167        push16(&mut cd, 7);
3168        push16(&mut cd, 9);
3169        let cd1 = parse_class_def(&cd, 0).expect("format 1 parses");
3170        assert_eq!(cd1.class(5), 7);
3171        assert_eq!(cd1.class(6), 9);
3172        assert_eq!(cd1.class(7), 0); // past the array
3173        assert_eq!(cd1.class(4), 0); // before startGlyphID
3174
3175        // Format-2 ranges: covered ranges map, gaps are class 0.
3176        let mut cd2b = Vec::new();
3177        push16(&mut cd2b, 2);
3178        push16(&mut cd2b, 1);
3179        push16(&mut cd2b, 10);
3180        push16(&mut cd2b, 20);
3181        push16(&mut cd2b, 3);
3182        let cd2 = parse_class_def(&cd2b, 0).expect("format 2 parses");
3183        assert_eq!(cd2.class(15), 3);
3184        assert_eq!(cd2.class(9), 0);
3185        assert_eq!(cd2.class(21), 0);
3186    }
3187
3188    #[test]
3189    fn kern_subtable_format2_guards_class_ranges_and_empty_matrix() {
3190        let st = KernSubtable::Format2 {
3191            coverage: vec![5, 9],
3192            class1: ClassDef::Format1 {
3193                start: 5,
3194                classes: vec![1, 0, 0, 0, 9],
3195            },
3196            class2: ClassDef::Format1 {
3197                start: 6,
3198                classes: vec![1, 7],
3199            },
3200            class1_count: 2,
3201            class2_count: 2,
3202            matrix: vec![0, 0, 0, -55],
3203        };
3204        assert_eq!(st.lookup(4, 6), None); // left glyph not covered
3205        assert_eq!(st.lookup(5, 6), Some(-55)); // classes (1, 1) -> cell 3
3206        assert_eq!(st.lookup(9, 6), Some(0)); // class1 out of declared range
3207        assert_eq!(st.lookup(5, 7), Some(0)); // class2 out of declared range
3208
3209        let empty = KernSubtable::Format2 {
3210            coverage: vec![5],
3211            class1: ClassDef::Format2 { ranges: Vec::new() },
3212            class2: ClassDef::Format2 { ranges: Vec::new() },
3213            class1_count: 1,
3214            class2_count: 1,
3215            matrix: Vec::new(),
3216        };
3217        assert_eq!(empty.lookup(5, 6), Some(0));
3218
3219        // A covered-but-zero first subtable still wins over later subtables.
3220        let kerning = Kerning {
3221            subtables: vec![empty, st],
3222        };
3223        assert_eq!(kerning.pair(5, 6), 0);
3224        assert_eq!(kerning.pair(4, 6), 0);
3225    }
3226
3227    #[test]
3228    fn pair_format1_skips_malformed_sets_and_truncated_records() {
3229        // pairSetCount 2 but the coverage names one glyph; the second set is
3230        // skipped while the first still yields (5, 6) -> -40.
3231        let mut d = Vec::new();
3232        push16(&mut d, 1); // posFormat
3233        push16(&mut d, 20); // coverage @20
3234        push16(&mut d, 0x0004); // valueFormat1
3235        push16(&mut d, 0); // valueFormat2
3236        push16(&mut d, 2); // pairSetCount
3237        push16(&mut d, 14); // pairSet[0] @14
3238        push16(&mut d, 14); // pairSet[1] (no coverage glyph -> skipped)
3239        push16(&mut d, 1); // pairValueCount
3240        push16(&mut d, 6); // secondGlyph
3241        push_i16(&mut d, -40);
3242        push16(&mut d, 1); // coverage format
3243        push16(&mut d, 1);
3244        push16(&mut d, 5);
3245        let st = parse_pair_subtable(&d, 0).expect("format 1 parses");
3246        assert_eq!(st.lookup(5, 6), Some(-40));
3247        assert_eq!(st.lookup(5, 7), None);
3248
3249        // A pair-set offset pointing past the data contributes nothing.
3250        let mut d2 = Vec::new();
3251        push16(&mut d2, 1);
3252        push16(&mut d2, 12); // coverage @12
3253        push16(&mut d2, 0x0004);
3254        push16(&mut d2, 0);
3255        push16(&mut d2, 1);
3256        push16(&mut d2, 0x4000); // pairSet[0]: far past the end
3257        push16(&mut d2, 1);
3258        push16(&mut d2, 1);
3259        push16(&mut d2, 5);
3260        let st2 = parse_pair_subtable(&d2, 0).expect("parses to an empty set");
3261        assert_eq!(st2.lookup(5, 6), None);
3262
3263        // pairValueCount claims 2 records but the data ends after the first.
3264        let mut d3 = Vec::new();
3265        push16(&mut d3, 1);
3266        push16(&mut d3, 12); // coverage @12
3267        push16(&mut d3, 0x0004);
3268        push16(&mut d3, 0);
3269        push16(&mut d3, 1);
3270        push16(&mut d3, 18); // pairSet @18
3271        push16(&mut d3, 1); // coverage format
3272        push16(&mut d3, 1);
3273        push16(&mut d3, 5);
3274        push16(&mut d3, 2); // pairValueCount (overlong)
3275        push16(&mut d3, 6);
3276        push_i16(&mut d3, -40);
3277        let st3 = parse_pair_subtable(&d3, 0).expect("parses the readable record");
3278        assert_eq!(st3.lookup(5, 6), Some(-40));
3279        assert_eq!(st3.lookup(5, 0), None);
3280    }
3281
3282    #[test]
3283    fn pair_format1_work_cap_stops_aliased_pair_set_expansion() {
3284        // Two pair sets alias one huge set. With 65 535 records the ceiling
3285        // trips between the sets; with 65 534 it trips inside the second one.
3286        for count in [65_535u16, 65_534] {
3287            let mut d = Vec::new();
3288            push16(&mut d, 1); // posFormat
3289            push16(&mut d, 14); // coverage @14
3290            push16(&mut d, 0); // valueFormat1: empty records
3291            push16(&mut d, 0); // valueFormat2
3292            push16(&mut d, 2); // pairSetCount
3293            push16(&mut d, 22); // pairSet[0] @22
3294            push16(&mut d, 22); // pairSet[1]: aliases the same set
3295            push16(&mut d, 1); // coverage format
3296            push16(&mut d, 2);
3297            push16(&mut d, 5);
3298            push16(&mut d, 6);
3299            push16(&mut d, count); // pairValueCount
3300            d.resize(d.len() + usize::from(count) * 2, 0); // secondGlyph = 0 each
3301            let st = parse_pair_subtable(&d, 0).expect("parses under the work cap");
3302            // The first set registers (5, 0); the ceiling stops the aliased
3303            // second set before it can register (6, 0).
3304            assert_eq!(st.lookup(5, 0), Some(0), "count={count}");
3305            assert_eq!(st.lookup(6, 0), None, "count={count}");
3306        }
3307    }
3308
3309    #[test]
3310    fn pair_format2_empty_value_formats_and_oversized_matrix() {
3311        // Both value formats empty: the matrix is elided and every covered
3312        // pair resolves to zero.
3313        let mut d = Vec::new();
3314        push16(&mut d, 2); // posFormat
3315        push16(&mut d, 16); // coverage @16
3316        push16(&mut d, 0); // valueFormat1
3317        push16(&mut d, 0); // valueFormat2
3318        push16(&mut d, 22); // classDef1 @22
3319        push16(&mut d, 22); // classDef2 @22 (shared)
3320        push16(&mut d, 1); // class1Count
3321        push16(&mut d, 1); // class2Count
3322        push16(&mut d, 1); // coverage format
3323        push16(&mut d, 1);
3324        push16(&mut d, 3);
3325        push16(&mut d, 1); // classdef format 1, empty array
3326        push16(&mut d, 0);
3327        push16(&mut d, 0);
3328        let st = parse_pair_subtable(&d, 0).expect("empty-value format 2 parses");
3329        assert!(matches!(
3330            &st,
3331            KernSubtable::Format2 { matrix, .. } if matrix.is_empty()
3332        ));
3333        assert_eq!(st.lookup(3, 42), Some(0));
3334        assert_eq!(st.lookup(4, 42), None);
3335
3336        // A declared matrix larger than the whole table is rejected.
3337        let mut big = Vec::new();
3338        push16(&mut big, 2);
3339        push16(&mut big, 16);
3340        push16(&mut big, 0x0004);
3341        push16(&mut big, 0);
3342        push16(&mut big, 22);
3343        push16(&mut big, 22);
3344        push16(&mut big, 0xFFFF);
3345        push16(&mut big, 0xFFFF);
3346        assert!(parse_pair_subtable(&big, 0).is_none());
3347    }
3348
3349    // --- GSUB -----------------------------------------------------------
3350
3351    #[test]
3352    fn gsub_extension_lookup_parses_greedy_ligatures() {
3353        let ligs = gsub_font(1, 4, 0);
3354        assert!(!ligs.is_empty());
3355        assert!(Ligatures::default().is_empty());
3356        assert_eq!(ligs.substitute(&[10, 11, 12]), vec![99]);
3357        assert_eq!(ligs.substitute(&[10, 11, 7]), vec![77, 7]);
3358        assert_eq!(ligs.substitute(&[10, 7]), vec![10, 7]);
3359        assert_eq!(
3360            ligs.substitute_with_spans(&[10, 11, 12, 10, 11]),
3361            vec![(99, 3), (77, 2)]
3362        );
3363    }
3364
3365    #[test]
3366    fn gsub_skips_foreign_extensions_and_bad_lookup_indices() {
3367        // Extension wrapping a non-ligature lookup type is ignored.
3368        assert!(gsub_font(1, 2, 0).is_empty());
3369        // Extension subtable with an unknown format fails to resolve.
3370        assert!(gsub_font(2, 4, 0).is_empty());
3371        // A liga feature pointing past the lookup list is skipped.
3372        assert!(gsub_font(1, 4, 9).is_empty());
3373    }
3374
3375    #[test]
3376    fn gsub_truncated_structures_yield_no_ligatures() {
3377        // Same cut points as the GPOS walker: the two table layouts share
3378        // their header/feature/lookup shape.
3379        for end in [16usize, 20, 22, 26, 28, 32, 34] {
3380            let mut g = gsub_table(1, 4, 0);
3381            g.truncate(end);
3382            assert!(gsub_ligatures_of(g).is_empty(), "end={end}");
3383        }
3384        // featureCount over-claim: phantom records break the walk after the
3385        // real record already registered its ligatures.
3386        let mut over = gsub_table(1, 4, 0);
3387        over[10..12].copy_from_slice(&12u16.to_be_bytes());
3388        assert_eq!(gsub_ligatures_of(over).substitute(&[10, 11]), vec![77]);
3389        // A direct non-ligature, non-extension lookup type is ignored.
3390        let mut direct = gsub_table(1, 4, 0);
3391        direct[28..30].copy_from_slice(&1u16.to_be_bytes());
3392        assert!(gsub_ligatures_of(direct).is_empty());
3393    }
3394
3395    #[test]
3396    fn ligature_subst_skips_malformed_entries() {
3397        let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
3398            std::collections::BTreeMap::new();
3399
3400        // Unknown subtable format: ignored outright.
3401        parse_ligature_subst(&[0, 2, 0, 0], 0, &mut rules);
3402        assert!(rules.is_empty());
3403
3404        // Header reads running off the end return without any rules.
3405        parse_ligature_subst(&[], 0, &mut rules); // no format
3406        parse_ligature_subst(&[0, 1], 0, &mut rules); // no coverage offset
3407        parse_ligature_subst(&[0, 1, 0, 8], 0, &mut rules); // no ligSetCount
3408        parse_ligature_subst(&[0, 1, 0, 6, 0, 1, 0, 3], 0, &mut rules); // coverage fmt 3
3409        assert!(rules.is_empty());
3410
3411        // LigatureSet offset far past the data: no rules.
3412        let mut d = Vec::new();
3413        push16(&mut d, 1); // substFormat
3414        push16(&mut d, 8); // coverage @8
3415        push16(&mut d, 1); // ligSetCount
3416        push16(&mut d, 0x4000); // ligatureSet: far past the end
3417        push16(&mut d, 1); // coverage format
3418        push16(&mut d, 1);
3419        push16(&mut d, 10);
3420        parse_ligature_subst(&d, 0, &mut rules);
3421        assert!(rules.is_empty());
3422
3423        // ligSetCount 2 with a single-glyph coverage: the second set has no
3424        // coverage glyph, the first still parses (10, 11) -> 77.
3425        let mut d2 = Vec::new();
3426        push16(&mut d2, 1); // substFormat
3427        push16(&mut d2, 20); // coverage @20
3428        push16(&mut d2, 2); // ligSetCount
3429        push16(&mut d2, 10); // set[0] @10
3430        push16(&mut d2, 10); // set[1] (never reached)
3431        push16(&mut d2, 1); // ligatureCount
3432        push16(&mut d2, 4); // ligature @14
3433        push16(&mut d2, 77); // ligatureGlyph
3434        push16(&mut d2, 2); // componentCount
3435        push16(&mut d2, 11); // component[1]
3436        push16(&mut d2, 1); // coverage format
3437        push16(&mut d2, 1);
3438        push16(&mut d2, 10);
3439        parse_ligature_subst(&d2, 0, &mut rules);
3440        assert_eq!(rules.len(), 1);
3441        assert_eq!(rules[&10].len(), 1);
3442        assert_eq!(rules[&10][0].components, vec![11]);
3443        assert_eq!(rules[&10][0].ligature, 77);
3444
3445        // Zero component count, comp-count/glyph reads past the end, and a
3446        // truncated component array: each entry drops without a rule.
3447        rules.clear();
3448        let mut d3 = Vec::new();
3449        push16(&mut d3, 1); // substFormat
3450        push16(&mut d3, 8); // coverage @8
3451        push16(&mut d3, 1); // ligSetCount
3452        push16(&mut d3, 14); // ligatureSet @14
3453        push16(&mut d3, 1); // coverage format
3454        push16(&mut d3, 1);
3455        push16(&mut d3, 10);
3456        push16(&mut d3, 4); // ligatureCount
3457        push16(&mut d3, 10); // @24: zero componentCount
3458        push16(&mut d3, 20); // @34: componentCount past the end
3459        push16(&mut d3, 0x4000); // unreadable ligature glyph
3460        push16(&mut d3, 14); // @28: component array past the end
3461        push16(&mut d3, 33); // ligature @24
3462        push16(&mut d3, 0); // componentCount 0
3463        push16(&mut d3, 88); // ligature @28
3464        push16(&mut d3, 5); // componentCount 5, components cut off
3465        push16(&mut d3, 11);
3466        push16(&mut d3, 12);
3467        assert_eq!(d3.len(), 36);
3468        parse_ligature_subst(&d3, 0, &mut rules);
3469        assert!(rules.is_empty());
3470    }
3471
3472    #[test]
3473    fn ligature_subst_work_cap_stops_aliased_sets() {
3474        // Two ligature sets alias one set whose declared count is huge and
3475        // whose offset array is entirely missing. With 65 535 the ceiling
3476        // trips between the sets; with 65 534 inside the second one.
3477        for lig_count in [65_535u16, 65_534] {
3478            let mut d = Vec::new();
3479            push16(&mut d, 1); // substFormat
3480            push16(&mut d, 10); // coverage @10
3481            push16(&mut d, 2); // ligSetCount
3482            push16(&mut d, 18); // set[0] @18
3483            push16(&mut d, 18); // set[1]: aliases set[0]
3484            push16(&mut d, 1); // coverage format
3485            push16(&mut d, 2);
3486            push16(&mut d, 10);
3487            push16(&mut d, 11);
3488            push16(&mut d, lig_count); // every ligature offset is unreadable
3489            let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
3490                std::collections::BTreeMap::new();
3491            parse_ligature_subst(&d, 0, &mut rules);
3492            assert!(rules.is_empty(), "lig_count={lig_count}");
3493        }
3494    }
3495}