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, GSUB standard ligatures, and optional `fvar`/`avar` font
9//! variation axes (named instances + clamped axis mapping). Latin-first,
10//! zero-dependency, and free of `unsafe`/`unwrap`/`panic` — every read is
11//! bounds-checked.
12//!
13//! Additive strict subset APIs also support name-keyed CFF1 with explicit
14//! embedding metadata; see [`cff`]. [`shaping`] provides bounded Latin/Arabic
15//! shaping with UTF-8 source clusters and typed unsupported/malformed errors.
16//! Existing renderer shaping and TrueType subset output remain unchanged.
17//!
18//! Factored out of `franken_markdown`'s `src/text.rs` into this standalone
19//! `fmd-font` workspace crate so the wider Franken suite can consume the
20//! font subsystem directly (franken_manim's Scribe is the first external
21//! consumer). The [`outline`] module is the piece added with the factoring:
22//! a decoder from `glyf` point data to quadratic-Bézier contours with
23//! phantom-point-correct metrics.
24#![forbid(unsafe_code)]
25
26#[cfg(feature = "bundled-faces")]
27pub mod bundled;
28pub mod cff;
29mod gvar;
30pub mod outline;
31pub mod shaping;
32mod subset;
33pub use subset::{EmbeddingFormat, Subset, SubsetError, SubsetErrorKind};
34
35/// Tiny OFL variable-font fixture (one glyph, `wght` 100..=900, gvar peak
36/// +50 x on point 0). Host-font / CLI / WASM tests use this; it is not a
37/// design face. ASCII `U+0020..=U+007E` map to glyph 0.
38#[must_use]
39pub fn variable_triangle_fixture() -> Vec<u8> {
40    gvar::variable_triangle_fixture()
41}
42
43/// Hard ceiling on how many glyphs a single OpenType layout structure may
44/// enumerate. A font cannot contain more than 65 536 glyphs, so a well-formed
45/// Coverage / ligature / pair table never exceeds this. It bounds the work an
46/// untrusted host font can drive: without it, a tiny malicious table (aliased
47/// offsets or a 6-byte range claiming 65 536 ids) amplifies into billions of
48/// iterations or gigabytes of retained state — a CPU-hang / OOM-kill DoS.
49const MAX_LAYOUT_GLYPHS: usize = 65_536;
50
51/// Alias of the layout ceiling used specifically for Coverage-table expansion.
52const MAX_COVERAGE_GLYPHS: usize = MAX_LAYOUT_GLYPHS;
53/// Sentinel in the dense glyph remap returned by
54/// [`Font::subset_glyphs_with_lookup`]: `lookup[old] == MISSING_GLYPH_REMAP`
55/// means glyph `old` is not part of the subset (glyph 0, `.notdef`, is always
56/// kept and always remaps to 0).
57pub const MISSING_GLYPH_REMAP: u16 = u16::MAX;
58
59/// OpenType variable fonts almost never exceed a handful of axes (`wght`,
60/// `wdth`, `opsz`, …). A hostile `fvar` can claim 65 535 axes at 20 bytes
61/// each; this cap bounds the retained `Vec` and the walk.
62const MAX_VARIATION_AXES: usize = 64;
63/// Named instances are a short designer-authored list. Cap the walk so a
64/// huge `instanceCount` cannot hang the parser.
65const MAX_NAMED_INSTANCES: usize = 256;
66/// `avar` segment maps are piecewise-linear; a few dozen knots is generous.
67const MAX_AVAR_MAPS: usize = 64;
68/// Per-axis `avar` maps: `None` means identity for that axis.
69type AvarAxisMaps = Vec<Option<Vec<(f32, f32)>>>;
70
71#[derive(Debug, Clone)]
72struct Cmap4Segment {
73    start: u16,
74    end: u16,
75    id_delta: u16,
76    id_range_offset: u16,
77    id_range_offset_pos: usize,
78}
79
80#[derive(Debug, Clone)]
81struct Cmap4Cache {
82    segments: Vec<Cmap4Segment>,
83    sorted_by_end: bool,
84}
85
86/// A parsed font, owning its backing bytes.
87#[derive(Debug, Clone)]
88pub struct Font {
89    data: Vec<u8>,
90    /// Font design units per em (the coordinate scale; advances are in these).
91    pub units_per_em: u16,
92    /// Number of glyphs in the font.
93    pub num_glyphs: u16,
94    /// Typographic ascender (design units).
95    pub ascent: i16,
96    /// Typographic descender (design units, usually negative).
97    pub descent: i16,
98    /// Recommended extra line gap (design units).
99    pub line_gap: i16,
100    num_h_metrics: u16,
101    hmtx_off: usize,
102    cmap_off: usize,
103    cmap_format: u16,
104    cmap4_cache: Option<Cmap4Cache>,
105    /// `(offset, length)` of the `glyf` table, when the font has TrueType
106    /// outlines. Absent for CFF/OpenType (`OTTO`) fonts.
107    glyf: Option<(usize, usize)>,
108    /// Offset of the `loca` table (glyph offsets into `glyf`).
109    loca_off: Option<usize>,
110    /// True when `loca` uses the 32-bit (long) offset format.
111    loca_long: bool,
112    /// `(pair_record_offset, pair_count)` for a legacy `kern` format-0 table.
113    kern0: Option<(usize, u16)>,
114    /// Optional OpenType variations (`fvar` + optional `avar`). Absent for
115    /// static faces and for fonts whose variation tables fail validation.
116    variation: Option<FontVariation>,
117    latin1_glyphs: [u16; 256],
118    latin1_advances_1000: [u32; 256],
119}
120
121/// One `fvar` variation axis (`wght`, `wdth`, `opsz`, …).
122///
123/// Values are in the axis's user space (the same units as `fvar` `minValue` /
124/// `defaultValue` / `maxValue`, typically 1.0-based design coordinates such as
125/// CSS `font-weight` 100–900).
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct VariationAxis {
128    /// Four-byte axis tag, e.g. `*b"wght"`.
129    pub tag: [u8; 4],
130    /// Inclusive lower bound of the axis.
131    pub min: f32,
132    /// Default (uninstanced) location.
133    pub default: f32,
134    /// Inclusive upper bound of the axis.
135    pub max: f32,
136    /// Axis flags from `fvar` (bit 0 = hidden axis).
137    pub flags: u16,
138    /// Name table ID for the axis's display name.
139    pub name_id: u16,
140}
141
142/// User-space `(min, default, max)` for one variation axis.
143#[derive(Debug, Clone, Copy, PartialEq)]
144pub struct AxisBounds {
145    /// Inclusive lower bound.
146    pub min: f32,
147    /// Default location.
148    pub default: f32,
149    /// Inclusive upper bound.
150    pub max: f32,
151}
152
153/// One named instance from `fvar` (a designer-authored location in axis space).
154#[derive(Debug, Clone, PartialEq)]
155pub struct NamedInstance {
156    /// Name table ID for the instance subfamily name (e.g. "Bold").
157    pub subfamily_name_id: u16,
158    /// Instance flags from `fvar`.
159    pub flags: u16,
160    /// Per-axis coordinates in user space, parallel to [`Font::axes`].
161    pub coordinates: Vec<f32>,
162    /// Optional PostScript name ID when `instanceSize` includes it.
163    pub postscript_name_id: Option<u16>,
164}
165
166#[derive(Debug, Clone)]
167struct FontVariation {
168    axes: Vec<VariationAxis>,
169    instances: Vec<NamedInstance>,
170    /// Per-axis `avar` maps of `(from, to)` in normalized `[-1, 1]` space.
171    /// `None` means identity mapping for that axis.
172    avar: AvarAxisMaps,
173}
174
175/// Why a font failed to parse.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum FontError {
178    /// Not a recognized sfnt (`0x00010000`, `true`, or `OTTO`).
179    BadMagic,
180    /// A required table was absent.
181    MissingTable(&'static str),
182    /// The file ended before a required field could be read.
183    Truncated,
184    /// No usable Unicode `cmap` subtable (format 4 or 12) was found.
185    NoUnicodeCmap,
186}
187
188impl core::fmt::Display for FontError {
189    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
190        match self {
191            Self::BadMagic => write!(f, "not a TrueType/OpenType font"),
192            Self::MissingTable(t) => write!(f, "missing required font table: {t}"),
193            Self::Truncated => write!(f, "font data is truncated"),
194            Self::NoUnicodeCmap => write!(f, "no usable Unicode cmap (format 4/12)"),
195        }
196    }
197}
198
199impl std::error::Error for FontError {}
200
201pub(crate) fn be_u16(d: &[u8], o: usize) -> Option<u16> {
202    let bytes = d.get(o..o.checked_add(2)?)?;
203    Some(u16::from_be_bytes([bytes[0], bytes[1]]))
204}
205pub(crate) fn be_i16(d: &[u8], o: usize) -> Option<i16> {
206    be_u16(d, o).map(|v| v as i16)
207}
208pub(crate) fn be_u32(d: &[u8], o: usize) -> Option<u32> {
209    let bytes = d.get(o..o.checked_add(4)?)?;
210    Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
211}
212
213fn be_i32(d: &[u8], o: usize) -> Option<i32> {
214    be_u32(d, o).map(|v| v as i32)
215}
216
217/// OpenType `Fixed` (16.16) → `f32`.
218fn fixed_16_16(v: i32) -> f32 {
219    (f64::from(v) / 65536.0) as f32
220}
221
222/// OpenType `F2DOT14` → `f32` in (approximately) `[-2, 2)`.
223fn f2dot14(v: i16) -> f32 {
224    f32::from(v) / 16384.0
225}
226
227pub(crate) fn off(base: usize, delta: usize) -> Option<usize> {
228    base.checked_add(delta)
229}
230
231pub(crate) fn off_mul(base: usize, index: usize, stride: usize) -> Option<usize> {
232    base.checked_add(index.checked_mul(stride)?)
233}
234
235fn be_u16_at(d: &[u8], base: usize, delta: usize) -> Option<u16> {
236    be_u16(d, off(base, delta)?)
237}
238
239fn be_u32_at(d: &[u8], base: usize, delta: usize) -> Option<u32> {
240    be_u32(d, off(base, delta)?)
241}
242
243fn bytes_at(d: &[u8], base: usize, len: usize) -> Option<&[u8]> {
244    d.get(base..off(base, len)?)
245}
246
247/// Write a big-endian `u16` at `off` into a mutable buffer, bounds-checked.
248fn write_u16(d: &mut [u8], off: usize, v: u16) -> Option<()> {
249    let b = v.to_be_bytes();
250    let dst = d.get_mut(off..off.checked_add(2)?)?;
251    dst.copy_from_slice(&b);
252    Some(())
253}
254
255/// Write a big-endian `u32` at `off` into a mutable buffer, bounds-checked.
256pub(crate) fn write_u32(d: &mut [u8], off: usize, v: u32) -> Option<()> {
257    let b = v.to_be_bytes();
258    let dst = d.get_mut(off..off.checked_add(4)?)?;
259    dst.copy_from_slice(&b);
260    Some(())
261}
262
263pub(crate) fn table_checksum(d: &[u8]) -> u32 {
264    let mut sum: u32 = 0;
265    let mut chunks16 = d.chunks_exact(16);
266    for c in &mut chunks16 {
267        let w0 = u32::from_be_bytes([c[0], c[1], c[2], c[3]]);
268        let w1 = u32::from_be_bytes([c[4], c[5], c[6], c[7]]);
269        let w2 = u32::from_be_bytes([c[8], c[9], c[10], c[11]]);
270        let w3 = u32::from_be_bytes([c[12], c[13], c[14], c[15]]);
271        sum = sum
272            .wrapping_add(w0)
273            .wrapping_add(w1)
274            .wrapping_add(w2)
275            .wrapping_add(w3);
276    }
277    let mut chunks4 = chunks16.remainder().chunks_exact(4);
278    for c in &mut chunks4 {
279        sum = sum.wrapping_add(u32::from_be_bytes([c[0], c[1], c[2], c[3]]));
280    }
281    let rem = chunks4.remainder();
282    if !rem.is_empty() {
283        let mut buf = [0u8; 4];
284        buf[..rem.len()].copy_from_slice(rem);
285        sum = sum.wrapping_add(u32::from_be_bytes(buf));
286    }
287    sum
288}
289
290fn find_table(d: &[u8], tag: &[u8; 4]) -> Option<usize> {
291    find_table_full(d, tag).map(|(off, _)| off)
292}
293
294/// Locate a table by tag, returning `(offset, length)`.
295pub(crate) fn find_table_full(d: &[u8], tag: &[u8; 4]) -> Option<(usize, usize)> {
296    let num_tables = be_u16(d, 4)? as usize;
297    for i in 0..num_tables {
298        let rec = off_mul(12, i, 16)?;
299        if bytes_at(d, rec, 4)? == tag {
300            return Some((
301                be_u32_at(d, rec, 8)? as usize,
302                be_u32_at(d, rec, 12)? as usize,
303            ));
304        }
305    }
306    None
307}
308
309/// Locate a legacy TrueType `kern` v0 format-0 horizontal pair table.
310fn find_kern0(d: &[u8]) -> Option<(usize, u16)> {
311    let (kern, kern_len) = find_table_full(d, b"kern")?;
312    let table_end = kern.checked_add(kern_len)?;
313    let version = be_u16(d, kern)?;
314    let n_tables = be_u16_at(d, kern, 2)? as usize;
315    if version != 0 {
316        return None;
317    }
318
319    let mut sub = off(kern, 4)?;
320    for _ in 0..n_tables {
321        if sub.checked_add(6)? > table_end {
322            return None;
323        }
324        let length = be_u16_at(d, sub, 2)? as usize;
325        let coverage = be_u16_at(d, sub, 4)?;
326        let format = coverage >> 8;
327        let horizontal = coverage & 0x0001 != 0;
328        let minimum = coverage & 0x0002 != 0;
329        let pairs = off(sub, 14)?;
330        if format == 0 && horizontal && !minimum && length >= 14 {
331            let sub_end = sub.checked_add(length)?;
332            if sub_end > table_end {
333                return None;
334            }
335            let n_pairs = be_u16_at(d, sub, 6)?;
336            let bytes_needed = (n_pairs as usize).checked_mul(6)?;
337            if pairs.checked_add(bytes_needed)? <= sub_end {
338                return Some((pairs, n_pairs));
339            }
340            return None;
341        }
342        if length == 0 {
343            return None;
344        }
345        sub = sub.checked_add(length)?;
346    }
347    None
348}
349
350/// Parse an optional `fvar` table. Returns `None` on truncation, version
351/// mismatch, or a structurally hostile header; a well-formed static font
352/// simply has no `fvar`.
353fn parse_fvar(d: &[u8], table_off: usize, table_len: usize) -> Option<FontVariation> {
354    let table_end = table_off.checked_add(table_len)?;
355    if table_off.checked_add(16)? > table_end {
356        return None;
357    }
358    let major = be_u16(d, table_off)?;
359    if major != 1 {
360        return None;
361    }
362    let axes_array_offset = be_u16_at(d, table_off, 4)? as usize;
363    let axis_count = be_u16_at(d, table_off, 8)? as usize;
364    let axis_size = be_u16_at(d, table_off, 10)? as usize;
365    let instance_count = be_u16_at(d, table_off, 12)? as usize;
366    let instance_size = be_u16_at(d, table_off, 14)? as usize;
367    if axis_size < 20 {
368        return None;
369    }
370    let n_axes = axis_count.min(MAX_VARIATION_AXES);
371    let axes_off = off(table_off, axes_array_offset)?;
372    let axes_bytes = n_axes.checked_mul(axis_size)?;
373    if axes_off.checked_add(axes_bytes)? > table_end {
374        return None;
375    }
376
377    let mut axes = Vec::with_capacity(n_axes);
378    for i in 0..n_axes {
379        let rec = off_mul(axes_off, i, axis_size)?;
380        let tag_bytes = bytes_at(d, rec, 4)?;
381        let mut tag = [0u8; 4];
382        tag.copy_from_slice(tag_bytes);
383        let min = fixed_16_16(be_i32(d, off(rec, 4)?)?);
384        let default = fixed_16_16(be_i32(d, off(rec, 8)?)?);
385        let max = fixed_16_16(be_i32(d, off(rec, 12)?)?);
386        let flags = be_u16_at(d, rec, 16)?;
387        let name_id = be_u16_at(d, rec, 18)?;
388        axes.push(VariationAxis {
389            tag,
390            min,
391            default,
392            max,
393            flags,
394            name_id,
395        });
396    }
397
398    let n_inst = instance_count.min(MAX_NAMED_INSTANCES);
399    let coord_bytes = n_axes.checked_mul(4)?;
400    let min_inst_size = 4usize.checked_add(coord_bytes)?;
401    let mut instances = Vec::new();
402    if instance_size >= min_inst_size {
403        let inst_off = off(axes_off, axes_bytes)?;
404        let inst_bytes = n_inst.checked_mul(instance_size)?;
405        if inst_off.checked_add(inst_bytes)? <= table_end {
406            let has_ps_name = instance_size >= min_inst_size.saturating_add(2);
407            for i in 0..n_inst {
408                let rec = off_mul(inst_off, i, instance_size)?;
409                let subfamily_name_id = be_u16(d, rec)?;
410                let flags = be_u16_at(d, rec, 2)?;
411                let mut coordinates = Vec::with_capacity(n_axes);
412                let mut ok = true;
413                for a in 0..n_axes {
414                    let Some(coord) = off(rec, 4)
415                        .and_then(|base| off_mul(base, a, 4))
416                        .and_then(|o| be_i32(d, o))
417                    else {
418                        ok = false;
419                        break;
420                    };
421                    coordinates.push(fixed_16_16(coord));
422                }
423                if !ok {
424                    continue;
425                }
426                let postscript_name_id = if has_ps_name {
427                    be_u16_at(d, rec, min_inst_size)
428                } else {
429                    None
430                };
431                instances.push(NamedInstance {
432                    subfamily_name_id,
433                    flags,
434                    coordinates,
435                    postscript_name_id,
436                });
437            }
438        }
439    }
440
441    let avar = vec![None; axes.len()];
442    Some(FontVariation {
443        axes,
444        instances,
445        avar,
446    })
447}
448
449/// Overlay `avar` segment maps onto a parsed `fvar`. Axis count must match
450/// the (already-capped) `fvar` axis count; otherwise `avar` is ignored.
451fn parse_avar(d: &[u8], table_off: usize, table_len: usize, n_axes: usize) -> Option<AvarAxisMaps> {
452    let table_end = table_off.checked_add(table_len)?;
453    if table_off.checked_add(8)? > table_end {
454        return None;
455    }
456    let major = be_u16(d, table_off)?;
457    if major != 1 {
458        return None;
459    }
460    let axis_count = be_u16_at(d, table_off, 6)? as usize;
461    if axis_count != n_axes {
462        return None;
463    }
464    let mut maps = Vec::with_capacity(n_axes);
465    let mut cursor = off(table_off, 8)?;
466    for _ in 0..n_axes {
467        if cursor.checked_add(2)? > table_end {
468            return None;
469        }
470        let count = be_u16(d, cursor)? as usize;
471        cursor = off(cursor, 2)?;
472        let n = count.min(MAX_AVAR_MAPS);
473        let bytes = n.checked_mul(4)?;
474        if cursor.checked_add(bytes)? > table_end {
475            return None;
476        }
477        let mut segs = Vec::with_capacity(n);
478        for i in 0..n {
479            let rec = off_mul(cursor, i, 4)?;
480            let from = f2dot14(be_i16(d, rec)?);
481            let to = f2dot14(be_i16_at(d, rec, 2)?);
482            segs.push((from, to));
483        }
484        // Skip any claimed-but-capped remainder so the next axis stays aligned.
485        let claimed = count.checked_mul(4)?;
486        cursor = off(cursor, claimed)?;
487        if segs.len() < 2 {
488            maps.push(None);
489            continue;
490        }
491        // Piecewise lookup needs non-decreasing `from`. Drop the axis map
492        // rather than inventing a sort that would hide a broken table.
493        let sorted = segs.windows(2).all(|w| w[0].0 <= w[1].0);
494        maps.push(if sorted { Some(segs) } else { None });
495    }
496    Some(maps)
497}
498
499fn be_i16_at(d: &[u8], base: usize, delta: usize) -> Option<i16> {
500    be_i16(d, off(base, delta)?)
501}
502
503/// Map a normalized `[-1, 1]` coordinate through an `avar` segment list.
504fn avar_map(maps: &[(f32, f32)], x: f32) -> f32 {
505    let Some(first) = maps.first() else {
506        return x;
507    };
508    if x <= first.0 {
509        return first.1;
510    }
511    let Some(last) = maps.last() else {
512        return x;
513    };
514    if x >= last.0 {
515        return last.1;
516    }
517    for pair in maps.windows(2) {
518        let (from0, to0) = pair[0];
519        let (from1, to1) = pair[1];
520        if x <= from1 {
521            let span = from1 - from0;
522            if span == 0.0 {
523                return to0;
524            }
525            let t = (x - from0) / span;
526            return to0 + t * (to1 - to0);
527        }
528    }
529    last.1
530}
531
532/// User-space value → normalized `[-1, 1]`, clamped to `[min, max]`.
533/// Inverted `min`/`max` (hostile tables) are ordered before clamping so
534/// `f32::clamp` cannot panic.
535fn normalize_user(user: f32, axis: &VariationAxis) -> f32 {
536    let lo = axis.min.min(axis.max);
537    let hi = axis.min.max(axis.max);
538    let user = if user < lo {
539        lo
540    } else if user > hi {
541        hi
542    } else {
543        user
544    };
545    let default = if axis.default < lo {
546        lo
547    } else if axis.default > hi {
548        hi
549    } else {
550        axis.default
551    };
552    if user < default {
553        let span = default - lo;
554        if span == 0.0 {
555            0.0
556        } else {
557            (user - default) / span
558        }
559    } else if user > default {
560        let span = hi - default;
561        if span == 0.0 {
562            0.0
563        } else {
564            (user - default) / span
565        }
566    } else {
567        0.0
568    }
569}
570
571impl Font {
572    /// Parse a font from its raw bytes (e.g. an `include_bytes!` blob).
573    ///
574    /// # Errors
575    /// Returns a [`FontError`] for a non-sfnt file, a missing required table, a
576    /// truncated file, or the absence of a usable Unicode `cmap`.
577    pub fn parse(data: Vec<u8>) -> Result<Self, FontError> {
578        let d = data.as_slice();
579        let magic = be_u32(d, 0).ok_or(FontError::Truncated)?;
580        // 0x00010000 = TrueType outlines; "true"; "OTTO" = CFF/OpenType.
581        if magic != 0x0001_0000 && magic != 0x7472_7565 && magic != 0x4F54_544F {
582            return Err(FontError::BadMagic);
583        }
584
585        let head = find_table(d, b"head").ok_or(FontError::MissingTable("head"))?;
586        let maxp = find_table(d, b"maxp").ok_or(FontError::MissingTable("maxp"))?;
587        let hhea = find_table(d, b"hhea").ok_or(FontError::MissingTable("hhea"))?;
588        let hmtx = find_table(d, b"hmtx").ok_or(FontError::MissingTable("hmtx"))?;
589        let cmap = find_table(d, b"cmap").ok_or(FontError::MissingTable("cmap"))?;
590
591        let units_per_em =
592            be_u16(d, off(head, 18).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
593        let num_glyphs =
594            be_u16(d, off(maxp, 4).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
595        let ascent =
596            be_i16(d, off(hhea, 4).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
597        let descent =
598            be_i16(d, off(hhea, 6).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
599        let line_gap =
600            be_i16(d, off(hhea, 8).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
601        let num_h_metrics =
602            be_u16(d, off(hhea, 34).ok_or(FontError::Truncated)?).ok_or(FontError::Truncated)?;
603
604        let (cmap_off, cmap_format) = select_cmap(d, cmap).ok_or(FontError::NoUnicodeCmap)?;
605        let cmap4_cache = if cmap_format == 4 {
606            parse_cmap4_cache(d, cmap_off)
607        } else {
608            None
609        };
610
611        // Outline tables are optional: present for TrueType (glyf) fonts, absent
612        // for CFF/OpenType. Their absence is not an error here.
613        let loca_long = off(head, 50)
614            .and_then(|offset| be_i16(d, offset))
615            .unwrap_or(0)
616            != 0;
617        let loca_off = find_table(d, b"loca");
618        let glyf = find_table_full(d, b"glyf");
619        let kern0 = find_kern0(d);
620        let mut variation =
621            find_table_full(d, b"fvar").and_then(|(off, len)| parse_fvar(d, off, len));
622        if let Some(var) = variation.as_mut() {
623            if let Some(avar) = find_table_full(d, b"avar")
624                .and_then(|(off, len)| parse_avar(d, off, len, var.axes.len()))
625            {
626                var.avar = avar;
627            }
628        }
629
630        let mut font = Self {
631            data,
632            units_per_em,
633            num_glyphs,
634            ascent,
635            descent,
636            line_gap,
637            num_h_metrics,
638            hmtx_off: hmtx,
639            cmap_off,
640            cmap_format,
641            cmap4_cache,
642            glyf,
643            loca_off,
644            loca_long,
645            kern0,
646            variation,
647            latin1_glyphs: [0; 256],
648            latin1_advances_1000: [0; 256],
649        };
650        for b in 0..256 {
651            let gid = match font.cmap_format {
652                4 => font.cmap4_lookup(b as u32).unwrap_or(0),
653                12 => font.cmap12_lookup(b as u32).unwrap_or(0),
654                _ => 0,
655            };
656            font.latin1_glyphs[b] = gid;
657        }
658        if font.units_per_em > 0 {
659            for b in 0..256 {
660                let gid = font.latin1_glyphs[b];
661                let aw = font.advance_width(gid) as u32;
662                font.latin1_advances_1000[b] = aw * 1000 / font.units_per_em as u32;
663            }
664        }
665        Ok(font)
666    }
667
668    /// True when the font carries TrueType (`glyf`) outlines we can read/subset.
669    #[must_use]
670    pub fn has_glyf_outlines(&self) -> bool {
671        self.glyf.is_some() && self.loca_off.is_some()
672    }
673
674    /// Variation axes from `fvar`, in table order. Empty for static fonts.
675    #[must_use]
676    pub fn axes(&self) -> &[VariationAxis] {
677        self.variation
678            .as_ref()
679            .map(|v| v.axes.as_slice())
680            .unwrap_or(&[])
681    }
682
683    /// Named instances from `fvar`. Empty when the table is absent or has none.
684    #[must_use]
685    pub fn named_instances(&self) -> &[NamedInstance] {
686        self.variation
687            .as_ref()
688            .map(|v| v.instances.as_slice())
689            .unwrap_or(&[])
690    }
691
692    /// User-space `(min, default, max)` for the axis whose tag is `tag`.
693    #[must_use]
694    pub fn instance_bounds(&self, tag: [u8; 4]) -> Option<AxisBounds> {
695        self.axes()
696            .iter()
697            .find(|a| a.tag == tag)
698            .map(|a| AxisBounds {
699                min: a.min,
700                default: a.default,
701                max: a.max,
702            })
703    }
704
705    /// Clamp `user` to the axis bounds, normalize to `[-1, 1]`, then apply
706    /// `avar` (identity when the table is absent or that axis has no maps).
707    ///
708    /// Values below `min` and above `max` map to the corresponding endpoints
709    /// (`-1` / `+1` after identity `avar`).
710    #[must_use]
711    pub fn normalized_axis(&self, tag: [u8; 4], user: f32) -> Option<f32> {
712        let var = self.variation.as_ref()?;
713        let (idx, axis) = var.axes.iter().enumerate().find(|(_, a)| a.tag == tag)?;
714        let mut n = normalize_user(user, axis);
715        if let Some(maps) = var.avar.get(idx).and_then(|m| m.as_ref()) {
716            n = avar_map(maps, n);
717        }
718        Some(n)
719    }
720
721    /// Instance this face at CSS `font-weight` `weight` on the `wght` axis.
722    ///
723    /// Applies `gvar` tuple deltas (packed point numbers, packed deltas, IUP)
724    /// and returns a **static** TrueType font: `fvar`/`avar`/`gvar` are
725    /// dropped, `glyf`/`loca` hold the frozen outlines. `None` when the font
726    /// has no `wght` axis, no TrueType outlines, or a table is unreadable.
727    ///
728    /// The same `weight` twice yields identical bytes.
729    #[must_use]
730    pub fn instance(&self, weight: f32) -> Option<Font> {
731        crate::gvar::instance_font(self, weight)
732    }
733
734    /// Current sfnt bytes. After [`Self::instance`], this is the static
735    /// instanced face (`fvar`/`avar`/`gvar` dropped).
736    #[must_use]
737    pub fn as_sfnt(&self) -> &[u8] {
738        &self.data
739    }
740
741    pub(crate) fn raw_bytes(&self) -> &[u8] {
742        self.as_sfnt()
743    }
744
745    /// The `[start, end)` byte range of glyph `gid` within the `glyf` table.
746    /// Returns `None` if the font has no `glyf`/`loca`, or `Some((s, s))` for an
747    /// empty glyph (e.g. space).
748    fn glyph_range(&self, gid: u16) -> Option<(usize, usize)> {
749        let loca = self.loca_off?;
750        let (glyf_off, glyf_len) = self.glyf?;
751        let i = gid as usize;
752        let (start, end) = if self.loca_long {
753            (
754                be_u32(&self.data, off_mul(loca, i, 4)?)? as usize,
755                be_u32(&self.data, off_mul(loca, i.checked_add(1)?, 4)?)? as usize,
756            )
757        } else {
758            // Short loca stores offsets / 2.
759            (
760                be_u16(&self.data, off_mul(loca, i, 2)?)? as usize * 2,
761                be_u16(&self.data, off_mul(loca, i.checked_add(1)?, 2)?)? as usize * 2,
762            )
763        };
764        if end < start || end > glyf_len {
765            return None;
766        }
767        Some((off(glyf_off, start)?, off(glyf_off, end)?))
768    }
769
770    /// Raw `glyf` bytes for glyph `gid` (for subset embedding), or `None`.
771    /// An empty (zero-length) glyph yields `Some(&[])`.
772    #[must_use]
773    pub fn glyph_data(&self, gid: u16) -> Option<&[u8]> {
774        let (s, e) = self.glyph_range(gid)?;
775        self.data.get(s..e)
776    }
777
778    /// Glyph bounding box `[xMin, yMin, xMax, yMax]` (design units), or `None`
779    /// for an empty glyph / no outlines.
780    #[must_use]
781    pub fn glyph_bbox(&self, gid: u16) -> Option<[i16; 4]> {
782        let (s, e) = self.glyph_range(gid)?;
783        if e <= s {
784            return None; // empty glyph (no contours)
785        }
786        Some([
787            be_i16(&self.data, off(s, 2)?)?,
788            be_i16(&self.data, off(s, 4)?)?,
789            be_i16(&self.data, off(s, 6)?)?,
790            be_i16(&self.data, off(s, 8)?)?,
791        ])
792    }
793
794    /// True when glyph `gid` is a composite (built from component glyphs).
795    #[must_use]
796    pub fn is_composite(&self, gid: u16) -> bool {
797        match self.glyph_range(gid) {
798            Some((s, e)) if e > s => be_i16(&self.data, s).is_some_and(|n| n < 0),
799            _ => false,
800        }
801    }
802
803    /// Component glyph ids referenced by a composite glyph (for transitive
804    /// subsetting). Empty for simple or empty glyphs.
805    #[must_use]
806    pub fn glyph_components(&self, gid: u16) -> Vec<u16> {
807        const ARG_WORDS: u16 = 0x0001;
808        const WE_HAVE_SCALE: u16 = 0x0008;
809        const MORE: u16 = 0x0020;
810        const X_Y_SCALE: u16 = 0x0040;
811        const TWO_BY_TWO: u16 = 0x0080;
812
813        let mut out = Vec::new();
814        let Some((s, e)) = self.glyph_range(gid) else {
815            return out;
816        };
817        if e <= s || be_i16(&self.data, s).is_none_or(|n| n >= 0) {
818            return out;
819        }
820        let Some(mut p) = off(s, 10) else {
821            return out;
822        };
823        while let Some(component_record_end) = off(p, 4) {
824            if component_record_end > e {
825                break;
826            }
827            let Some(flags) = be_u16(&self.data, p) else {
828                break;
829            };
830            let Some(comp) = off(p, 2).and_then(|offset| be_u16(&self.data, offset)) else {
831                break;
832            };
833            let mut step = 4usize + if flags & ARG_WORDS != 0 { 4 } else { 2 };
834            step += if flags & WE_HAVE_SCALE != 0 {
835                2
836            } else if flags & X_Y_SCALE != 0 {
837                4
838            } else if flags & TWO_BY_TWO != 0 {
839                8
840            } else {
841                0
842            };
843            let Some(next) = off(p, step) else {
844                break;
845            };
846            if next > e {
847                break;
848            }
849            out.push(comp);
850            p = next;
851            if flags & MORE == 0 || p >= e {
852                break;
853            }
854        }
855        out
856    }
857
858    /// The advance width of glyph `gid` in design units. Glyphs past the
859    /// `hmtx` metric run share the last advance (monospaced trailing run).
860    #[must_use]
861    pub fn advance_width(&self, gid: u16) -> u16 {
862        let last = self.num_h_metrics.saturating_sub(1);
863        let idx = gid.min(last) as usize;
864        off_mul(self.hmtx_off, idx, 4)
865            .and_then(|offset| be_u16(&self.data, offset))
866            .unwrap_or(0)
867    }
868
869    /// The left side bearing of glyph `gid` in design units. Glyphs past the
870    /// long-metric run share the last advance but keep their own trailing LSB.
871    #[must_use]
872    pub fn left_side_bearing(&self, gid: u16) -> i16 {
873        if self.num_h_metrics == 0 {
874            return 0;
875        }
876        let gid = gid as usize;
877        let num_h_metrics = self.num_h_metrics as usize;
878        let offset = if gid < num_h_metrics {
879            off_mul(self.hmtx_off, gid, 4).and_then(|base| off(base, 2))
880        } else {
881            off_mul(self.hmtx_off, num_h_metrics, 4)
882                .and_then(|base| off_mul(base, gid - num_h_metrics, 2))
883        };
884        offset
885            .and_then(|offset| be_i16(&self.data, offset))
886            .unwrap_or(0)
887    }
888
889    /// The glyph id for a character, or `0` (`.notdef`) if unmapped.
890    #[must_use]
891    #[inline(always)]
892    pub fn glyph_index(&self, ch: char) -> u16 {
893        let cp = ch as u32;
894        if cp < 256 {
895            return self.latin1_glyphs[cp as usize];
896        }
897        match self.cmap_format {
898            4 => self.cmap4_lookup(cp).unwrap_or(0),
899            12 => self.cmap12_lookup(cp).unwrap_or(0),
900            _ => 0,
901        }
902    }
903
904    /// Advance width of `ch` in 1/1000 em (PDF text-space units). An unmapped
905    /// `ch` resolves to glyph 0 (`.notdef`) and reserves that glyph's advance
906    /// (so a tofu box still occupies its natural width); only an unparsable face
907    /// with `units_per_em == 0` yields `0`.
908    #[must_use]
909    #[inline(always)]
910    pub fn advance_1000(&self, ch: char) -> u32 {
911        let cp = ch as u32;
912        if cp < 256 {
913            return self.latin1_advances_1000[cp as usize];
914        }
915        if self.units_per_em == 0 {
916            return 0;
917        }
918        let aw = self.advance_width(self.glyph_index(ch)) as u32;
919        aw * 1000 / self.units_per_em as u32
920    }
921
922    /// Kerning adjustment between two glyph ids in design units.
923    ///
924    /// Unsupported or absent kerning tables return zero. This currently supports
925    /// legacy TrueType/Microsoft `kern` table version 0, format 0, horizontal
926    /// pairs. GPOS pair positioning is tracked separately.
927    #[must_use]
928    pub fn kerning_between_glyphs(&self, left: u16, right: u16) -> i16 {
929        let Some((pairs, n_pairs)) = self.kern0 else {
930            return 0;
931        };
932        let target = ((left as u32) << 16) | right as u32;
933        let mut lo = 0usize;
934        let mut hi = n_pairs as usize;
935        while lo < hi {
936            let mid = lo + (hi - lo) / 2;
937            let Some(rec) = off_mul(pairs, mid, 6) else {
938                return 0;
939            };
940            let Some(l) = be_u16(&self.data, rec) else {
941                return 0;
942            };
943            let Some(r) = off(rec, 2).and_then(|offset| be_u16(&self.data, offset)) else {
944                return 0;
945            };
946            let key = ((l as u32) << 16) | r as u32;
947            if key == target {
948                return off(rec, 4)
949                    .and_then(|offset| be_i16(&self.data, offset))
950                    .unwrap_or(0);
951            }
952            if key < target {
953                lo = mid + 1;
954            } else {
955                hi = mid;
956            }
957        }
958        0
959    }
960
961    /// Kerning adjustment between two characters in design units.
962    #[must_use]
963    pub fn kerning(&self, left: char, right: char) -> i16 {
964        self.kerning_between_glyphs(self.glyph_index(left), self.glyph_index(right))
965    }
966
967    /// Kerning adjustment between two characters in 1/1000 em units.
968    #[must_use]
969    pub fn kerning_1000(&self, left: char, right: char) -> i32 {
970        if self.units_per_em == 0 {
971            return 0;
972        }
973        self.kerning(left, right) as i32 * 1000 / self.units_per_em as i32
974    }
975
976    fn cmap4_lookup(&self, cp: u32) -> Option<u16> {
977        if cp > 0xFFFF {
978            return Some(0);
979        }
980        let c = cp as u16;
981        if let Some(cache) = &self.cmap4_cache {
982            return self.cmap4_cached_lookup(c, cache);
983        }
984        self.cmap4_uncached_lookup(c)
985    }
986
987    fn cmap4_cached_lookup(&self, c: u16, cache: &Cmap4Cache) -> Option<u16> {
988        let segment = if cache.sorted_by_end {
989            let idx = cache.segments.partition_point(|seg| seg.end < c);
990            cache.segments.get(idx)
991        } else {
992            cache.segments.iter().find(|seg| c <= seg.end)
993        }?;
994
995        if c < segment.start {
996            return Some(0);
997        }
998        if segment.id_range_offset == 0 {
999            return Some(c.wrapping_add(segment.id_delta));
1000        }
1001        let gi_addr = off(
1002            off(
1003                segment.id_range_offset_pos,
1004                segment.id_range_offset as usize,
1005            )?,
1006            2usize.checked_mul((c - segment.start) as usize)?,
1007        )?;
1008        let g = be_u16(&self.data, gi_addr)?;
1009        Some(if g == 0 {
1010            0
1011        } else {
1012            g.wrapping_add(segment.id_delta)
1013        })
1014    }
1015
1016    fn cmap4_uncached_lookup(&self, c: u16) -> Option<u16> {
1017        let d = &self.data;
1018        let base = self.cmap_off;
1019        let seg_x2 = be_u16(d, off(base, 6)?)? as usize;
1020        let seg_count = seg_x2 / 2;
1021        let end_codes = off(base, 14)?;
1022        let start_codes = off(off(end_codes, seg_x2)?, 2)?; // +2 for reservedPad
1023        let id_deltas = off(start_codes, seg_x2)?;
1024        let id_range_offsets = off(id_deltas, seg_x2)?;
1025        for i in 0..seg_count {
1026            let end = be_u16(d, off_mul(end_codes, i, 2)?)?;
1027            if c > end {
1028                continue;
1029            }
1030            let start = be_u16(d, off_mul(start_codes, i, 2)?)?;
1031            if c < start {
1032                return Some(0);
1033            }
1034            let id_delta = be_u16(d, off_mul(id_deltas, i, 2)?)?;
1035            let iro_pos = off_mul(id_range_offsets, i, 2)?;
1036            let id_range_offset = be_u16(d, iro_pos)?;
1037            if id_range_offset == 0 {
1038                return Some(c.wrapping_add(id_delta));
1039            }
1040            let gi_addr = off(
1041                off(iro_pos, id_range_offset as usize)?,
1042                2usize.checked_mul((c - start) as usize)?,
1043            )?;
1044            let g = be_u16(d, gi_addr)?;
1045            return Some(if g == 0 { 0 } else { g.wrapping_add(id_delta) });
1046        }
1047        Some(0)
1048    }
1049
1050    /// Build a new, minimal, valid TrueType (`glyf`) font containing glyph 0
1051    /// (`.notdef`) plus exactly the glyphs needed to render `keep` (mapped
1052    /// through the original `cmap`), transitively closing over composite
1053    /// components. Returns a fresh sfnt (`0x00010000`) suitable for a PDF
1054    /// `FontFile2`, or `None` on any failure (missing `glyf`/`loca`/required
1055    /// table, or a malformed read).
1056    #[must_use]
1057    pub fn subset(&self, keep: &[char]) -> Option<Vec<u8>> {
1058        let seed: Vec<u16> = keep.iter().map(|&c| self.glyph_index(c)).collect();
1059        // Web-embedding path: include `OS/2` (see `subset_core`) so browser
1060        // OpenType sanitizers (Chromium's OTS) accept the font instead of
1061        // silently falling back to system fonts.
1062        self.subset_core(&seed, keep, true, false)
1063            .ok()
1064            .map(|(bytes, _)| bytes)
1065    }
1066
1067    /// Subset to an explicit glyph set (the closure still pulls in composite
1068    /// components), building the `cmap` from `cmap_chars`. Returns the font bytes
1069    /// plus the old->new glyph id remap — for callers that pre-shaped a glyph
1070    /// sequence (e.g. GSUB ligatures) and must emit the renumbered ids.
1071    ///
1072    /// The map is the ordered projection of [`Font::subset_glyphs_with_lookup`];
1073    /// prefer that method when a dense lookup table is more useful than an
1074    /// ordered map (same font bytes, no per-glyph tree nodes).
1075    ///
1076    /// # Errors
1077    /// Returns `None` for a font without `glyf`/`loca` outlines or on a malformed
1078    /// read (same conditions as [`Font::subset`]).
1079    pub fn subset_glyphs(
1080        &self,
1081        glyphs: &[u16],
1082        cmap_chars: &[char],
1083    ) -> Option<(Vec<u8>, std::collections::BTreeMap<u16, u16>)> {
1084        let (bytes, lookup) = self.subset_glyphs_with_lookup(glyphs, cmap_chars)?;
1085        let mut new_of = std::collections::BTreeMap::new();
1086        for (old, new) in lookup.into_iter().enumerate() {
1087            if new != MISSING_GLYPH_REMAP {
1088                new_of.insert(u16::try_from(old).ok()?, new);
1089            }
1090        }
1091        Some((bytes, new_of))
1092    }
1093
1094    /// Subset to an explicit glyph set (same closure and `cmap` construction as
1095    /// [`Font::subset_glyphs`]), returning the font bytes plus the subsetter's
1096    /// own dense old->new lookup: `lookup[old]` is the glyph's renumbered id in
1097    /// the subset, or [`MISSING_GLYPH_REMAP`] when `old` is not part of it.
1098    /// The vector has `max(num_glyphs, 1)` entries indexed by old gid — the
1099    /// exact table the subsetter builds internally, so callers translating
1100    /// pre-shaped glyph runs need neither an ordered map nor a rebuild of
1101    /// this very vector. Font bytes are identical to [`Font::subset_glyphs`].
1102    ///
1103    /// # Errors
1104    /// Returns `None` for a font without `glyf`/`loca` outlines or on a malformed
1105    /// read (same conditions as [`Font::subset`]).
1106    pub fn subset_glyphs_with_lookup(
1107        &self,
1108        glyphs: &[u16],
1109        cmap_chars: &[char],
1110    ) -> Option<(Vec<u8>, Vec<u16>)> {
1111        // PDF font programs do not require `OS/2`; leaving it out keeps the
1112        // embedded font streams (and existing golden PDF bytes) unchanged.
1113        self.subset_core(glyphs, cmap_chars, false, false).ok()
1114    }
1115
1116    fn subset_core(
1117        &self,
1118        seed_glyphs: &[u16],
1119        cmap_chars: &[char],
1120        include_os2: bool,
1121        strict: bool,
1122    ) -> Result<(Vec<u8>, Vec<u16>), SubsetError> {
1123        let mut error = SubsetError::new(SubsetErrorKind::Malformed, *b"sfnt", None, None);
1124        // --- 1. Glyph closure ------------------------------------------------
1125        // Require TrueType outlines; CFF/`OTTO` fonts cannot be subset here.
1126        if !self.has_glyf_outlines() {
1127            return Err(SubsetError::new(
1128                SubsetErrorKind::UnsupportedFormat,
1129                *b"sfnt",
1130                None,
1131                None,
1132            ));
1133        }
1134        let mut set: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
1135        set.insert(0);
1136        for &gid in seed_glyphs {
1137            if strict && gid >= self.num_glyphs {
1138                return Err(SubsetError::new(
1139                    SubsetErrorKind::InvalidGlyph,
1140                    *b"maxp",
1141                    Some(gid),
1142                    None,
1143                ));
1144            }
1145            if gid != 0 && gid < self.num_glyphs {
1146                set.insert(gid);
1147            }
1148        }
1149        // Transitively pull in composite components until the set is stable.
1150        // A worklist expands each glyph's components exactly once, so a chain of
1151        // composites (glyph k referencing k-1 referencing ...) is O(n) instead of
1152        // the O(n^2) that re-scanning the whole growing set each round would cost.
1153        // `BTreeSet::insert` returns false for an already-present component, which
1154        // also terminates cyclic/self-referential composites. The final set — and
1155        // hence the ascending `old_gids` and the whole subset — is identical.
1156        let mut worklist: Vec<u16> = set.iter().copied().collect();
1157        while let Some(gid) = worklist.pop() {
1158            if strict {
1159                self.validate_subset_glyph(gid)?;
1160            }
1161            if self.is_composite(gid) {
1162                for c in self.glyph_components(gid) {
1163                    if c < self.num_glyphs && set.insert(c) {
1164                        worklist.push(c);
1165                    }
1166                }
1167            }
1168        }
1169        let old_gids: Vec<u16> = set.into_iter().collect(); // ascending, 0 first
1170        // --- 2. Renumber old -> new -----------------------------------------
1171        // Dense table: new_of_lookup[old] = new gid (or MISSING_GLYPH_REMAP).
1172        // Returned to callers directly (subset_glyphs_with_lookup); the
1173        // ordered BTreeMap variant is reconstructed from it on demand.
1174        let mut new_of_lookup = vec![MISSING_GLYPH_REMAP; usize::from(self.num_glyphs).max(1)];
1175        for (i, &g) in old_gids.iter().enumerate() {
1176            let new_gid = u16::try_from(i).ok().ok_or(error)?;
1177            *new_of_lookup.get_mut(usize::from(g)).ok_or(error)? = new_gid;
1178        }
1179        let n = old_gids.len();
1180        let n_u16 = u16::try_from(n).ok().ok_or(error)?;
1181
1182        // --- 3. Rebuild glyf + loca (long offsets) --------------------------
1183        let mut glyf_bytes: Vec<u8> = Vec::with_capacity(n.saturating_mul(64));
1184        let mut loca_bytes: Vec<u8> =
1185            Vec::with_capacity(n.checked_add(1).ok_or(error)?.checked_mul(4).ok_or(error)?);
1186        for &old in &old_gids {
1187            error = SubsetError::new(SubsetErrorKind::Malformed, *b"glyf", Some(old), None);
1188            let offset = u32::try_from(glyf_bytes.len()).ok().ok_or(error)?;
1189            loca_bytes.extend_from_slice(&offset.to_be_bytes());
1190            let gb = self.subset_glyph_bytes(old, &new_of_lookup).ok_or(error)?;
1191            glyf_bytes.extend_from_slice(&gb);
1192            // Pad each glyph to a 4-byte multiple so the next glyph (and every
1193            // long-loca offset) is word-aligned.
1194            let rem = glyf_bytes.len() % 4;
1195            if rem != 0 {
1196                glyf_bytes.resize(glyf_bytes.len() + (4 - rem), 0);
1197            }
1198        }
1199        let final_offset = u32::try_from(glyf_bytes.len()).ok().ok_or(error)?;
1200        loca_bytes.extend_from_slice(&final_offset.to_be_bytes());
1201
1202        // --- 4. Metric/meta tables ------------------------------------------
1203        // maxp: original bytes with numGlyphs (u16 @ +4) set to n.
1204        error = SubsetError::new(SubsetErrorKind::Malformed, *b"maxp", None, None);
1205        let (maxp_off, maxp_len) = find_table_full(&self.data, b"maxp").ok_or(error)?;
1206        let mut maxp = self
1207            .data
1208            .get(maxp_off..off(maxp_off, maxp_len).ok_or(error)?)
1209            .ok_or(error)?
1210            .to_vec();
1211        write_u16(&mut maxp, 4, n_u16).ok_or(error)?;
1212
1213        // hhea: original bytes with numberOfHMetrics (u16 @ +34) set to n.
1214        error = SubsetError::new(SubsetErrorKind::Malformed, *b"hhea", None, None);
1215        let (hhea_off, hhea_len) = find_table_full(&self.data, b"hhea").ok_or(error)?;
1216        let mut hhea = self
1217            .data
1218            .get(hhea_off..off(hhea_off, hhea_len).ok_or(error)?)
1219            .ok_or(error)?
1220            .to_vec();
1221        write_u16(&mut hhea, 34, n_u16).ok_or(error)?;
1222
1223        // hmtx: n long metrics (advanceWidth + true lsb), no trailing run.
1224        let mut hmtx: Vec<u8> = Vec::with_capacity(n.checked_mul(4).ok_or(error)?);
1225        for &old in &old_gids {
1226            let [a0, a1] = self.advance_width(old).to_be_bytes();
1227            let [l0, l1] = self.left_side_bearing(old).to_be_bytes();
1228            hmtx.extend_from_slice(&[a0, a1, l0, l1]);
1229        }
1230
1231        // head: original bytes; zero checkSumAdjustment (@ +8), force long loca.
1232        error = SubsetError::new(SubsetErrorKind::Malformed, *b"head", None, None);
1233        let (head_off, head_len) = find_table_full(&self.data, b"head").ok_or(error)?;
1234        let mut head = self
1235            .data
1236            .get(head_off..off(head_off, head_len).ok_or(error)?)
1237            .ok_or(error)?
1238            .to_vec();
1239        write_u32(&mut head, 8, 0).ok_or(error)?;
1240        write_u16(&mut head, 50, 1).ok_or(error)?; // indexToLocFormat = 1 (long)
1241
1242        // cmap: fresh single-subtable table. Format 4 (`(3,1)`, BMP-only)
1243        // remains the default so every existing BMP-only subset stays
1244        // byte-identical; format 12 (`(3,10)`, full Unicode) is required as
1245        // soon as any kept supplementary-plane glyph survives, because
1246        // format 4's u16 segment arrays cannot address codepoints past 0xFFFF.
1247        error = SubsetError::new(SubsetErrorKind::Capacity, *b"cmap", None, None);
1248        let cmap = if self.subset_reaches_supplementary_plane(cmap_chars, &new_of_lookup) {
1249            self.build_cmap12(cmap_chars, &new_of_lookup).ok_or(error)?
1250        } else {
1251            self.build_cmap4(cmap_chars, &new_of_lookup).ok_or(error)?
1252        };
1253
1254        // name: minimal valid table (format 0, count 0, stringOffset 6).
1255        let mut name: Vec<u8> = Vec::with_capacity(6);
1256        name.extend_from_slice(&0u16.to_be_bytes());
1257        name.extend_from_slice(&0u16.to_be_bytes());
1258        name.extend_from_slice(&6u16.to_be_bytes());
1259
1260        // post: format 3.0, 32 bytes, all metric fields zero.
1261        let mut post: Vec<u8> = Vec::with_capacity(32);
1262        post.extend_from_slice(&0x0003_0000u32.to_be_bytes()); // version 3.0
1263        post.extend_from_slice(&0u32.to_be_bytes()); // italicAngle
1264        post.extend_from_slice(&0u16.to_be_bytes()); // underlinePosition
1265        post.extend_from_slice(&0u16.to_be_bytes()); // underlineThickness
1266        post.extend_from_slice(&0u32.to_be_bytes()); // isFixedPitch
1267        post.extend_from_slice(&0u32.to_be_bytes()); // minMemType42
1268        post.extend_from_slice(&0u32.to_be_bytes()); // maxMemType42
1269        post.extend_from_slice(&0u32.to_be_bytes()); // minMemType1
1270        post.extend_from_slice(&0u32.to_be_bytes()); // maxMemType1
1271
1272        // OS/2: copied verbatim from the source face when requested and
1273        // present. Browsers' OpenType sanitizer (Chromium's OTS) rejects web
1274        // fonts without an `OS/2` table ("OS/2: missing required table"), so
1275        // the HTML embedding path opts in. The aggregate fields (average
1276        // width, Unicode ranges, win metrics) remain those of the full face,
1277        // which is valid if conservative for a subset. A source face without
1278        // `OS/2` subsets as before and is rejected by OTS either way.
1279        let os2: Option<Vec<u8>> = if include_os2 {
1280            find_table_full(&self.data, b"OS/2")
1281                .and_then(|(o, l)| Some(self.data.get(o..off(o, l)?)?.to_vec()))
1282        } else {
1283            None
1284        };
1285
1286        // --- 5. Assemble the sfnt -------------------------------------------
1287        let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![
1288            (b"head", head),
1289            (b"hhea", hhea),
1290            (b"maxp", maxp),
1291            (b"hmtx", hmtx),
1292            (b"loca", loca_bytes),
1293            (b"glyf", glyf_bytes),
1294            (b"cmap", cmap),
1295            (b"name", name),
1296            (b"post", post),
1297        ];
1298        if let Some(os2) = os2 {
1299            tables.push((b"OS/2", os2));
1300        }
1301        tables.sort_by(|a, b| a.0.cmp(b.0)); // ascending by tag
1302
1303        error = SubsetError::new(SubsetErrorKind::Capacity, *b"sfnt", None, None);
1304        let num_tables = tables.len();
1305        // searchRange = (2^floor(log2(n)))*16, entrySelector = floor(log2(n)).
1306        let mut pw: usize = 1;
1307        let mut es: u16 = 0;
1308        while pw * 2 <= num_tables {
1309            pw *= 2;
1310            es += 1;
1311        }
1312        let search_range = (pw as u16).wrapping_mul(16);
1313        let entry_selector = es;
1314        let range_shift = (num_tables as u16)
1315            .wrapping_mul(16)
1316            .wrapping_sub(search_range);
1317
1318        let dir_size = 12 + num_tables * 16;
1319        let mut body: Vec<u8> = Vec::new();
1320        // (tag, checksum, offset, length)
1321        let mut records: Vec<([u8; 4], u32, u32, u32)> = Vec::with_capacity(num_tables);
1322        let mut head_offset: usize = 0;
1323        for (tag, bytes) in &tables {
1324            // Align each table's file start to a 4-byte boundary.
1325            while (dir_size + body.len()) % 4 != 0 {
1326                body.push(0);
1327            }
1328            let table_offset = dir_size + body.len();
1329            if *tag == b"head" {
1330                head_offset = table_offset;
1331            }
1332            let checksum = table_checksum(bytes);
1333            records.push((
1334                **tag,
1335                checksum,
1336                u32::try_from(table_offset).ok().ok_or(error)?,
1337                u32::try_from(bytes.len()).ok().ok_or(error)?,
1338            ));
1339            body.extend_from_slice(bytes);
1340        }
1341        while body.len() % 4 != 0 {
1342            body.push(0);
1343        }
1344
1345        let mut out: Vec<u8> = Vec::with_capacity(dir_size + body.len());
1346        out.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // sfntVersion
1347        out.extend_from_slice(&(num_tables as u16).to_be_bytes());
1348        out.extend_from_slice(&search_range.to_be_bytes());
1349        out.extend_from_slice(&entry_selector.to_be_bytes());
1350        out.extend_from_slice(&range_shift.to_be_bytes());
1351        for (tag, checksum, toff, tlen) in &records {
1352            out.extend_from_slice(tag);
1353            out.extend_from_slice(&checksum.to_be_bytes());
1354            out.extend_from_slice(&toff.to_be_bytes());
1355            out.extend_from_slice(&tlen.to_be_bytes());
1356        }
1357        out.extend_from_slice(&body);
1358
1359        // checkSumAdjustment: 0xB1B0AFBA - checksum(whole file with field zeroed).
1360        let file_checksum = table_checksum(&out);
1361        let adj = 0xB1B0_AFBAu32.wrapping_sub(file_checksum);
1362        write_u32(&mut out, off(head_offset, 8).ok_or(error)?, adj).ok_or(error)?;
1363
1364        Ok((out, new_of_lookup))
1365    }
1366
1367    /// Glyph bytes for the subset: simple glyphs are copied without hinting
1368    /// instructions; composite glyphs are copied with each component `glyphIndex`
1369    /// (u16) rewritten from its old gid to its new gid and any trailing
1370    /// instructions removed. Empty glyphs yield an empty `Vec`.
1371    fn subset_glyph_bytes(&self, old: u16, new_of: &[u16]) -> Option<Vec<u8>> {
1372        const ARG_WORDS: u16 = 0x0001;
1373        const WE_HAVE_SCALE: u16 = 0x0008;
1374        const MORE: u16 = 0x0020;
1375        const X_Y_SCALE: u16 = 0x0040;
1376        const TWO_BY_TWO: u16 = 0x0080;
1377        const WE_HAVE_INSTRUCTIONS: u16 = 0x0100;
1378
1379        let data = self.glyph_data(old).unwrap_or(&[]);
1380        if data.is_empty() {
1381            return Some(Vec::new());
1382        }
1383        let num_contours = be_i16(data, 0)?;
1384        if num_contours >= 0 {
1385            return strip_simple_glyph_instructions(data, num_contours as usize);
1386        }
1387        // Composite: walk component records, rewriting each glyphIndex.
1388        let mut out = data.to_vec();
1389        let mut p = 10usize; // skip numberOfContours + 4x i16 bbox
1390        let mut instruction_flags_positions = Vec::new();
1391        let mut dangling_more = false;
1392        loop {
1393            let last_flags_pos = p;
1394            let flags = be_u16(&out, p)?;
1395
1396            if flags & WE_HAVE_INSTRUCTIONS != 0 {
1397                instruction_flags_positions.push(p);
1398            }
1399            let comp_old = be_u16_at(&out, p, 2)?;
1400            // A component that fell outside the subset (e.g. a component gid
1401            // >= numGlyphs in a malformed font — the closure never reaches it)
1402            // is substituted with `.notdef` (new gid 0, always present) rather
1403            // than failing the whole font. The composite still renders, minus
1404            // the one bad component.
1405            let comp_new = remapped_gid(new_of, comp_old).unwrap_or(0);
1406            let nb = comp_new.to_be_bytes();
1407            *out.get_mut(off(p, 2)?)? = nb[0];
1408            *out.get_mut(off(p, 3)?)? = nb[1];
1409            p = off(p, 4)?;
1410            p = off(p, if flags & ARG_WORDS != 0 { 4 } else { 2 })?;
1411            if flags & WE_HAVE_SCALE != 0 {
1412                p = off(p, 2)?;
1413            } else if flags & X_Y_SCALE != 0 {
1414                p = off(p, 4)?;
1415            } else if flags & TWO_BY_TWO != 0 {
1416                p = off(p, 8)?;
1417            }
1418            if flags & MORE == 0 {
1419                break;
1420            }
1421
1422            // If MORE_COMPONENTS was set but no bytes remain for a complete
1423            // component header (flags + gid = 4 bytes), clear the dangling
1424            // MORE flag on the current record and finish the walk — the same
1425            // tolerance the component reader applies to a malformed final
1426            // record, so one bad composite cannot fail the entire subset. A
1427            // dangling record claiming instructions has no instruction bytes
1428            // behind it either, so its WE_HAVE_INSTRUCTIONS claim is dropped
1429            // here and the strip pass below is skipped entirely.
1430            if off(p, 4).is_none_or(|end| end > out.len()) {
1431                write_u16(
1432                    &mut out,
1433                    last_flags_pos,
1434                    flags & !(MORE | WE_HAVE_INSTRUCTIONS),
1435                )?;
1436                dangling_more = true;
1437                break;
1438            }
1439        }
1440        if dangling_more {
1441            for flags_pos in &instruction_flags_positions {
1442                let flags = be_u16(&out, *flags_pos)?;
1443                write_u16(&mut out, *flags_pos, flags & !WE_HAVE_INSTRUCTIONS)?;
1444            }
1445        } else if !instruction_flags_positions.is_empty() {
1446            for flags_pos in instruction_flags_positions {
1447                let flags = be_u16(&out, flags_pos)?;
1448                write_u16(&mut out, flags_pos, flags & !WE_HAVE_INSTRUCTIONS)?;
1449            }
1450            let instruction_len = be_u16(&out, p)? as usize;
1451            let instruction_start = off(p, 2)?;
1452            let instruction_end = off(instruction_start, instruction_len)?;
1453            if instruction_end > out.len() {
1454                return None;
1455            }
1456            out.drain(p..instruction_end);
1457        }
1458        Some(out)
1459    }
1460
1461    /// Build a complete `cmap` table holding a single format-4 `(3,1)` subtable
1462    /// mapping every BMP char in `keep` to its NEW gid (one 1-char segment each,
1463    /// plus the mandatory final `0xFFFF` segment).
1464    fn build_cmap4(&self, keep: &[char], new_of: &[u16]) -> Option<Vec<u8>> {
1465        // Unique, ascending code -> new gid (0xFFFF reserved for the final seg).
1466        let mut codes: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
1467        for &ch in keep {
1468            let cp = ch as u32;
1469            if cp >= 0xFFFF {
1470                continue;
1471            }
1472            let old = self.glyph_index(ch);
1473            // Skip a char whose glyph is not in the subset (a malformed source
1474            // cmap, or a glyph the closure could not reach) instead of failing the
1475            // whole font; it falls back to `.notdef` at render time.
1476            let Some(ng) = remapped_gid(new_of, old) else {
1477                continue;
1478            };
1479            codes.insert(cp as u16, ng);
1480        }
1481        let entries: Vec<(u16, u16)> = codes.into_iter().collect();
1482        let seg_count = entries.len().checked_add(1)?; // + final 0xFFFF segment
1483        let sub_len = 16usize.checked_add(seg_count.checked_mul(8)?)?;
1484        let sub_len_u16 = u16::try_from(sub_len).ok()?;
1485        let seg_count_x2 = u16::try_from(seg_count.checked_mul(2)?).ok()?;
1486
1487        let mut pw: usize = 1;
1488        let mut es: u16 = 0;
1489        while pw * 2 <= seg_count {
1490            pw *= 2;
1491            es += 1;
1492        }
1493        let search_range = u16::try_from(pw.checked_mul(2)?).ok()?;
1494        let entry_selector = es;
1495        let range_shift = seg_count_x2.checked_sub(search_range)?;
1496
1497        let mut sub: Vec<u8> = Vec::with_capacity(sub_len);
1498        sub.extend_from_slice(&4u16.to_be_bytes()); // format
1499        sub.extend_from_slice(&sub_len_u16.to_be_bytes()); // length
1500        sub.extend_from_slice(&0u16.to_be_bytes()); // language
1501        sub.extend_from_slice(&seg_count_x2.to_be_bytes()); // segCountX2
1502        sub.extend_from_slice(&search_range.to_be_bytes());
1503        sub.extend_from_slice(&entry_selector.to_be_bytes());
1504        sub.extend_from_slice(&range_shift.to_be_bytes());
1505        // endCode[]
1506        for &(code, _) in &entries {
1507            sub.extend_from_slice(&code.to_be_bytes());
1508        }
1509        sub.extend_from_slice(&0xFFFFu16.to_be_bytes());
1510        // reservedPad
1511        sub.extend_from_slice(&0u16.to_be_bytes());
1512        // startCode[]
1513        for &(code, _) in &entries {
1514            sub.extend_from_slice(&code.to_be_bytes());
1515        }
1516        sub.extend_from_slice(&0xFFFFu16.to_be_bytes());
1517        // idDelta[]: (code + idDelta) & 0xFFFF == new gid.
1518        for &(code, ng) in &entries {
1519            sub.extend_from_slice(&ng.wrapping_sub(code).to_be_bytes());
1520        }
1521        // Final segment idDelta = 1.
1522        sub.extend_from_slice(&1u16.to_be_bytes());
1523        // idRangeOffset[] (all zero, glyphIdArray empty).
1524        for _ in &entries {
1525            sub.extend_from_slice(&0u16.to_be_bytes());
1526        }
1527        sub.extend_from_slice(&0u16.to_be_bytes());
1528
1529        let mut cmap: Vec<u8> = Vec::with_capacity(12 + sub.len());
1530        cmap.extend_from_slice(&0u16.to_be_bytes()); // version
1531        cmap.extend_from_slice(&1u16.to_be_bytes()); // numTables
1532        cmap.extend_from_slice(&3u16.to_be_bytes()); // platformID (Windows)
1533        cmap.extend_from_slice(&1u16.to_be_bytes()); // encodingID (Unicode BMP)
1534        cmap.extend_from_slice(&12u32.to_be_bytes()); // subtable offset
1535        cmap.extend_from_slice(&sub);
1536        Some(cmap)
1537    }
1538
1539    /// Whether any char in `keep` maps to a supplementary-plane codepoint
1540    /// with a surviving glyph, which format 4's u16 segments cannot address.
1541    fn subset_reaches_supplementary_plane(&self, keep: &[char], new_of: &[u16]) -> bool {
1542        keep.iter().any(|&ch| {
1543            (ch as u32) >= 0x1_0000 && remapped_gid(new_of, self.glyph_index(ch)).is_some()
1544        })
1545    }
1546
1547    /// Build a complete `cmap` table holding a single format-12 `(3,10)`
1548    /// subtable mapping every kept char — supplementary-plane math
1549    /// alphanumeric letters included — to its NEW gid. Entries merge into a
1550    /// group only where both the codepoints and their new gids are fully
1551    /// contiguous; otherwise one single-char group per entry, mirroring
1552    /// `build_cmap4`'s segment shape. Output stays deterministic.
1553    fn build_cmap12(&self, keep: &[char], new_of: &[u16]) -> Option<Vec<u8>> {
1554        // Unique, ascending codepoint -> new gid.
1555        let mut codes: std::collections::BTreeMap<u32, u16> = std::collections::BTreeMap::new();
1556        for &ch in keep {
1557            let old = self.glyph_index(ch);
1558            let Some(ng) = remapped_gid(new_of, old) else {
1559                continue;
1560            };
1561            codes.insert(u32::from(ch), ng);
1562        }
1563        struct Group {
1564            start_cp: u32,
1565            end_cp: u32,
1566            start_gid: u16,
1567        }
1568        let mut groups: Vec<Group> = Vec::with_capacity(codes.len());
1569        for (&cp, &ng) in &codes {
1570            match groups.last_mut() {
1571                Some(g)
1572                    if g.end_cp.checked_add(1) == Some(cp)
1573                        && u64::from(g.start_gid) + (g.end_cp - g.start_cp) as u64 + 1
1574                            == u64::from(ng) =>
1575                {
1576                    g.end_cp = cp;
1577                }
1578                _ => groups.push(Group {
1579                    start_cp: cp,
1580                    end_cp: cp,
1581                    start_gid: ng,
1582                }),
1583            }
1584        }
1585
1586        let sub_len = 16usize.checked_add(groups.len().checked_mul(12)?)?;
1587        if sub_len > u32::MAX as usize {
1588            return None;
1589        }
1590        let mut sub: Vec<u8> = Vec::with_capacity(sub_len);
1591        sub.extend_from_slice(&12u16.to_be_bytes()); // format
1592        sub.extend_from_slice(&0u16.to_be_bytes()); // reserved
1593        sub.extend_from_slice(&(sub_len as u32).to_be_bytes()); // length
1594        sub.extend_from_slice(&0u32.to_be_bytes()); // language
1595        sub.extend_from_slice(&(groups.len() as u32).to_be_bytes()); // numGroups
1596        for g in &groups {
1597            sub.extend_from_slice(&g.start_cp.to_be_bytes());
1598            sub.extend_from_slice(&g.end_cp.to_be_bytes());
1599            sub.extend_from_slice(&u32::from(g.start_gid).to_be_bytes());
1600        }
1601
1602        let mut cmap: Vec<u8> = Vec::with_capacity(12 + sub.len());
1603        cmap.extend_from_slice(&0u16.to_be_bytes()); // version
1604        cmap.extend_from_slice(&1u16.to_be_bytes()); // numTables
1605        cmap.extend_from_slice(&3u16.to_be_bytes()); // platformID (Windows)
1606        cmap.extend_from_slice(&10u16.to_be_bytes()); // encodingID (full Unicode)
1607        cmap.extend_from_slice(&12u32.to_be_bytes()); // subtable offset
1608        cmap.extend_from_slice(&sub);
1609        Some(cmap)
1610    }
1611
1612    fn cmap12_lookup(&self, cp: u32) -> Option<u16> {
1613        let d = &self.data;
1614        let base = self.cmap_off;
1615        let num_groups = be_u32(d, off(base, 12)?)? as usize;
1616        for i in 0..num_groups {
1617            let g = off_mul(off(base, 16)?, i, 12)?;
1618            let start = be_u32(d, g)?;
1619            let end = be_u32(d, off(g, 4)?)?;
1620            if cp >= start && cp <= end {
1621                let start_gid = be_u32(d, off(g, 8)?)?;
1622                let gid = start_gid.checked_add(cp - start)?;
1623                return Some((gid & 0xFFFF) as u16);
1624            }
1625        }
1626        Some(0)
1627    }
1628}
1629
1630fn remapped_gid(new_of: &[u16], old: u16) -> Option<u16> {
1631    match new_of.get(usize::from(old)).copied()? {
1632        MISSING_GLYPH_REMAP => None,
1633        gid => Some(gid),
1634    }
1635}
1636
1637fn strip_simple_glyph_instructions(data: &[u8], contour_count: usize) -> Option<Vec<u8>> {
1638    let instruction_len_offset = off(10, contour_count.checked_mul(2)?)?;
1639    let instruction_len = be_u16(data, instruction_len_offset)? as usize;
1640    let instruction_start = off(instruction_len_offset, 2)?;
1641    let instruction_end = off(instruction_start, instruction_len)?;
1642    if instruction_end > data.len() {
1643        return None;
1644    }
1645
1646    let mut out = Vec::with_capacity(data.len().saturating_sub(instruction_len));
1647    out.extend_from_slice(data.get(..instruction_len_offset)?);
1648    out.extend_from_slice(&0u16.to_be_bytes());
1649    out.extend_from_slice(data.get(instruction_end..)?);
1650    Some(out)
1651}
1652
1653fn parse_cmap4_cache(d: &[u8], base: usize) -> Option<Cmap4Cache> {
1654    let seg_x2 = be_u16(d, off(base, 6)?)? as usize;
1655    let seg_count = seg_x2 / 2;
1656    let end_codes = off(base, 14)?;
1657    let start_codes = off(off(end_codes, seg_x2)?, 2)?;
1658    let id_deltas = off(start_codes, seg_x2)?;
1659    let id_range_offsets = off(id_deltas, seg_x2)?;
1660
1661    let mut segments = Vec::with_capacity(seg_count);
1662    let mut sorted_by_end = true;
1663    let mut prev_end: Option<u16> = None;
1664    for i in 0..seg_count {
1665        let end = be_u16(d, off_mul(end_codes, i, 2)?)?;
1666        let start = be_u16(d, off_mul(start_codes, i, 2)?)?;
1667        let id_delta = be_u16(d, off_mul(id_deltas, i, 2)?)?;
1668        let id_range_offset_pos = off_mul(id_range_offsets, i, 2)?;
1669        let id_range_offset = be_u16(d, id_range_offset_pos)?;
1670        if prev_end.is_some_and(|prev| end < prev) {
1671            sorted_by_end = false;
1672        }
1673        prev_end = Some(end);
1674        segments.push(Cmap4Segment {
1675            start,
1676            end,
1677            id_delta,
1678            id_range_offset,
1679            id_range_offset_pos,
1680        });
1681    }
1682
1683    Some(Cmap4Cache {
1684        segments,
1685        sorted_by_end,
1686    })
1687}
1688
1689/// Choose the best Unicode `cmap` subtable, returning its absolute offset and
1690/// format. Prefers a full-repertoire format-12 `(3,10)`/`(0,*)` table, then a
1691/// BMP format-4 `(3,1)`/`(0,*)` table.
1692fn select_cmap(d: &[u8], cmap: usize) -> Option<(usize, u16)> {
1693    let num = be_u16(d, off(cmap, 2)?)? as usize;
1694    let mut best: Option<(usize, u16, u8)> = None; // (offset, format, rank)
1695    for i in 0..num {
1696        let rec = off_mul(off(cmap, 4)?, i, 8)?;
1697        let platform = be_u16(d, rec)?;
1698        let encoding = be_u16(d, off(rec, 2)?)?;
1699        let sub = off(cmap, be_u32(d, off(rec, 4)?)? as usize)?;
1700        let format = be_u16(d, sub)?;
1701        let unicode = matches!((platform, encoding), (0, _) | (3, 1) | (3, 10));
1702        if !unicode {
1703            continue;
1704        }
1705        let rank = match format {
1706            12 => 3,
1707            4 => {
1708                if (platform, encoding) == (3, 1) || platform == 0 {
1709                    2
1710                } else {
1711                    1
1712                }
1713            }
1714            _ => continue,
1715        };
1716        if best.is_none_or(|(_, _, r)| rank > r) {
1717            best = Some((sub, format, rank));
1718        }
1719    }
1720    best.map(|(off, fmt, _)| (off, fmt))
1721}
1722
1723// ===========================================================================
1724// OpenType GPOS pair-kerning parser (clean-room). Corrected version.
1725//
1726// Reuses existing module helpers be_u16/be_i16/be_u32/find_table_full.
1727// No unsafe, no unwrap/expect/panic; every read AND every allocation is
1728// bounds-checked against the font data.
1729// ===========================================================================
1730
1731/// Hasher for packed `(left << 16) | right` kern-pair keys: a single
1732/// multiply-rotate mix, far cheaper than the default SipHash for the
1733/// O(pairs) `pair()` lookups in layout and TJ generation. Deterministic.
1734#[derive(Default)]
1735struct PairKeyHasher(u64);
1736
1737impl std::hash::Hasher for PairKeyHasher {
1738    fn write_u32(&mut self, v: u32) {
1739        self.0 = u64::from(v).wrapping_mul(0x9E37_79B9_7F4A_7C15);
1740    }
1741
1742    fn write(&mut self, _bytes: &[u8]) {
1743        // Keys are always written via `write_u32`.
1744    }
1745
1746    fn finish(&self) -> u64 {
1747        let mut x = self.0;
1748        x ^= x >> 29;
1749        x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
1750        x ^= x >> 32;
1751        x
1752    }
1753}
1754
1755type PairMap = std::collections::HashMap<u32, i16, std::hash::BuildHasherDefault<PairKeyHasher>>;
1756
1757/// Pack a kern pair into the u32 key `PairMap` is keyed on.
1758fn pair_key(left: u16, right: u16) -> u32 {
1759    (u32::from(left) << 16) | u32::from(right)
1760}
1761
1762/// A class-definition table (`ClassDef`), used by Pair Adjustment format 2.
1763#[derive(Clone, Debug)]
1764enum ClassDef {
1765    /// `startGlyphID` + dense `classValueArray`.
1766    Format1 { start: u16, classes: Vec<u16> },
1767    /// `(startGlyphID, endGlyphID, class)` ranges. `dense` marks ranges that
1768    /// are sorted by start and pairwise non-overlapping (the spec-mandated
1769    /// shape), which lets `class()` binary-search instead of linearly scanning.
1770    Format2 {
1771        ranges: Vec<(u16, u16, u16)>,
1772        dense: bool,
1773    },
1774}
1775
1776impl ClassDef {
1777    /// Class of `g`; glyphs not covered by any entry are class 0.
1778    fn class(&self, g: u16) -> u16 {
1779        match self {
1780            ClassDef::Format1 { start, classes } => {
1781                if g >= *start {
1782                    let i = (g - *start) as usize;
1783                    if i < classes.len() {
1784                        return classes[i];
1785                    }
1786                }
1787                0
1788            }
1789            ClassDef::Format2 { ranges, dense } => {
1790                if *dense {
1791                    // Last range whose start is <= g; the range covers g iff
1792                    // its end reaches g (ranges are sorted + non-overlapping).
1793                    let idx = ranges.partition_point(|&(s, _, _)| s <= g);
1794                    if idx > 0 {
1795                        let (_, e, c) = ranges[idx - 1];
1796                        if g <= e {
1797                            return c;
1798                        }
1799                    }
1800                    0
1801                } else {
1802                    for &(s, e, c) in ranges {
1803                        if g >= s && g <= e {
1804                            return c;
1805                        }
1806                    }
1807                    0
1808                }
1809            }
1810        }
1811    }
1812}
1813
1814/// One parsed Pair Adjustment subtable (`lookupType` 2), reduced to the
1815/// `xAdvance` of `valueRecord1` (the only field we apply).
1816#[derive(Clone, Debug)]
1817enum KernSubtable {
1818    /// Specific-pair kerning: packed `(left << 16) | right` -> `xAdvance`.
1819    Format1 { pairs: PairMap },
1820    /// Class-based kerning.
1821    Format2 {
1822        /// First-glyph coverage, sorted ascending for `binary_search`.
1823        coverage: Vec<u16>,
1824        class1: ClassDef,
1825        class2: ClassDef,
1826        /// Declared matrix dimensions; needed to reject out-of-range class
1827        /// values that would otherwise index a wrong matrix cell.
1828        class1_count: u16,
1829        class2_count: u16,
1830        /// Row-major `xAdvance` matrix: `matrix[c1 * class2_count + c2]`.
1831        /// Empty iff both value formats are empty (all adjustments are 0).
1832        matrix: Vec<i16>,
1833    },
1834}
1835
1836impl KernSubtable {
1837    /// Returns `Some(xAdvance)` if this subtable defines `(left, right)`.
1838    ///
1839    /// For format 2 a `Some(0)` is returned when `left` is covered but the
1840    /// resolved record is zero or the classes fall outside the declared
1841    /// dimensions — that still counts as a defined (first) match.
1842    fn lookup(&self, left: u16, right: u16) -> Option<i16> {
1843        match self {
1844            KernSubtable::Format1 { pairs } => pairs.get(&pair_key(left, right)).copied(),
1845            KernSubtable::Format2 {
1846                coverage,
1847                class1,
1848                class2,
1849                class1_count,
1850                class2_count,
1851                matrix,
1852            } => {
1853                // Format 2 only applies when `left` is in coverage.
1854                if coverage.binary_search(&left).is_err() {
1855                    return None;
1856                }
1857                let c1 = class1.class(left) as usize;
1858                let c2 = class2.class(right) as usize;
1859                let c1_count = *class1_count as usize;
1860                let c2_count = *class2_count as usize;
1861                // Out-of-range class values must NOT wrap into another row.
1862                if c1 >= c1_count || c2 >= c2_count {
1863                    return Some(0);
1864                }
1865                // Zero-length value records => every adjustment is 0.
1866                if matrix.is_empty() {
1867                    return Some(0);
1868                }
1869                let idx = c1.checked_mul(c2_count)?.checked_add(c2)?;
1870                // idx is guaranteed < matrix.len() given the bounds above, but
1871                // fall back to 0 defensively rather than ever returning None.
1872                Some(matrix.get(idx).copied().unwrap_or(0))
1873            }
1874        }
1875    }
1876}
1877
1878/// Parsed GPOS `kern`-feature pair positioning for a font.
1879///
1880/// Built once via [`Font::gpos_kerning`]; [`Kerning::pair`] is a cheap,
1881/// allocation-free lookup. An empty `Kerning` (no GPOS / no kern feature /
1882/// malformed) makes every `pair()` return 0.
1883#[derive(Clone, Debug, Default)]
1884pub struct Kerning {
1885    subtables: Vec<KernSubtable>,
1886}
1887
1888impl Kerning {
1889    /// x-advance adjustment (font design units) applied between `left` and
1890    /// `right` glyph ids; 0 if no kern pair applies. First matching subtable
1891    /// wins.
1892    #[must_use]
1893    pub fn pair(&self, left: u16, right: u16) -> i16 {
1894        for st in &self.subtables {
1895            if let Some(v) = st.lookup(left, right) {
1896                return v;
1897            }
1898        }
1899        0
1900    }
1901
1902    /// Enumerates the ASCII byte pairs this kerning adjusts, replacing the
1903    /// 16,384-pair brute force over [`Kerning::pair`].
1904    ///
1905    /// For every byte pair `(l, r)` with `pair(glyph_of(l), glyph_of(r)) != 0`
1906    /// — where `glyph_of` maps the 128 ASCII bytes to glyph ids, possibly
1907    /// several bytes to one glyph — calls `emit(l, r, v)` with `v` exactly
1908    /// equal to that `pair` result (first matching subtable wins, defined
1909    /// zeros shadow later subtables just like `pair`). Pairs resolving to 0
1910    /// are never emitted: a zero-initialized matrix already holds their value,
1911    /// so `emit` fires once per nonzero cell. The order of `emit` calls across
1912    /// distinct pairs is unspecified (format-1 pair maps hash-scatter), so
1913    /// `emit` must be order-independent.
1914    ///
1915    /// Cost is proportional to what the tables actually contain — format-1
1916    /// pair-map entries and format-2 coverage glyphs — instead of the full
1917    /// 128x128 cross product.
1918    pub fn for_each_ascii_pair(
1919        &self,
1920        glyph_of: impl Fn(u8) -> u16,
1921        mut emit: impl FnMut(u8, u8, i16),
1922    ) {
1923        let glyphs: [u16; 128] = std::array::from_fn(|b| glyph_of(b as u8));
1924        // Byte index sorted by glyph id so a subtable pair locates its bytes
1925        // with two binary searches; duplicate gids stay as duplicate entries.
1926        let mut by_glyph: Vec<(u16, u8)> = glyphs
1927            .iter()
1928            .enumerate()
1929            .map(|(b, &g)| (g, b as u8))
1930            .collect();
1931        by_glyph.sort_unstable();
1932
1933        // A defined zero must still shadow later subtables, so track which
1934        // cells any subtable has defined — not which are nonzero.
1935        let mut defined = [[false; 128]; 128];
1936
1937        for st in &self.subtables {
1938            match st {
1939                KernSubtable::Format1 { pairs } => {
1940                    for (&key, &v) in pairs {
1941                        let left = (key >> 16) as u16;
1942                        let right = (key & 0xFFFF) as u16;
1943                        let ls = by_glyph.partition_point(|&(g, _)| g < left);
1944                        let le = by_glyph.partition_point(|&(g, _)| g <= left);
1945                        let rs = by_glyph.partition_point(|&(g, _)| g < right);
1946                        let re = by_glyph.partition_point(|&(g, _)| g <= right);
1947                        for &(_, l) in &by_glyph[ls..le] {
1948                            for &(_, r) in &by_glyph[rs..re] {
1949                                let row = &mut defined[usize::from(l)];
1950                                if row[usize::from(r)] {
1951                                    continue;
1952                                }
1953                                row[usize::from(r)] = true;
1954                                if v != 0 {
1955                                    emit(l, r, v);
1956                                }
1957                            }
1958                        }
1959                    }
1960                }
1961                KernSubtable::Format2 {
1962                    coverage,
1963                    class1,
1964                    class2,
1965                    class1_count,
1966                    class2_count,
1967                    matrix,
1968                } => {
1969                    // A covered first glyph defines an adjustment for every
1970                    // second glyph, so walk the 128x128 cells but hoist the
1971                    // coverage search and both class lookups out of the walk.
1972                    let c2_of: [u16; 128] = std::array::from_fn(|b| class2.class(glyphs[b]));
1973                    let c1_count = usize::from(*class1_count);
1974                    let c2_count = usize::from(*class2_count);
1975                    for l in 0..128u8 {
1976                        if coverage.binary_search(&glyphs[usize::from(l)]).is_err() {
1977                            continue;
1978                        }
1979                        let c1 = usize::from(class1.class(glyphs[usize::from(l)]));
1980                        for r in 0..128u8 {
1981                            let row = &mut defined[usize::from(l)];
1982                            if row[usize::from(r)] {
1983                                continue;
1984                            }
1985                            // Mirror `lookup` exactly: out-of-range classes
1986                            // and empty value records are a defined zero; an
1987                            // index that cannot be computed leaves the pair
1988                            // undefined for this subtable.
1989                            let value = if c1 >= c1_count
1990                                || usize::from(c2_of[usize::from(r)]) >= c2_count
1991                                || matrix.is_empty()
1992                            {
1993                                Some(0)
1994                            } else {
1995                                c1.checked_mul(c2_count)
1996                                    .and_then(|m| m.checked_add(usize::from(c2_of[usize::from(r)])))
1997                                    .map(|idx| matrix.get(idx).copied().unwrap_or(0))
1998                            };
1999                            if let Some(v) = value {
2000                                row[usize::from(r)] = true;
2001                                if v != 0 {
2002                                    emit(l, r, v);
2003                                }
2004                            }
2005                        }
2006                    }
2007                }
2008            }
2009        }
2010    }
2011}
2012
2013/// ValueRecord byte size = popcount(valueFormat) * 2.
2014fn value_record_size(value_format: u16) -> usize {
2015    value_format.count_ones() as usize * 2
2016}
2017
2018/// Reads the `xAdvance` (0x0004) i16 of a ValueRecord starting at `off`.
2019///
2020/// Returns `Some(0)` when X_ADVANCE is not present, `None` only when the bytes
2021/// are missing. The field offset within the record is `2 * popcount(vf & 0x0003)`
2022/// (skip X/Y placement if set).
2023fn value_record_x_advance(d: &[u8], off: usize, value_format: u16) -> Option<i16> {
2024    const X_ADVANCE: u16 = 0x0004;
2025    if value_format & X_ADVANCE == 0 {
2026        return Some(0);
2027    }
2028    let skip = (value_format & 0x0003).count_ones() as usize * 2;
2029    be_i16(d, off.checked_add(skip)?)
2030}
2031
2032/// Parses a Coverage table at `cov`, returning glyph ids ordered by coverage
2033/// index (index `i` -> returned vec position `i`).
2034fn parse_coverage_glyphs(d: &[u8], cov: usize) -> Option<Vec<u16>> {
2035    let format = be_u16(d, cov)?;
2036    match format {
2037        1 => {
2038            let count = be_u16_at(d, cov, 2)? as usize;
2039            let mut v = Vec::with_capacity(count.min(d.len() / 2 + 1));
2040            for i in 0..count {
2041                v.push(be_u16(d, off_mul(off(cov, 4)?, i, 2)?)?);
2042            }
2043            Some(v)
2044        }
2045        2 => {
2046            let range_count = be_u16_at(d, cov, 2)? as usize;
2047            // Key by coverage index so the result is correctly ordered even if
2048            // ranges are listed out of order.
2049            let mut by_index: std::collections::BTreeMap<u32, u16> =
2050                std::collections::BTreeMap::new();
2051            // A well-formed Coverage cannot enumerate more glyphs than exist in a
2052            // font (<= 65536). Each 6-byte RangeRecord can otherwise claim up to
2053            // 65536 ids, so without a cap a small malicious table drives billions
2054            // of iterations (a CPU-hang DoS on an untrusted host font). Cap the
2055            // total span and bail before expanding an over-claiming table.
2056            let mut total: usize = 0;
2057            for i in 0..range_count {
2058                let rec = off_mul(off(cov, 4)?, i, 6)?;
2059                let start = be_u16(d, rec)? as u32;
2060                let end = be_u16_at(d, rec, 2)? as u32;
2061                let start_idx = be_u16_at(d, rec, 4)? as u32;
2062                if end < start {
2063                    continue;
2064                }
2065                total = total.checked_add((end - start + 1) as usize)?;
2066                if total > MAX_COVERAGE_GLYPHS {
2067                    return None;
2068                }
2069                let mut g = start;
2070                let mut idx = start_idx;
2071                while g <= end {
2072                    by_index.insert(idx, g as u16);
2073                    g += 1;
2074                    idx += 1;
2075                }
2076            }
2077            Some(by_index.into_values().collect())
2078        }
2079        _ => None,
2080    }
2081}
2082
2083/// Parses a ClassDef table at `cd`.
2084fn parse_class_def(d: &[u8], cd: usize) -> Option<ClassDef> {
2085    let format = be_u16(d, cd)?;
2086    match format {
2087        1 => {
2088            let start = be_u16_at(d, cd, 2)?;
2089            let count = be_u16_at(d, cd, 4)? as usize;
2090            let mut classes = Vec::with_capacity(count.min(d.len() / 2 + 1));
2091            for i in 0..count {
2092                classes.push(be_u16(d, off_mul(off(cd, 6)?, i, 2)?)?);
2093            }
2094            Some(ClassDef::Format1 { start, classes })
2095        }
2096        2 => {
2097            let range_count = be_u16_at(d, cd, 2)? as usize;
2098            let mut ranges = Vec::with_capacity(range_count.min(d.len() / 6 + 1));
2099            for i in 0..range_count {
2100                let rec = off_mul(off(cd, 4)?, i, 6)?;
2101                let s = be_u16(d, rec)?;
2102                let e = be_u16_at(d, rec, 2)?;
2103                let c = be_u16_at(d, rec, 4)?;
2104                ranges.push((s, e, c));
2105            }
2106            // The spec orders ranges by ascending startGlyphID without
2107            // overlap; detect that shape so `class()` can binary-search.
2108            // Sorting keeps a merely unsorted (still well-formed) table on
2109            // the fast path; overlapping ranges leave `dense` off and
2110            // preserve the original first-match-wins linear scan.
2111            ranges.sort_by_key(|&(s, _, _)| s);
2112            let dense = ranges
2113                .windows(2)
2114                .all(|w| w[0].1 < w[1].0 || (w[0].1 == w[1].0 && w[0].2 == w[1].2));
2115            Some(ClassDef::Format2 { ranges, dense })
2116        }
2117        _ => None,
2118    }
2119}
2120
2121/// Parses a Pair Adjustment subtable (`lookupType` 2) whose start is `sub`.
2122fn parse_pair_subtable(d: &[u8], sub: usize) -> Option<KernSubtable> {
2123    let pos_format = be_u16(d, sub)?;
2124    match pos_format {
2125        1 => parse_pair_format1(d, sub),
2126        2 => parse_pair_format2(d, sub),
2127        _ => None,
2128    }
2129}
2130
2131/// Pair Adjustment format 1 (specific pairs).
2132fn parse_pair_format1(d: &[u8], sub: usize) -> Option<KernSubtable> {
2133    let cov_off = be_u16_at(d, sub, 2)? as usize;
2134    let vf1 = be_u16_at(d, sub, 4)?;
2135    let vf2 = be_u16_at(d, sub, 6)?;
2136    let pair_set_count = be_u16_at(d, sub, 8)? as usize;
2137
2138    let rec1_size = value_record_size(vf1);
2139    let rec2_size = value_record_size(vf2);
2140    // Each PairValueRecord: secondGlyph(2) + valueRecord1 + valueRecord2.
2141    let pair_rec_size = off(2, off(rec1_size, rec2_size)?)?;
2142
2143    let coverage = parse_coverage_glyphs(d, off(sub, cov_off)?)?;
2144
2145    let mut pairs: PairMap = std::collections::HashMap::default();
2146
2147    // Bound total work: PairSet offsets may all alias one target, so a font of
2148    // O(pair_set_count + pair_value_count) bytes can otherwise drive their product
2149    // in iterations — a CPU-hang DoS on an untrusted host font.
2150    let mut work: usize = 0;
2151    for i in 0..pair_set_count {
2152        work += 1;
2153        if work > MAX_LAYOUT_GLYPHS {
2154            break;
2155        }
2156        // PairSet for coverage-index i is for coverage glyph at position i.
2157        let Some(left_glyph) = coverage.get(i).copied() else {
2158            continue;
2159        };
2160        let Some(ps_off) = off_mul(off(sub, 10)?, i, 2).and_then(|slot| be_u16(d, slot)) else {
2161            continue;
2162        };
2163        let Some(ps) = off(sub, ps_off as usize) else {
2164            continue;
2165        };
2166        let Some(pair_value_count) = be_u16(d, ps) else {
2167            continue;
2168        };
2169        let Some(mut p) = off(ps, 2) else {
2170            continue;
2171        };
2172        for _ in 0..pair_value_count {
2173            work += 1;
2174            if work > MAX_LAYOUT_GLYPHS {
2175                break;
2176            }
2177            let Some(second) = be_u16(d, p) else {
2178                break;
2179            };
2180            let x_adv = off(p, 2)
2181                .and_then(|value_off| value_record_x_advance(d, value_off, vf1))
2182                .unwrap_or(0);
2183            // First subtable / first record wins for a given pair.
2184            pairs.entry(pair_key(left_glyph, second)).or_insert(x_adv);
2185            let Some(np) = p.checked_add(pair_rec_size) else {
2186                break;
2187            };
2188            p = np;
2189        }
2190    }
2191
2192    Some(KernSubtable::Format1 { pairs })
2193}
2194
2195/// Pair Adjustment format 2 (class-based).
2196fn parse_pair_format2(d: &[u8], sub: usize) -> Option<KernSubtable> {
2197    let cov_off = be_u16_at(d, sub, 2)? as usize;
2198    let vf1 = be_u16_at(d, sub, 4)?;
2199    let vf2 = be_u16_at(d, sub, 6)?;
2200    let class_def1_off = be_u16_at(d, sub, 8)? as usize;
2201    let class_def2_off = be_u16_at(d, sub, 10)? as usize;
2202    let class1_count = be_u16_at(d, sub, 12)? as usize;
2203    let class2_count = be_u16_at(d, sub, 14)? as usize;
2204
2205    let rec1_size = value_record_size(vf1);
2206    let rec2_size = value_record_size(vf2);
2207    let class_rec_size = off(rec1_size, rec2_size)?;
2208
2209    // Class1Record[]: each holds class2_count Class2Records (record[c1][c2]).
2210    let matrix_base = off(sub, 16)?;
2211    let cell_count = class1_count.checked_mul(class2_count)?;
2212
2213    // Never allocate/iterate based on untrusted class counts unless the
2214    // declared matrix actually fits within the font data.
2215    let matrix: Vec<i16> = if class_rec_size == 0 {
2216        // Both value formats empty => every xAdvance is 0; store nothing.
2217        Vec::new()
2218    } else {
2219        let needed = cell_count.checked_mul(class_rec_size)?;
2220        let end = matrix_base.checked_add(needed)?;
2221        if end > d.len() {
2222            // Matrix cannot fit -> malformed; drop this subtable.
2223            return None;
2224        }
2225        let mut m = Vec::with_capacity(cell_count);
2226        for idx in 0..cell_count {
2227            let cell = off_mul(matrix_base, idx, class_rec_size)?;
2228            // In-bounds by the check above; reads only xAdvance of record1.
2229            let x_adv = value_record_x_advance(d, cell, vf1).unwrap_or(0);
2230            m.push(x_adv);
2231        }
2232        m
2233    };
2234
2235    let mut coverage = parse_coverage_glyphs(d, off(sub, cov_off)?)?;
2236    coverage.sort_unstable();
2237
2238    let class1 = parse_class_def(d, off(sub, class_def1_off)?)?;
2239    let class2 = parse_class_def(d, off(sub, class_def2_off)?)?;
2240
2241    Some(KernSubtable::Format2 {
2242        coverage,
2243        class1,
2244        class2,
2245        class1_count: class1_count as u16,
2246        class2_count: class2_count as u16,
2247        matrix,
2248    })
2249}
2250
2251/// Resolves an Extension Positioning subtable (`lookupType` 9), returning
2252/// `(extensionLookupType, realSubtableOffset)`.
2253fn resolve_extension(d: &[u8], sub: usize) -> Option<(u16, usize)> {
2254    let pos_format = be_u16(d, sub)?;
2255    if pos_format != 1 {
2256        return None;
2257    }
2258    let ext_type = be_u16_at(d, sub, 2)?;
2259    let ext_off = be_u32_at(d, sub, 4)? as usize;
2260    Some((ext_type, sub.checked_add(ext_off)?))
2261}
2262
2263impl Font {
2264    /// Parses the GPOS `kern` feature once into a [`Kerning`] structure.
2265    ///
2266    /// Returns an empty `Kerning` (every `pair()` -> 0) when the font has no
2267    /// GPOS table, no `kern` feature, or the relevant offsets are malformed.
2268    #[must_use]
2269    pub fn gpos_kerning(&self) -> Kerning {
2270        self.parse_gpos_kerning().unwrap_or_default()
2271    }
2272
2273    fn parse_gpos_kerning(&self) -> Option<Kerning> {
2274        let d = &self.data;
2275        let (gpos, _gpos_len) = find_table_full(d, b"GPOS")?;
2276
2277        // GPOS header: major(0) minor(2) scriptList(4) featureList(6) lookupList(8).
2278        let feature_list_off = be_u16_at(d, gpos, 6)? as usize;
2279        let lookup_list_off = be_u16_at(d, gpos, 8)? as usize;
2280        let feature_list = off(gpos, feature_list_off)?;
2281        let lookup_list = off(gpos, lookup_list_off)?;
2282
2283        // --- Collect every 'kern' feature's lookup indices (deduplicated). ---
2284        let feature_count = be_u16(d, feature_list)? as usize;
2285        let mut lookup_indices: Vec<u16> = Vec::new();
2286        for i in 0..feature_count {
2287            // FeatureRecord: tag[4] + featureOffset(2), from FeatureList.
2288            let rec = off_mul(off(feature_list, 2)?, i, 6)?;
2289            let Some(tag) = bytes_at(d, rec, 4) else {
2290                break;
2291            };
2292            if tag != b"kern" {
2293                continue;
2294            }
2295            let Some(feat_off) = be_u16_at(d, rec, 4) else {
2296                continue;
2297            };
2298            let Some(feat) = off(feature_list, feat_off as usize) else {
2299                continue;
2300            };
2301            // Feature: featureParams(0) lookupIndexCount(2) lookupIndices(4..).
2302            let Some(lookup_index_count) = be_u16_at(d, feat, 2) else {
2303                continue;
2304            };
2305            for j in 0..lookup_index_count as usize {
2306                if let Some(idx) = off_mul(off(feat, 4)?, j, 2).and_then(|slot| be_u16(d, slot)) {
2307                    if !lookup_indices.contains(&idx) {
2308                        lookup_indices.push(idx);
2309                    }
2310                }
2311            }
2312        }
2313
2314        // --- Walk the gathered lookups, collecting pair subtables. ---
2315        let lookup_count = be_u16(d, lookup_list)? as usize;
2316        let mut subtables: Vec<KernSubtable> = Vec::new();
2317
2318        for &li in &lookup_indices {
2319            let li = li as usize;
2320            if li >= lookup_count {
2321                continue;
2322            }
2323            let Some(lookup_off) =
2324                off_mul(off(lookup_list, 2)?, li, 2).and_then(|slot| be_u16(d, slot))
2325            else {
2326                continue;
2327            };
2328            let Some(lookup) = off(lookup_list, lookup_off as usize) else {
2329                continue;
2330            };
2331            // Lookup: lookupType(0) lookupFlag(2) subTableCount(4) offsets(6..).
2332            let Some(lookup_type) = be_u16(d, lookup) else {
2333                continue;
2334            };
2335            let Some(sub_count) = be_u16_at(d, lookup, 4) else {
2336                continue;
2337            };
2338
2339            for s in 0..sub_count as usize {
2340                let Some(sub_off) = off_mul(off(lookup, 6)?, s, 2).and_then(|slot| be_u16(d, slot))
2341                else {
2342                    continue;
2343                };
2344                let Some(sub) = off(lookup, sub_off as usize) else {
2345                    continue;
2346                };
2347
2348                match lookup_type {
2349                    2 => {
2350                        if let Some(st) = parse_pair_subtable(d, sub) {
2351                            subtables.push(st);
2352                        }
2353                    }
2354                    9 => {
2355                        // Extension: resolve, then handle a real type-2 subtable.
2356                        if let Some((ext_type, real_sub)) = resolve_extension(d, sub) {
2357                            if ext_type == 2 {
2358                                if let Some(st) = parse_pair_subtable(d, real_sub) {
2359                                    subtables.push(st);
2360                                }
2361                            }
2362                        }
2363                    }
2364                    _ => {}
2365                }
2366            }
2367        }
2368
2369        Some(Kerning { subtables })
2370    }
2371}
2372
2373// ===========================================================================
2374// GSUB ligature substitution (vxi.3). Reuses parse_coverage_glyphs +
2375// resolve_extension + be_u16 + find_table_full. No unsafe/unwrap/panic.
2376// ===========================================================================
2377
2378/// One ligature rule: a first glyph (the map key) followed by `components`
2379/// (the remaining component glyph ids) substitutes to `ligature`.
2380#[derive(Clone, Debug)]
2381struct LigRule {
2382    components: Vec<u16>,
2383    ligature: u16,
2384}
2385
2386/// Parsed GSUB `liga` standard ligatures for a font. Built once via
2387/// [`Font::gsub_ligatures`]; [`Ligatures::substitute`] applies them.
2388#[derive(Clone, Debug, Default)]
2389pub struct Ligatures {
2390    /// first glyph id -> rules, sorted longest-component-run first.
2391    rules: std::collections::BTreeMap<u16, Vec<LigRule>>,
2392}
2393
2394impl Ligatures {
2395    /// True when the font defines no standard ligatures.
2396    #[must_use]
2397    pub fn is_empty(&self) -> bool {
2398        self.rules.is_empty()
2399    }
2400
2401    /// Glyph ids that begin some ligature rule (the keys of the rule map).
2402    /// Callers shaping ASCII text use this to prove `substitute` is an identity
2403    /// for a given string: if no glyph in the string starts a rule, the
2404    /// substitution is a no-op.
2405    pub fn rule_start_glyphs(&self) -> impl Iterator<Item = &u16> {
2406        self.rules.keys()
2407    }
2408
2409    /// Total glyph length (first glyph + components) of the longest ligature
2410    /// rule. Incremental shapers use it as the settle window: an input suffix
2411    /// shorter than this cannot complete a rule that starts before it, so
2412    /// decisions before the window are final.
2413    #[must_use]
2414    pub fn max_rule_len(&self) -> usize {
2415        self.rules
2416            .values()
2417            .flat_map(|rules| rules.iter().map(|r| r.components.len() + 1))
2418            .max()
2419            .unwrap_or(1)
2420    }
2421
2422    /// Apply ligature substitution to a glyph-id sequence (greedy longest match),
2423    /// returning the shaped sequence (which may contain ligature glyph ids that
2424    /// no single character maps to).
2425    #[must_use]
2426    pub fn substitute(&self, gids: &[u16]) -> Vec<u16> {
2427        self.substitute_with_spans(gids)
2428            .into_iter()
2429            .map(|(g, _)| g)
2430            .collect()
2431    }
2432
2433    /// Like [`Ligatures::substitute`] but pairs each output glyph with the number
2434    /// of input glyphs it consumed (1 for a pass-through, N for an N-component
2435    /// ligature) — so callers can map a ligature back to its source characters
2436    /// (e.g. to build a `ToUnicode` entry).
2437    #[must_use]
2438    pub fn substitute_with_spans(&self, gids: &[u16]) -> Vec<(u16, usize)> {
2439        let mut out = Vec::with_capacity(gids.len());
2440        self.substitute_with_spans_into(gids, &mut out);
2441        out
2442    }
2443
2444    /// Into-scratch [`Ligatures::substitute_with_spans`]: identical decisions
2445    /// and output, but appends into a caller-owned buffer (cleared first) so
2446    /// repeat shapers reuse one allocation across runs instead of returning a
2447    /// fresh `Vec` per call.
2448    pub fn substitute_with_spans_into(&self, gids: &[u16], out: &mut Vec<(u16, usize)>) {
2449        out.clear();
2450        out.reserve(gids.len());
2451        let mut i = 0;
2452        while i < gids.len() {
2453            let mut applied = false;
2454            if let Some(rules) = self.rules.get(&gids[i]) {
2455                for r in rules {
2456                    let n = r.components.len();
2457                    if i + 1 + n <= gids.len() && gids[i + 1..i + 1 + n] == r.components[..] {
2458                        out.push((r.ligature, n + 1));
2459                        i += n + 1;
2460                        applied = true;
2461                        break;
2462                    }
2463                }
2464            }
2465            if !applied {
2466                out.push((gids[i], 1));
2467                i += 1;
2468            }
2469        }
2470    }
2471}
2472
2473impl Font {
2474    /// Parse the GSUB `liga` standard-ligature substitutions once.
2475    ///
2476    /// Returns empty [`Ligatures`] when the font has no GSUB / no `liga` feature
2477    /// or the relevant offsets are malformed.
2478    #[must_use]
2479    pub fn gsub_ligatures(&self) -> Ligatures {
2480        self.parse_gsub_ligatures().unwrap_or_default()
2481    }
2482
2483    fn parse_gsub_ligatures(&self) -> Option<Ligatures> {
2484        let d = &self.data;
2485        let (gsub, _) = find_table_full(d, b"GSUB")?;
2486        let feature_list = off(gsub, be_u16_at(d, gsub, 6)? as usize)?;
2487        let lookup_list = off(gsub, be_u16_at(d, gsub, 8)? as usize)?;
2488
2489        // Collect every 'liga' feature's lookup indices.
2490        let feature_count = be_u16(d, feature_list)? as usize;
2491        let mut lookup_indices: Vec<u16> = Vec::new();
2492        for i in 0..feature_count {
2493            let rec = off_mul(off(feature_list, 2)?, i, 6)?;
2494            let Some(tag) = bytes_at(d, rec, 4) else {
2495                break;
2496            };
2497            if tag != b"liga" {
2498                continue;
2499            }
2500            let Some(feat_off) = be_u16_at(d, rec, 4) else {
2501                continue;
2502            };
2503            let Some(feat) = off(feature_list, feat_off as usize) else {
2504                continue;
2505            };
2506            let Some(n) = be_u16_at(d, feat, 2) else {
2507                continue;
2508            };
2509            for j in 0..n as usize {
2510                if let Some(idx) = off_mul(off(feat, 4)?, j, 2).and_then(|slot| be_u16(d, slot)) {
2511                    if !lookup_indices.contains(&idx) {
2512                        lookup_indices.push(idx);
2513                    }
2514                }
2515            }
2516        }
2517
2518        let lookup_count = be_u16(d, lookup_list)? as usize;
2519        let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
2520            std::collections::BTreeMap::new();
2521        for &li in &lookup_indices {
2522            let li = li as usize;
2523            if li >= lookup_count {
2524                continue;
2525            }
2526            let Some(lookup_off) =
2527                off_mul(off(lookup_list, 2)?, li, 2).and_then(|slot| be_u16(d, slot))
2528            else {
2529                continue;
2530            };
2531            let Some(lookup) = off(lookup_list, lookup_off as usize) else {
2532                continue;
2533            };
2534            let Some(lookup_type) = be_u16(d, lookup) else {
2535                continue;
2536            };
2537            let Some(sub_count) = be_u16_at(d, lookup, 4) else {
2538                continue;
2539            };
2540            for s in 0..sub_count as usize {
2541                let Some(sub_off) = off_mul(off(lookup, 6)?, s, 2).and_then(|slot| be_u16(d, slot))
2542                else {
2543                    continue;
2544                };
2545                let Some(sub) = off(lookup, sub_off as usize) else {
2546                    continue;
2547                };
2548                match lookup_type {
2549                    4 => parse_ligature_subst(d, sub, &mut rules),
2550                    // Extension Substitution -> a real type-4 subtable.
2551                    7 => {
2552                        if let Some((ext_type, real)) = resolve_extension(d, sub) {
2553                            if ext_type == 4 {
2554                                parse_ligature_subst(d, real, &mut rules);
2555                            }
2556                        }
2557                    }
2558                    _ => {}
2559                }
2560            }
2561        }
2562        // Greedy longest match: try the longest ligature first.
2563        for v in rules.values_mut() {
2564            v.sort_by_key(|r| std::cmp::Reverse(r.components.len()));
2565        }
2566        Some(Ligatures { rules })
2567    }
2568}
2569
2570/// Parse one Ligature Substitution subtable (GSUB `lookupType` 4) at `sub`.
2571fn parse_ligature_subst(
2572    d: &[u8],
2573    sub: usize,
2574    rules: &mut std::collections::BTreeMap<u16, Vec<LigRule>>,
2575) {
2576    let Some(format) = be_u16(d, sub) else {
2577        return;
2578    };
2579    if format != 1 {
2580        return;
2581    }
2582    let Some(cov_off) = be_u16_at(d, sub, 2) else {
2583        return;
2584    };
2585    let Some(set_count) = be_u16_at(d, sub, 4) else {
2586        return;
2587    };
2588    let Some(coverage) = off(sub, cov_off as usize).and_then(|cov| parse_coverage_glyphs(d, cov))
2589    else {
2590        return;
2591    };
2592    let Some(set_offsets) = off(sub, 6) else {
2593        return;
2594    };
2595    // Bound total work: LigatureSet/Ligature offsets may all alias one target, so
2596    // a font of O(set_count + lig_count) bytes can otherwise drive set_count *
2597    // lig_count iterations (and retained `LigRule`s) — an OOM-kill DoS. A valid
2598    // font has far fewer ligature entries than the glyph ceiling.
2599    let mut work: usize = 0;
2600    for i in 0..set_count as usize {
2601        work += 1;
2602        if work > MAX_LAYOUT_GLYPHS {
2603            return;
2604        }
2605        // LigatureSet i is for coverage glyph i (the ligature's first component).
2606        let Some(first) = coverage.get(i).copied() else {
2607            continue;
2608        };
2609        let Some(set_off) = off_mul(set_offsets, i, 2).and_then(|slot| be_u16(d, slot)) else {
2610            continue;
2611        };
2612        let Some(lig_set) = off(sub, set_off as usize) else {
2613            continue;
2614        };
2615        let Some(lig_count) = be_u16(d, lig_set) else {
2616            continue;
2617        };
2618        let Some(lig_offsets) = off(lig_set, 2) else {
2619            continue;
2620        };
2621        for j in 0..lig_count as usize {
2622            work += 1;
2623            if work > MAX_LAYOUT_GLYPHS {
2624                return;
2625            }
2626            let Some(lig_off) = off_mul(lig_offsets, j, 2).and_then(|slot| be_u16(d, slot)) else {
2627                continue;
2628            };
2629            let Some(lig) = off(lig_set, lig_off as usize) else {
2630                continue;
2631            };
2632            let Some(lig_glyph) = be_u16(d, lig) else {
2633                continue;
2634            };
2635            let Some(comp_count) = be_u16_at(d, lig, 2) else {
2636                continue;
2637            };
2638            if comp_count == 0 {
2639                continue;
2640            }
2641            // componentGlyphIDs holds comp_count-1 entries (the first is `first`).
2642            let mut components = Vec::with_capacity(comp_count as usize - 1);
2643            let mut ok = true;
2644            let Some(component_base) = off(lig, 4) else {
2645                continue;
2646            };
2647            for k in 0..(comp_count as usize - 1) {
2648                match off_mul(component_base, k, 2).and_then(|slot| be_u16(d, slot)) {
2649                    Some(g) => components.push(g),
2650                    None => {
2651                        ok = false;
2652                        break;
2653                    }
2654                }
2655            }
2656            if ok {
2657                rules.entry(first).or_default().push(LigRule {
2658                    components,
2659                    ligature: lig_glyph,
2660                });
2661            }
2662        }
2663    }
2664}
2665
2666#[cfg(test)]
2667#[cfg_attr(coverage_nightly, coverage(off))]
2668#[allow(clippy::indexing_slicing, clippy::unwrap_used)]
2669mod dos_tests {
2670    use super::{MAX_COVERAGE_GLYPHS, parse_coverage_glyphs};
2671
2672    fn be(v: u16) -> [u8; 2] {
2673        v.to_be_bytes()
2674    }
2675
2676    #[test]
2677    fn coverage_format2_valid_range_expands() {
2678        // format=2, rangeCount=1, range [10..=20] at coverage index 0.
2679        let mut d = Vec::new();
2680        d.extend_from_slice(&be(2));
2681        d.extend_from_slice(&be(1));
2682        d.extend_from_slice(&be(10)); // start
2683        d.extend_from_slice(&be(20)); // end
2684        d.extend_from_slice(&be(0)); // startCoverageIndex
2685        let got = parse_coverage_glyphs(&d, 0).unwrap();
2686        assert_eq!(got, (10u16..=20).collect::<Vec<_>>());
2687    }
2688
2689    #[test]
2690    fn coverage_format2_overclaiming_table_is_rejected_not_expanded() {
2691        // Two ranges each spanning 0..=65535 => total 131072 > the glyph ceiling,
2692        // so the parser must bail (None) instead of grinding billions of inserts.
2693        let mut d = Vec::new();
2694        d.extend_from_slice(&be(2));
2695        d.extend_from_slice(&be(2)); // rangeCount = 2
2696        for _ in 0..2 {
2697            d.extend_from_slice(&be(0)); // start
2698            d.extend_from_slice(&be(0xFFFF)); // end
2699            d.extend_from_slice(&be(0)); // startCoverageIndex
2700        }
2701        assert!(parse_coverage_glyphs(&d, 0).is_none());
2702        // Sanity: the ceiling is the font-wide glyph limit.
2703        assert_eq!(MAX_COVERAGE_GLYPHS, 65_536);
2704    }
2705}
2706
2707#[cfg(test)]
2708#[cfg_attr(coverage_nightly, coverage(off))]
2709#[allow(clippy::unwrap_used, clippy::expect_used)]
2710mod subset_degradation_tests {
2711    use super::{
2712        Font, MISSING_GLYPH_REMAP, be_i16, be_u16, find_table_full, strip_simple_glyph_instructions,
2713    };
2714
2715    // The bundled faces ship in-crate under `fmd-font/fonts/`.
2716    fn cm_regular() -> Font {
2717        let bytes = std::fs::read(concat!(
2718            env!("CARGO_MANIFEST_DIR"),
2719            "/fonts/computer-modern/cmunrm.ttf"
2720        ))
2721        .expect("read bundled font");
2722        Font::parse(bytes).expect("parse bundled font")
2723    }
2724
2725    fn all_faces() -> Vec<Font> {
2726        let base = env!("CARGO_MANIFEST_DIR");
2727        [
2728            "/fonts/computer-modern/cmunrm.ttf",
2729            "/fonts/computer-modern/cmunbx.ttf",
2730            "/fonts/computer-modern/cmunti.ttf",
2731            "/fonts/computer-modern/cmunbi.ttf",
2732            "/fonts/computer-modern/cmuntt.ttf",
2733            "/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
2734            "/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf",
2735            "/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf",
2736            "/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf",
2737        ]
2738        .iter()
2739        .filter_map(|p| Font::parse(std::fs::read(format!("{base}{p}")).ok()?).ok())
2740        .collect()
2741    }
2742
2743    fn test_remap(font: &Font, pairs: &[(u16, u16)]) -> Vec<u16> {
2744        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs).max(1)];
2745        for &(old, new) in pairs {
2746            if let Some(slot) = new_of.get_mut(usize::from(old)) {
2747                *slot = new;
2748            }
2749        }
2750        new_of
2751    }
2752
2753    /// `subset_glyphs_with_lookup` must expose exactly the remap
2754    /// `subset_glyphs` reports as a `BTreeMap` (same font bytes, same
2755    /// old->new pairs, same absent-glyph semantics) for every face and
2756    /// glyph-set shape, so the PDF path can consume the dense table without
2757    /// rebuilding one.
2758    #[test]
2759    fn subset_glyphs_with_lookup_matches_btreemap_remap() {
2760        fn assert_agree(font: &Font, glyphs: &[u16], cmap_chars: &[char]) {
2761            let (map_bytes, remap) = font
2762                .subset_glyphs(glyphs, cmap_chars)
2763                .expect("map-path subset");
2764            let (dense_bytes, lookup) = font
2765                .subset_glyphs_with_lookup(glyphs, cmap_chars)
2766                .expect("dense-path subset");
2767            assert_eq!(map_bytes, dense_bytes, "font bytes must be identical");
2768            assert_eq!(
2769                lookup.len(),
2770                usize::from(font.num_glyphs).max(1),
2771                "dense lookup covers every source glyph"
2772            );
2773            let mut mapped = 0usize;
2774            for (old, new) in lookup.iter().enumerate() {
2775                if *new == MISSING_GLYPH_REMAP {
2776                    assert!(
2777                        !remap.contains_key(&(old as u16)),
2778                        "dense sentinel at {old} must be absent from the map"
2779                    );
2780                } else {
2781                    mapped += 1;
2782                    assert_eq!(
2783                        remap.get(&(old as u16)).copied(),
2784                        Some(*new),
2785                        "dense entry {old} -> {new} must match the map"
2786                    );
2787                }
2788            }
2789            assert_eq!(
2790                remap.len(),
2791                mapped,
2792                "map and dense table cover the same glyphs"
2793            );
2794            // Ascending old-gid enumeration of the dense table reproduces the
2795            // BTreeMap iteration order exactly (pdf.rs relied on that order
2796            // to scatter-build its map_lookup table).
2797            let dense_pairs: Vec<(u16, u16)> = lookup
2798                .iter()
2799                .enumerate()
2800                .filter(|&(_, &v)| v != MISSING_GLYPH_REMAP)
2801                .map(|(old, &v)| (old as u16, v))
2802                .collect();
2803            let map_pairs: Vec<(u16, u16)> = remap.iter().map(|(&k, &v)| (k, v)).collect();
2804            assert_eq!(dense_pairs, map_pairs);
2805        }
2806
2807        for font in all_faces() {
2808            let a = font.glyph_index('A');
2809            let b = font.glyph_index('B');
2810            let q = font.glyph_index('Q');
2811            // A composite glyph whose closure must pull in extra component
2812            // gids beyond the seed (accented Latin in the bundled faces).
2813            let composite =
2814                (0..font.num_glyphs).find(|&g| font.is_composite(g) && g != a && g != b && g != q);
2815            // Empty seed, empty cmap: subset is .notdef-only.
2816            assert_agree(&font, &[], &[]);
2817            // Empty seed with a cmap char that maps to .notdef: same.
2818            assert_agree(&font, &[], &['A', '\u{1D49C}']);
2819            // Explicit .notdef-only seed.
2820            assert_agree(&font, &[0], &[]);
2821            // Plain runs.
2822            assert_agree(&font, &[a, b, q], &['A', 'B', 'Q']);
2823            // Out-of-order ids, duplicates, and ids past num_glyphs
2824            // (which must be ignored exactly as before).
2825            let over = font.num_glyphs.saturating_add(3);
2826            assert_agree(
2827                &font,
2828                &[over, q, 0, u16::MAX, b, a, a, over],
2829                &['A', 'B', 'Q'],
2830            );
2831            if let Some(comp) = composite {
2832                assert_agree(&font, &[comp], &[]);
2833                let (bytes, _) = font.subset_glyphs(&[comp], &[]).expect("composite subset");
2834                let sub = Font::parse(bytes).expect("composite subset re-parses");
2835                assert!(
2836                    sub.num_glyphs > 2,
2837                    "closure must have pulled components beyond .notdef + the composite"
2838                );
2839            }
2840        }
2841    }
2842
2843    fn simple_instruction_len(data: &[u8]) -> Option<usize> {
2844        let contours = be_i16(data, 0)?;
2845        if contours < 0 {
2846            return None;
2847        }
2848        let instruction_len_offset = 10usize.checked_add((contours as usize).checked_mul(2)?)?;
2849        be_u16(data, instruction_len_offset).map(usize::from)
2850    }
2851
2852    #[test]
2853    fn simple_glyph_instruction_stripper_zeroes_length_and_removes_bytes() {
2854        let mut glyph = Vec::new();
2855        glyph.extend_from_slice(&1i16.to_be_bytes()); // one contour
2856        glyph.extend_from_slice(&[0u8; 8]); // bbox
2857        glyph.extend_from_slice(&0u16.to_be_bytes()); // endPtsOfContours[0]
2858        glyph.extend_from_slice(&3u16.to_be_bytes()); // instructionLength
2859        glyph.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // instructions
2860        glyph.extend_from_slice(&[0x11, 0x22, 0x33]); // flag/coordinate payload
2861
2862        let stripped = strip_simple_glyph_instructions(&glyph, 1).expect("valid simple glyph");
2863        assert_eq!(simple_instruction_len(&stripped), Some(0));
2864        assert_eq!(stripped.len(), glyph.len() - 3);
2865        assert_eq!(&stripped[stripped.len() - 3..], &[0x11, 0x22, 0x33]);
2866    }
2867
2868    #[test]
2869    fn subset_glyph_bytes_strips_simple_instructions_when_present() {
2870        let Some((font, gid, original_len)) = all_faces().into_iter().find_map(|font| {
2871            (1..font.num_glyphs).find_map(|gid| {
2872                let data = font.glyph_data(gid)?;
2873                let len = simple_instruction_len(data)?;
2874                (len > 0).then_some((font.clone(), gid, len))
2875            })
2876        }) else {
2877            eprintln!("skipping: bundled fonts have no hinted simple glyphs");
2878            return;
2879        };
2880
2881        let new_of = test_remap(&font, &[(0u16, 0u16), (gid, 1u16)]);
2882
2883        let stripped = font
2884            .subset_glyph_bytes(gid, &new_of)
2885            .expect("hinted simple glyph should subset");
2886        assert_eq!(simple_instruction_len(&stripped), Some(0));
2887        assert_eq!(
2888            stripped.len(),
2889            font.glyph_data(gid).expect("original glyph").len() - original_len
2890        );
2891    }
2892
2893    #[test]
2894    fn subset_hmtx_preserves_true_left_side_bearings() {
2895        let (font, ch, old_gid, old_lsb) = all_faces()
2896            .into_iter()
2897            .find_map(|font| {
2898                (33u8..=126).find_map(|byte| {
2899                    let ch = char::from(byte);
2900                    let gid = font.glyph_index(ch);
2901                    let lsb = font.left_side_bearing(gid);
2902                    (gid != 0 && lsb != 0).then_some((font.clone(), ch, gid, lsb))
2903                })
2904            })
2905            .expect("at least one bundled printable glyph has a nonzero lsb");
2906
2907        let (bytes, remap) = font
2908            .subset_glyphs(&[old_gid], &[ch])
2909            .expect("subset with nonzero-lsb glyph");
2910        let subset = Font::parse(bytes).expect("subset re-parses");
2911        let new_gid = remap[&old_gid];
2912        assert_eq!(subset.left_side_bearing(new_gid), old_lsb);
2913    }
2914
2915    #[test]
2916    fn html_subset_carries_verbatim_os2_while_pdf_subset_stays_lean() {
2917        // Chromium's OpenType sanitizer (OTS) rejects web fonts without an
2918        // `OS/2` table ("OS/2: missing required table"), silently downgrading
2919        // HTML previews to system fonts. The HTML path (`subset`) must carry
2920        // the source table verbatim; the PDF path (`subset_glyphs`) must keep
2921        // omitting it so embedded font streams and golden PDFs stay identical.
2922        for font in all_faces() {
2923            let (src_off, src_len) = find_table_full(&font.data, b"OS/2")
2924                .expect("every bundled face carries an OS/2 table");
2925            let src_os2 = font.data[src_off..src_off + src_len].to_vec();
2926
2927            let html_bytes = font.subset(&['A', 'b']).expect("html subset");
2928            let html_font = Font::parse(html_bytes).expect("html subset re-parses");
2929            let (o, l) = find_table_full(&html_font.data, b"OS/2")
2930                .expect("html subset must keep OS/2 for browser sanitizers");
2931            assert_eq!(
2932                &html_font.data[o..o + l],
2933                &src_os2[..],
2934                "OS/2 must be copied verbatim"
2935            );
2936
2937            let gid = font.glyph_index('A');
2938            assert_ne!(gid, 0, "bundled faces must map 'A'");
2939            let (pdf_bytes, _) = font.subset_glyphs(&[gid], &['A']).expect("pdf subset");
2940            assert!(
2941                find_table_full(&pdf_bytes, b"OS/2").is_none(),
2942                "pdf subset must not grow an OS/2 table (golden bytes)"
2943            );
2944            assert!(Font::parse(pdf_bytes).is_ok());
2945        }
2946    }
2947
2948    #[test]
2949    fn subset_skips_cmap_char_whose_glyph_is_absent_from_the_set() {
2950        // `subset_glyphs` takes the glyph set explicitly but builds the cmap from
2951        // `cmap_chars`. A cmap char whose glyph is not in the set must be skipped,
2952        // not abort the whole subset (which would deny an otherwise-usable font).
2953        let font = cm_regular();
2954        let g_b = font.glyph_index('B');
2955        assert_ne!(g_b, 0, "test font must map 'B'");
2956        // Provide only B's glyph, but ask the cmap to also map 'A' (absent).
2957        let out = font.subset_glyphs(&[g_b], &['A', 'B']);
2958        let (bytes, _) = out.expect("un-subsettable cmap char must be skipped, not abort");
2959        // The produced subset must still be a parseable font.
2960        assert!(Font::parse(bytes).is_ok());
2961    }
2962
2963    #[test]
2964    fn simple_instruction_len_rejects_composite_data() {
2965        // The helper reads numberOfContours first; a negative count (composite)
2966        // has no simple-glyph instruction stream to measure.
2967        assert_eq!(simple_instruction_len(&(-1i16).to_be_bytes()), None);
2968    }
2969
2970    #[test]
2971    fn subset_glyph_bytes_substitutes_notdef_for_a_missing_component() {
2972        // A composite whose component is not in `new_of` (a malformed out-of-range
2973        // component gid) must be substituted with `.notdef`, not abort.
2974        let (font, comp) = all_faces()
2975            .into_iter()
2976            .find_map(|f| {
2977                (1..f.num_glyphs)
2978                    .find(|&g| f.is_composite(g))
2979                    .map(|g| (f, g))
2980            })
2981            .expect("at least one bundled face has a composite glyph");
2982        // Map .notdef and the composite itself, but NONE of its components, so the
2983        // component lookup misses and must fall back to gid 0.
2984        let new_of = test_remap(&font, &[(0u16, 0u16), (comp, 1u16)]);
2985        let bytes = font
2986            .subset_glyph_bytes(comp, &new_of)
2987            .expect("missing component must be substituted, not abort");
2988        assert!(!bytes.is_empty(), "a composite glyph is non-empty");
2989    }
2990}
2991
2992#[cfg(test)]
2993#[cfg_attr(coverage_nightly, coverage(off))]
2994#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
2995mod synthetic_font_tests {
2996    use super::*;
2997
2998    // --- byte-level builders ------------------------------------------------
2999
3000    fn push16(out: &mut Vec<u8>, v: u16) {
3001        out.extend_from_slice(&v.to_be_bytes());
3002    }
3003
3004    fn push_i16(out: &mut Vec<u8>, v: i16) {
3005        out.extend_from_slice(&v.to_be_bytes());
3006    }
3007
3008    fn push32(out: &mut Vec<u8>, v: u32) {
3009        out.extend_from_slice(&v.to_be_bytes());
3010    }
3011
3012    /// Assemble an sfnt file. The directory records each table's real length;
3013    /// truncation tests chop bytes off the end of the returned file afterwards
3014    /// (the directory keeps claiming the full length, exactly like a damaged
3015    /// or malicious font would).
3016    fn sfnt(magic: u32, tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
3017        let mut out = Vec::new();
3018        push32(&mut out, magic);
3019        push16(&mut out, u16::try_from(tables.len()).unwrap());
3020        out.extend_from_slice(&[0u8; 6]); // search fields: unread by the parser
3021        let mut offset = 12 + tables.len() * 16;
3022        let mut body = Vec::new();
3023        for (tag, bytes) in tables {
3024            out.extend_from_slice(&tag[..]);
3025            push32(&mut out, 0); // checksum: unread by the parser
3026            push32(&mut out, u32::try_from(offset).unwrap());
3027            push32(&mut out, u32::try_from(bytes.len()).unwrap());
3028            offset += bytes.len();
3029            body.extend_from_slice(bytes);
3030        }
3031        out.extend_from_slice(&body);
3032        out
3033    }
3034
3035    fn head_table(upem: u16, loca_long: bool) -> Vec<u8> {
3036        let mut t = vec![0u8; 54];
3037        t[18..20].copy_from_slice(&upem.to_be_bytes());
3038        t[50..52].copy_from_slice(&u16::from(loca_long).to_be_bytes());
3039        t
3040    }
3041
3042    fn maxp_table(num_glyphs: u16) -> Vec<u8> {
3043        let mut t = vec![0u8; 6];
3044        t[4..6].copy_from_slice(&num_glyphs.to_be_bytes());
3045        t
3046    }
3047
3048    fn hhea_table(num_h_metrics: u16) -> Vec<u8> {
3049        let mut t = vec![0u8; 36];
3050        t[4..6].copy_from_slice(&700i16.to_be_bytes());
3051        t[6..8].copy_from_slice(&(-200i16).to_be_bytes());
3052        t[8..10].copy_from_slice(&50i16.to_be_bytes());
3053        t[34..36].copy_from_slice(&num_h_metrics.to_be_bytes());
3054        t
3055    }
3056
3057    fn hmtx_long(metrics: &[(u16, i16)]) -> Vec<u8> {
3058        let mut t = Vec::new();
3059        for &(aw, lsb) in metrics {
3060            push16(&mut t, aw);
3061            push_i16(&mut t, lsb);
3062        }
3063        t
3064    }
3065
3066    /// A complete `cmap` table holding one format-4 `(3,1)` subtable built from
3067    /// raw `(endCode, startCode, idDelta, idRangeOffset)` segments, followed by
3068    /// `glyph_id_array` bytes.
3069    fn cmap4_table(segs: &[(u16, u16, u16, u16)], glyph_id_array: &[u8]) -> Vec<u8> {
3070        let seg_count = segs.len();
3071        let mut t = Vec::new();
3072        push16(&mut t, 0); // version
3073        push16(&mut t, 1); // numTables
3074        push16(&mut t, 3); // platformID (Windows)
3075        push16(&mut t, 1); // encodingID (Unicode BMP)
3076        push32(&mut t, 12); // subtable offset
3077        push16(&mut t, 4); // format
3078        push16(
3079            &mut t,
3080            u16::try_from(16 + seg_count * 8 + glyph_id_array.len()).unwrap(),
3081        );
3082        push16(&mut t, 0); // language
3083        push16(&mut t, u16::try_from(seg_count * 2).unwrap()); // segCountX2
3084        push16(&mut t, 0); // searchRange (unread)
3085        push16(&mut t, 0); // entrySelector (unread)
3086        push16(&mut t, 0); // rangeShift (unread)
3087        for &(end, _, _, _) in segs {
3088            push16(&mut t, end);
3089        }
3090        push16(&mut t, 0); // reservedPad
3091        for &(_, start, _, _) in segs {
3092            push16(&mut t, start);
3093        }
3094        for &(_, _, delta, _) in segs {
3095            push16(&mut t, delta);
3096        }
3097        for &(_, _, _, iro) in segs {
3098            push16(&mut t, iro);
3099        }
3100        t.extend_from_slice(glyph_id_array);
3101        t
3102    }
3103
3104    /// A format-4 cmap mapping each ascending `(code, gid)` pair via its own
3105    /// delta-only segment, plus the mandatory final 0xFFFF segment.
3106    fn cmap4_simple(map: &[(u16, u16)]) -> Vec<u8> {
3107        let mut segs: Vec<(u16, u16, u16, u16)> = map
3108            .iter()
3109            .map(|&(code, gid)| (code, code, gid.wrapping_sub(code), 0))
3110            .collect();
3111        segs.push((0xFFFF, 0xFFFF, 1, 0));
3112        cmap4_table(&segs, &[])
3113    }
3114
3115    /// A complete `cmap` table holding one format-12 `(3,10)` subtable.
3116    fn cmap12_table(groups: &[(u32, u32, u32)]) -> Vec<u8> {
3117        let mut t = Vec::new();
3118        push16(&mut t, 0); // version
3119        push16(&mut t, 1); // numTables
3120        push16(&mut t, 3); // platformID (Windows)
3121        push16(&mut t, 10); // encodingID (Unicode full repertoire)
3122        push32(&mut t, 12); // subtable offset
3123        push16(&mut t, 12); // format
3124        push16(&mut t, 0); // reserved
3125        push32(&mut t, u32::try_from(16 + groups.len() * 12).unwrap());
3126        push32(&mut t, 0); // language
3127        push32(&mut t, u32::try_from(groups.len()).unwrap());
3128        for &(start, end, gid) in groups {
3129            push32(&mut t, start);
3130            push32(&mut t, end);
3131            push32(&mut t, gid);
3132        }
3133        t
3134    }
3135
3136    fn base_tables(
3137        num_glyphs: u16,
3138        num_h_metrics: u16,
3139        upem: u16,
3140        hmtx: Vec<u8>,
3141        cmap: Vec<u8>,
3142    ) -> Vec<(&'static [u8; 4], Vec<u8>)> {
3143        vec![
3144            (b"head", head_table(upem, false)),
3145            (b"maxp", maxp_table(num_glyphs)),
3146            (b"hhea", hhea_table(num_h_metrics)),
3147            (b"hmtx", hmtx),
3148            (b"cmap", cmap),
3149        ]
3150    }
3151
3152    fn parse(tables: &[(&[u8; 4], Vec<u8>)]) -> Font {
3153        Font::parse(sfnt(0x0001_0000, tables)).expect("synthetic font parses")
3154    }
3155
3156    // --- glyph builders -------------------------------------------------
3157
3158    const ARGW: u16 = 0x0001; // ARG_1_AND_2_ARE_WORDS
3159    const WHS: u16 = 0x0008; // WE_HAVE_A_SCALE
3160    const MORE: u16 = 0x0020; // MORE_COMPONENTS
3161    const XYS: u16 = 0x0040; // WE_HAVE_AN_X_AND_Y_SCALE
3162    const TWO: u16 = 0x0080; // WE_HAVE_A_TWO_BY_TWO
3163    const INSTR: u16 = 0x0100; // WE_HAVE_INSTRUCTIONS
3164
3165    /// A composite glyph: each record is `(flags, component gid, arg/scale
3166    /// payload)`; `trailer` bytes follow the last record.
3167    fn composite_glyph(bbox: [i16; 4], records: &[(u16, u16, &[u8])], trailer: &[u8]) -> Vec<u8> {
3168        let mut g = Vec::new();
3169        push_i16(&mut g, -1);
3170        for v in bbox {
3171            push_i16(&mut g, v);
3172        }
3173        for &(flags, gid, payload) in records {
3174            push16(&mut g, flags);
3175            push16(&mut g, gid);
3176            g.extend_from_slice(payload);
3177        }
3178        g.extend_from_slice(trailer);
3179        g
3180    }
3181
3182    /// A minimal valid hint-free simple glyph (16 bytes).
3183    fn simple_glyph16() -> Vec<u8> {
3184        let mut g = Vec::new();
3185        push_i16(&mut g, 1); // numberOfContours
3186        g.extend_from_slice(&[0u8; 8]); // bbox
3187        push16(&mut g, 0); // endPtsOfContours[0]
3188        push16(&mut g, 0); // instructionLength
3189        g.extend_from_slice(&[0x01, 0x00]); // flag + coordinate payload
3190        g
3191    }
3192
3193    /// Glyph zoo: 0 empty, 1 bare simple stub, 2/3/4 composites using the three
3194    /// transform payload sizes, 5 valid simple, 6 word-args + MORE chain,
3195    /// 7 MORE record ending exactly at the glyph end, 8 overlong instruction
3196    /// claim, 9 component gid past numGlyphs, 10 trailing junk after the last
3197    /// record, 11 transform payload overrunning the glyph, 12 valid composite
3198    /// instructions.
3199    fn zoo_font() -> Font {
3200        let glyphs: Vec<Vec<u8>> = vec![
3201            Vec::new(),
3202            1i16.to_be_bytes().to_vec(),
3203            composite_glyph([1, 2, 3, 4], &[(WHS, 5, &[0, 0, 0x40, 0])], &[]),
3204            composite_glyph([0; 4], &[(XYS, 5, &[0, 0, 0x40, 0, 0x40, 0])], &[]),
3205            composite_glyph(
3206                [0; 4],
3207                &[(TWO, 5, &[0, 0, 0x40, 0, 0, 0, 0, 0, 0x40, 0])],
3208                &[],
3209            ),
3210            simple_glyph16(),
3211            composite_glyph(
3212                [0; 4],
3213                &[(ARGW | MORE, 2, &[0, 0, 0, 0]), (0, 3, &[0, 0])],
3214                &[],
3215            ),
3216            composite_glyph([0; 4], &[(MORE, 5, &[0, 0])], &[]),
3217            composite_glyph([0; 4], &[(INSTR, 5, &[0, 0])], &[0xFF, 0xFF]),
3218            composite_glyph([0; 4], &[(0, 900, &[0, 0])], &[]),
3219            composite_glyph([0; 4], &[(MORE, 5, &[0, 0])], &[0, 0]),
3220            composite_glyph([0; 4], &[(TWO, 5, &[0, 0, 0x40, 0])], &[]),
3221            composite_glyph([0; 4], &[(INSTR, 5, &[0, 0])], &[0x00, 0x02, 0xAA, 0xBB]),
3222        ];
3223        let mut glyf = Vec::new();
3224        let mut loca = Vec::new();
3225        push16(&mut loca, 0);
3226        for g in &glyphs {
3227            glyf.extend_from_slice(g);
3228            push16(&mut loca, u16::try_from(glyf.len() / 2).unwrap());
3229        }
3230        let metrics: Vec<(u16, i16)> = (0..13u16).map(|g| (500 + g, g as i16)).collect();
3231        let mut tables = base_tables(13, 13, 1000, hmtx_long(&metrics), cmap4_simple(&[]));
3232        tables.push((b"loca", loca));
3233        tables.push((b"glyf", glyf));
3234        parse(&tables)
3235    }
3236
3237    /// One composite glyph whose loca/glyf directory claims 16 bytes while the
3238    /// file physically ends after `keep` of them.
3239    fn truncated_composite_font(keep: usize) -> Font {
3240        let glyph = composite_glyph([0; 4], &[(0, 5, &[0, 0])], &[]);
3241        assert_eq!(glyph.len(), 16);
3242        let mut loca = Vec::new();
3243        push16(&mut loca, 0);
3244        push16(&mut loca, 8);
3245        let mut tables = base_tables(1, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_simple(&[]));
3246        tables.push((b"loca", loca));
3247        tables.push((b"glyf", glyph));
3248        let mut bytes = sfnt(0x0001_0000, &tables);
3249        bytes.truncate(bytes.len() - (16 - keep));
3250        Font::parse(bytes).expect("glyf payload is lazily read")
3251    }
3252
3253    /// A `kern` table with one version-0 format-0 horizontal subtable.
3254    fn kern0_table(pairs: &[(u16, u16, i16)]) -> Vec<u8> {
3255        let mut t = Vec::new();
3256        push16(&mut t, 0); // version
3257        push16(&mut t, 1); // nTables
3258        push16(&mut t, 0); // subtable version
3259        push16(&mut t, u16::try_from(14 + pairs.len() * 6).unwrap()); // length
3260        push16(&mut t, 0x0001); // coverage: horizontal, format 0
3261        push16(&mut t, u16::try_from(pairs.len()).unwrap()); // nPairs
3262        t.extend_from_slice(&[0u8; 6]); // search fields (unread)
3263        for &(l, r, v) in pairs {
3264            push16(&mut t, l);
3265            push16(&mut t, r);
3266            push_i16(&mut t, v);
3267        }
3268        t
3269    }
3270
3271    /// Raw GPOS table: a `kern` feature routing through an Extension (type 9)
3272    /// lookup to a Pair Adjustment format-1 subtable holding (5, 6) -> -40.
3273    fn gpos_table(ext_format: u16, ext_type: u16, lookup_index: u16, pos_format: u16) -> Vec<u8> {
3274        let mut g = Vec::new();
3275        push32(&mut g, 0x0001_0000); // version
3276        push16(&mut g, 0); // scriptList (unread)
3277        push16(&mut g, 10); // featureList
3278        push16(&mut g, 24); // lookupList
3279        // FeatureList @10
3280        push16(&mut g, 1); // featureCount
3281        g.extend_from_slice(b"kern");
3282        push16(&mut g, 8); // feature @ featureList+8
3283        // Feature @18
3284        push16(&mut g, 0); // featureParams
3285        push16(&mut g, 1); // lookupIndexCount
3286        push16(&mut g, lookup_index);
3287        // LookupList @24
3288        push16(&mut g, 1); // lookupCount
3289        push16(&mut g, 4); // lookup @ lookupList+4
3290        // Lookup @28: Extension Positioning
3291        push16(&mut g, 9); // lookupType
3292        push16(&mut g, 0); // lookupFlag
3293        push16(&mut g, 1); // subTableCount
3294        push16(&mut g, 8); // subtable @ lookup+8
3295        // Extension @36
3296        push16(&mut g, ext_format);
3297        push16(&mut g, ext_type);
3298        push32(&mut g, 8); // wrapped subtable @ 36+8
3299        // PairPos @44
3300        push16(&mut g, pos_format);
3301        push16(&mut g, 18); // coverage @ 44+18
3302        push16(&mut g, 0x0004); // valueFormat1: X_ADVANCE
3303        push16(&mut g, 0); // valueFormat2
3304        push16(&mut g, 1); // pairSetCount
3305        push16(&mut g, 12); // pair set @ 44+12
3306        // PairSet @56
3307        push16(&mut g, 1); // pairValueCount
3308        push16(&mut g, 6); // secondGlyph
3309        push_i16(&mut g, -40); // xAdvance
3310        // Coverage @62
3311        push16(&mut g, 1);
3312        push16(&mut g, 1);
3313        push16(&mut g, 5);
3314        assert_eq!(g.len(), 68);
3315        g
3316    }
3317
3318    /// Attach raw GPOS bytes (as the last table, so shortened tables truncate
3319    /// the file) and parse its kerning.
3320    fn gpos_kerning_of(table: Vec<u8>) -> Kerning {
3321        let mut tables = base_tables(
3322            2,
3323            2,
3324            1000,
3325            hmtx_long(&[(600, 0), (600, 0)]),
3326            cmap4_simple(&[]),
3327        );
3328        tables.push((b"GPOS", table));
3329        parse(&tables).gpos_kerning()
3330    }
3331
3332    fn gpos_font(ext_format: u16, ext_type: u16, lookup_index: u16, pos_format: u16) -> Kerning {
3333        gpos_kerning_of(gpos_table(ext_format, ext_type, lookup_index, pos_format))
3334    }
3335
3336    /// Raw GSUB table: a `liga` feature routing through an Extension (type 7)
3337    /// lookup to a LigatureSubst with (10,11,12)->99 and (10,11)->77.
3338    fn gsub_table(ext_format: u16, ext_type: u16, lookup_index: u16) -> Vec<u8> {
3339        let mut g = Vec::new();
3340        push32(&mut g, 0x0001_0000);
3341        push16(&mut g, 0); // scriptList (unread)
3342        push16(&mut g, 10); // featureList
3343        push16(&mut g, 24); // lookupList
3344        // FeatureList @10
3345        push16(&mut g, 1);
3346        g.extend_from_slice(b"liga");
3347        push16(&mut g, 8); // feature @18
3348        // Feature @18
3349        push16(&mut g, 0);
3350        push16(&mut g, 1);
3351        push16(&mut g, lookup_index);
3352        // LookupList @24
3353        push16(&mut g, 1);
3354        push16(&mut g, 4); // lookup @28
3355        // Lookup @28: Extension Substitution
3356        push16(&mut g, 7);
3357        push16(&mut g, 0);
3358        push16(&mut g, 1);
3359        push16(&mut g, 8); // subtable @36
3360        // Extension @36
3361        push16(&mut g, ext_format);
3362        push16(&mut g, ext_type);
3363        push32(&mut g, 8); // wrapped subtable @44
3364        // LigatureSubst @44
3365        push16(&mut g, 1); // substFormat
3366        push16(&mut g, 28); // coverage @ 44+28
3367        push16(&mut g, 1); // ligSetCount
3368        push16(&mut g, 8); // ligature set @ 44+8
3369        // LigatureSet @52
3370        push16(&mut g, 2); // ligatureCount
3371        push16(&mut g, 6); // ligature @ 52+6
3372        push16(&mut g, 14); // ligature @ 52+14
3373        // Ligature @58: components (10, 11, 12) -> 99
3374        push16(&mut g, 99);
3375        push16(&mut g, 3);
3376        push16(&mut g, 11);
3377        push16(&mut g, 12);
3378        // Ligature @66: components (10, 11) -> 77
3379        push16(&mut g, 77);
3380        push16(&mut g, 2);
3381        push16(&mut g, 11);
3382        // Coverage @72
3383        push16(&mut g, 1);
3384        push16(&mut g, 1);
3385        push16(&mut g, 10);
3386        assert_eq!(g.len(), 78);
3387        g
3388    }
3389
3390    /// Attach raw GSUB bytes (as the last table) and parse its ligatures.
3391    fn gsub_ligatures_of(table: Vec<u8>) -> Ligatures {
3392        let mut tables = base_tables(
3393            2,
3394            2,
3395            1000,
3396            hmtx_long(&[(600, 0), (600, 0)]),
3397            cmap4_simple(&[]),
3398        );
3399        tables.push((b"GSUB", table));
3400        parse(&tables).gsub_ligatures()
3401    }
3402
3403    fn gsub_font(ext_format: u16, ext_type: u16, lookup_index: u16) -> Ligatures {
3404        gsub_ligatures_of(gsub_table(ext_format, ext_type, lookup_index))
3405    }
3406
3407    // --- parse errors and magics ---------------------------------------
3408
3409    #[test]
3410    fn parse_error_variants_and_display_messages() {
3411        assert_eq!(Font::parse(Vec::new()).err(), Some(FontError::Truncated));
3412        assert_eq!(
3413            Font::parse(vec![0x00, 0x02, 0x00, 0x00]).err(),
3414            Some(FontError::BadMagic)
3415        );
3416
3417        // Required tables are demanded in a fixed order.
3418        let mut tables: Vec<(&[u8; 4], Vec<u8>)> = Vec::new();
3419        let steps: [(&'static [u8; 4], &'static str, Vec<u8>); 4] = [
3420            (b"head", "head", head_table(1000, false)),
3421            (b"maxp", "maxp", maxp_table(1)),
3422            (b"hhea", "hhea", hhea_table(1)),
3423            (b"hmtx", "hmtx", hmtx_long(&[(500, 0)])),
3424        ];
3425        for (tag, name, table) in steps {
3426            assert_eq!(
3427                Font::parse(sfnt(0x0001_0000, &tables)).err(),
3428                Some(FontError::MissingTable(name))
3429            );
3430            tables.push((tag, table));
3431        }
3432        assert_eq!(
3433            Font::parse(sfnt(0x0001_0000, &tables)).err(),
3434            Some(FontError::MissingTable("cmap"))
3435        );
3436
3437        // A cmap with only a Mac record and an unsupported-format Windows
3438        // record has no usable Unicode subtable.
3439        let mut bad_cmap = Vec::new();
3440        push16(&mut bad_cmap, 0);
3441        push16(&mut bad_cmap, 2);
3442        push16(&mut bad_cmap, 1); // platform 1 (Macintosh): not Unicode
3443        push16(&mut bad_cmap, 0);
3444        push32(&mut bad_cmap, 20);
3445        push16(&mut bad_cmap, 3); // (3,1) but pointing at a format-6 subtable
3446        push16(&mut bad_cmap, 1);
3447        push32(&mut bad_cmap, 20);
3448        push16(&mut bad_cmap, 6); // subtable @20: format 6 (unsupported)
3449        tables.push((b"cmap", bad_cmap));
3450        assert_eq!(
3451            Font::parse(sfnt(0x0001_0000, &tables)).err(),
3452            Some(FontError::NoUnicodeCmap)
3453        );
3454
3455        // All tables found, but the file ends before head's unitsPerEm.
3456        let short_head: Vec<(&[u8; 4], Vec<u8>)> = vec![
3457            (b"maxp", maxp_table(1)),
3458            (b"hhea", hhea_table(1)),
3459            (b"hmtx", hmtx_long(&[(500, 0)])),
3460            (b"cmap", cmap4_simple(&[])),
3461            (b"head", vec![0u8; 10]),
3462        ];
3463        assert_eq!(
3464            Font::parse(sfnt(0x0001_0000, &short_head)).err(),
3465            Some(FontError::Truncated)
3466        );
3467
3468        assert_eq!(
3469            FontError::BadMagic.to_string(),
3470            "not a TrueType/OpenType font"
3471        );
3472        assert_eq!(
3473            FontError::MissingTable("hhea").to_string(),
3474            "missing required font table: hhea"
3475        );
3476        assert_eq!(FontError::Truncated.to_string(), "font data is truncated");
3477        assert_eq!(
3478            FontError::NoUnicodeCmap.to_string(),
3479            "no usable Unicode cmap (format 4/12)"
3480        );
3481    }
3482
3483    #[test]
3484    fn parse_accepts_true_and_otto_magics() {
3485        let tables = base_tables(
3486            3,
3487            3,
3488            2048,
3489            hmtx_long(&[(500, 1), (510, 2), (520, 3)]),
3490            cmap4_simple(&[(0x41, 1)]),
3491        );
3492        let t = Font::parse(sfnt(0x7472_7565, &tables)).expect("'true' magic parses");
3493        assert_eq!(t.units_per_em, 2048);
3494        assert_eq!(t.num_glyphs, 3);
3495        assert_eq!(t.ascent, 700);
3496        assert_eq!(t.descent, -200);
3497        assert_eq!(t.line_gap, 50);
3498        assert_eq!(t.glyph_index('A'), 1);
3499
3500        // CFF-flavored fonts parse for metrics but expose no glyf outlines.
3501        let o = Font::parse(sfnt(0x4F54_544F, &tables)).expect("'OTTO' magic parses");
3502        assert!(!o.has_glyf_outlines());
3503        assert_eq!(o.subset(&['A']), None);
3504        assert_eq!(o.glyph_bbox(1), None);
3505        assert_eq!(o.glyph_data(1), None);
3506        assert!(!o.is_composite(1));
3507        assert!(o.glyph_components(1).is_empty());
3508    }
3509
3510    // --- hmtx edges -------------------------------------------------------
3511
3512    #[test]
3513    fn left_side_bearing_reads_trailing_run_and_zero_metrics() {
3514        // 3 glyphs, 1 long metric: gid 0 keeps (advance, lsb); gids 1..2 share
3515        // the last advance but read their own trailing i16 lsb.
3516        let mut hmtx = hmtx_long(&[(500, 50)]);
3517        push_i16(&mut hmtx, -7);
3518        push_i16(&mut hmtx, 33);
3519        let font = parse(&base_tables(3, 1, 1000, hmtx, cmap4_simple(&[])));
3520        assert_eq!(font.left_side_bearing(0), 50);
3521        assert_eq!(font.left_side_bearing(1), -7);
3522        assert_eq!(font.left_side_bearing(2), 33);
3523        assert_eq!(font.advance_width(0), 500);
3524        assert_eq!(font.advance_width(2), 500);
3525
3526        // A face declaring zero hMetrics reports zero bearings.
3527        let font0 = parse(&base_tables(1, 0, 1000, Vec::new(), cmap4_simple(&[])));
3528        assert_eq!(font0.left_side_bearing(0), 0);
3529    }
3530
3531    // --- legacy kern --------------------------------------------------------
3532
3533    #[test]
3534    fn legacy_kern_pair_and_char_kerning() {
3535        let pairs = [(1u16, 2u16, -30i16), (1, 3, 15), (4, 1, 7)];
3536        let metrics: Vec<(u16, i16)> = (0..8u16).map(|g| (600 + g, 0)).collect();
3537        let mut tables = base_tables(
3538            8,
3539            8,
3540            1000,
3541            hmtx_long(&metrics),
3542            cmap4_simple(&[(0x41, 1), (0x56, 2)]),
3543        );
3544        tables.push((b"kern", kern0_table(&pairs)));
3545        let font = parse(&tables);
3546        assert_eq!(font.kerning_between_glyphs(1, 2), -30);
3547        assert_eq!(font.kerning_between_glyphs(1, 3), 15);
3548        assert_eq!(font.kerning_between_glyphs(4, 1), 7);
3549        assert_eq!(font.kerning_between_glyphs(2, 1), 0);
3550        assert_eq!(font.kerning_between_glyphs(1, 4), 0);
3551        assert_eq!(font.kerning('A', 'V'), -30);
3552        assert_eq!(font.kerning_1000('A', 'V'), -30); // upem == 1000
3553        assert_eq!(font.advance_1000('A'), 601);
3554
3555        // unitsPerEm == 0 short-circuits both per-mille scalers.
3556        let mut zero = base_tables(
3557            8,
3558            8,
3559            0,
3560            hmtx_long(&metrics),
3561            cmap4_simple(&[(0x41, 1), (0x56, 2)]),
3562        );
3563        zero.push((b"kern", kern0_table(&pairs)));
3564        let z = parse(&zero);
3565        assert_eq!(z.units_per_em, 0);
3566        assert_eq!(z.advance_1000('A'), 0);
3567        assert_eq!(z.kerning_1000('A', 'V'), 0);
3568    }
3569
3570    #[test]
3571    fn legacy_kern_skips_short_vertical_minimum_and_format2_subtables() {
3572        let mut k = Vec::new();
3573        push16(&mut k, 0); // version
3574        push16(&mut k, 5); // nTables
3575        // horizontal format 0 but length < 14: skipped
3576        push16(&mut k, 0);
3577        push16(&mut k, 10);
3578        push16(&mut k, 0x0001);
3579        k.extend_from_slice(&[0u8; 4]);
3580        // vertical (horizontal bit clear)
3581        push16(&mut k, 0);
3582        push16(&mut k, 14);
3583        push16(&mut k, 0x0000);
3584        k.extend_from_slice(&[0u8; 8]);
3585        // minimum-values bit set
3586        push16(&mut k, 0);
3587        push16(&mut k, 14);
3588        push16(&mut k, 0x0003);
3589        k.extend_from_slice(&[0u8; 8]);
3590        // format 2
3591        push16(&mut k, 0);
3592        push16(&mut k, 14);
3593        push16(&mut k, 0x0201);
3594        k.extend_from_slice(&[0u8; 8]);
3595        // the real horizontal format-0 subtable
3596        push16(&mut k, 0);
3597        push16(&mut k, 20);
3598        push16(&mut k, 0x0001);
3599        push16(&mut k, 1); // nPairs
3600        k.extend_from_slice(&[0u8; 6]);
3601        push16(&mut k, 3);
3602        push16(&mut k, 4);
3603        push_i16(&mut k, -11);
3604
3605        let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
3606        tables.push((b"kern", k));
3607        let font = parse(&tables);
3608        assert_eq!(font.kerning_between_glyphs(3, 4), -11);
3609        assert_eq!(font.kerning_between_glyphs(3, 5), 0);
3610    }
3611
3612    #[test]
3613    fn legacy_kern_rejects_malformed_table_headers() {
3614        fn kern_font(kern: Vec<u8>) -> Font {
3615            let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
3616            tables.push((b"kern", kern));
3617            parse(&tables)
3618        }
3619        // Table version != 0 (e.g. AAT kern 1.0): ignored entirely.
3620        let mut v1 = kern0_table(&[(1, 2, -30)]);
3621        v1[0..2].copy_from_slice(&1u16.to_be_bytes());
3622        assert_eq!(kern_font(v1).kerning_between_glyphs(1, 2), 0);
3623
3624        // A zero-length subtable would never advance: bail.
3625        let mut zero_len = Vec::new();
3626        push16(&mut zero_len, 0);
3627        push16(&mut zero_len, 2);
3628        push16(&mut zero_len, 0); // subtable version
3629        push16(&mut zero_len, 0); // length 0
3630        push16(&mut zero_len, 0x0000); // vertical, so the format match misses
3631        zero_len.extend_from_slice(&[0u8; 8]);
3632        assert_eq!(kern_font(zero_len).kerning_between_glyphs(1, 2), 0);
3633
3634        // nTables claims a second subtable beyond the table end.
3635        let mut walk_off = Vec::new();
3636        push16(&mut walk_off, 0);
3637        push16(&mut walk_off, 2);
3638        push16(&mut walk_off, 0);
3639        push16(&mut walk_off, 14);
3640        push16(&mut walk_off, 0x0000); // vertical: skipped
3641        walk_off.extend_from_slice(&[0u8; 8]);
3642        assert_eq!(kern_font(walk_off).kerning_between_glyphs(1, 2), 0);
3643
3644        // Subtable length overrunning the kern table itself.
3645        let mut overlong = Vec::new();
3646        push16(&mut overlong, 0);
3647        push16(&mut overlong, 1);
3648        push16(&mut overlong, 0);
3649        push16(&mut overlong, 200); // sub_end > table_end
3650        push16(&mut overlong, 0x0001);
3651        overlong.extend_from_slice(&[0u8; 8]);
3652        assert_eq!(kern_font(overlong).kerning_between_glyphs(1, 2), 0);
3653
3654        // nPairs needing more bytes than the subtable declares.
3655        let mut hungry = Vec::new();
3656        push16(&mut hungry, 0);
3657        push16(&mut hungry, 1);
3658        push16(&mut hungry, 0);
3659        push16(&mut hungry, 20); // room for exactly one pair
3660        push16(&mut hungry, 0x0001);
3661        push16(&mut hungry, 3); // nPairs 3: needs 18 pair bytes, has 6
3662        hungry.extend_from_slice(&[0u8; 12]);
3663        assert_eq!(kern_font(hungry).kerning_between_glyphs(1, 2), 0);
3664
3665        // A single skipped subtable: the walk ends without a match.
3666        let mut vertical_only = Vec::new();
3667        push16(&mut vertical_only, 0);
3668        push16(&mut vertical_only, 1);
3669        push16(&mut vertical_only, 0);
3670        push16(&mut vertical_only, 14);
3671        push16(&mut vertical_only, 0x0000);
3672        vertical_only.extend_from_slice(&[0u8; 8]);
3673        assert_eq!(kern_font(vertical_only).kerning_between_glyphs(1, 2), 0);
3674    }
3675
3676    #[test]
3677    fn legacy_kern_truncated_pair_records_kern_to_zero() {
3678        // kern is the last table; its directory claims 4 pair records but the
3679        // file ends inside them, so binary-search probes hit EOF and yield 0.
3680        // chop 10 leaves record 2's left glyph readable (right glyph missing);
3681        // chop 16 removes even the left glyph of the probed record.
3682        let pairs = [(1u16, 2u16, -30i16), (1, 3, 15), (4, 1, 7), (5, 5, 9)];
3683        for chop in [10usize, 16] {
3684            let mut tables = base_tables(8, 1, 1000, hmtx_long(&[(600, 0)]), cmap4_simple(&[]));
3685            tables.push((b"kern", kern0_table(&pairs)));
3686            let mut bytes = sfnt(0x0001_0000, &tables);
3687            bytes.truncate(bytes.len() - chop);
3688            let font = Font::parse(bytes).expect("kern pair payload is lazily read");
3689            assert_eq!(font.kerning_between_glyphs(1, 2), 0, "chop={chop}");
3690        }
3691    }
3692
3693    // --- glyf / loca edges ----------------------------------------------
3694
3695    #[test]
3696    fn glyph_range_rejects_inverted_and_overlong_loca_entries() {
3697        // loca (short) = [4, 2, 6]: glyph 0 is inverted (end < start); glyph 1
3698        // claims [2, 6) but the glyf table is only 4 bytes long.
3699        let mut loca = Vec::new();
3700        push16(&mut loca, 2);
3701        push16(&mut loca, 1);
3702        push16(&mut loca, 3);
3703        let mut tables = base_tables(
3704            2,
3705            2,
3706            1000,
3707            hmtx_long(&[(500, 0), (500, 0)]),
3708            cmap4_simple(&[]),
3709        );
3710        tables.push((b"loca", loca));
3711        tables.push((b"glyf", vec![0u8; 4]));
3712        let font = parse(&tables);
3713        assert!(font.has_glyf_outlines());
3714        assert_eq!(font.glyph_data(0), None);
3715        assert_eq!(font.glyph_data(1), None);
3716        assert_eq!(font.glyph_bbox(0), None);
3717        assert!(!font.is_composite(0));
3718    }
3719
3720    #[test]
3721    fn glyph_components_walk_all_transform_variants() {
3722        let font = zoo_font();
3723        assert!(font.glyph_components(0).is_empty()); // empty glyph
3724        assert!(font.glyph_components(1).is_empty()); // simple glyph
3725        assert_eq!(font.glyph_components(2), vec![5]); // WE_HAVE_A_SCALE
3726        assert_eq!(font.glyph_components(3), vec![5]); // X_AND_Y_SCALE
3727        assert_eq!(font.glyph_components(4), vec![5]); // TWO_BY_TWO
3728        assert_eq!(font.glyph_components(6), vec![2, 3]); // word args + MORE
3729        assert_eq!(font.glyph_components(7), vec![5]); // MORE, record ends at glyph end
3730        assert_eq!(font.glyph_components(10), vec![5]); // junk after the last record
3731        assert!(font.glyph_components(11).is_empty()); // 2x2 payload overruns glyph
3732        assert!(font.is_composite(2));
3733        assert!(!font.is_composite(1));
3734        assert!(!font.is_composite(0));
3735        assert_eq!(font.glyph_bbox(2), Some([1, 2, 3, 4]));
3736        assert_eq!(font.glyph_bbox(0), None);
3737        assert_eq!(font.glyph_data(0), Some(&[][..]));
3738    }
3739
3740    #[test]
3741    fn glyph_components_stop_at_truncated_component_records() {
3742        // keep = physically present bytes of the 16-byte composite: 1 cuts the
3743        // contour count, 10 cuts the first record's flags, 12 its glyph index.
3744        for keep in [1usize, 10, 12] {
3745            let font = truncated_composite_font(keep);
3746            assert!(font.glyph_components(0).is_empty(), "keep={keep}");
3747            assert_eq!(font.glyph_data(0), None, "keep={keep}");
3748        }
3749    }
3750
3751    // --- subsetting edges -------------------------------------------------
3752
3753    #[test]
3754    fn subset_rewrites_component_ids_across_transform_variants() {
3755        let font = zoo_font();
3756        let (bytes, remap) = font.subset_glyphs(&[2, 3, 4], &[]).expect("subset");
3757        let remap: Vec<(u16, u16)> = remap.into_iter().collect();
3758        assert_eq!(remap, vec![(0, 0), (2, 1), (3, 2), (4, 3), (5, 4)]);
3759        let sub = Font::parse(bytes).expect("subset re-parses");
3760        assert_eq!(sub.num_glyphs, 5);
3761        assert_eq!(sub.glyph_components(1), vec![4]);
3762        assert_eq!(sub.glyph_components(2), vec![4]);
3763        assert_eq!(sub.glyph_components(3), vec![4]);
3764        assert_eq!(sub.glyph_bbox(1), Some([1, 2, 3, 4]));
3765        assert_eq!(sub.advance_width(1), 502);
3766        assert_eq!(sub.left_side_bearing(1), 2);
3767        assert_eq!(sub.advance_width(4), 505);
3768        assert_eq!(sub.left_side_bearing(4), 5);
3769    }
3770
3771    #[test]
3772    fn subset_closure_skips_component_ids_past_num_glyphs() {
3773        let font = zoo_font();
3774        let (bytes, remap) = font.subset_glyphs(&[9], &[]).expect("subset");
3775        assert_eq!(remap.get(&9).copied(), Some(1));
3776        assert_eq!(remap.len(), 2); // .notdef + composite; gid 900 never joins
3777        let sub = Font::parse(bytes).expect("subset re-parses");
3778        assert_eq!(sub.num_glyphs, 2);
3779        // The out-of-range component was substituted with .notdef.
3780        assert_eq!(sub.glyph_components(1), vec![0]);
3781    }
3782
3783    #[test]
3784    fn subset_shares_a_component_between_two_composites() {
3785        let font = zoo_font();
3786        let (bytes, remap) = font.subset_glyphs(&[2, 4], &[]).expect("subset");
3787        let remap: Vec<(u16, u16)> = remap.into_iter().collect();
3788        assert_eq!(remap, vec![(0, 0), (2, 1), (4, 2), (5, 3)]);
3789        let sub = Font::parse(bytes).expect("subset re-parses");
3790        assert_eq!(sub.glyph_components(1), vec![3]);
3791        assert_eq!(sub.glyph_components(2), vec![3]);
3792    }
3793
3794    #[test]
3795    fn subset_tolerates_composite_whose_last_record_dangles_more() {
3796        // `glyph_components` tolerates a final record with MORE_COMPONENTS
3797        // set and nothing after it (gid 7); `subset_glyph_bytes` now mirrors
3798        // that policy — the dangling MORE flag is cleared on the final record
3799        // and the truncated composite is emitted instead of failing the whole
3800        // font subset.
3801        let font = zoo_font();
3802        assert_eq!(font.glyph_components(7), vec![5]);
3803        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
3804        new_of[0] = 0;
3805        new_of[5] = 1;
3806        new_of[7] = 2;
3807        let out = font
3808            .subset_glyph_bytes(7, &new_of)
3809            .expect("dangling MORE bit is tolerated and stripped");
3810        // The MORE bit on gid 7's single component record (offset 10) is gone.
3811        assert_eq!(be_u16(&out, 10), Some(0));
3812        assert!(font.subset_glyphs(&[7], &[]).is_some());
3813    }
3814
3815    #[test]
3816    fn subset_tolerates_composite_whose_dangling_record_claims_instructions() {
3817        // A final record with WE_HAVE_INSTRUCTIONS|MORE_COMPONENTS and zero
3818        // bytes behind it: the reader stops the walk, so the subsetter must
3819        // too — dropping both claims rather than failing the whole font on
3820        // the instruction_len read past the glyph end.
3821        let glyph = composite_glyph([0; 4], &[(0x0100 | 0x0020, 0, &[0, 0])], &[]);
3822        let mut glyf = Vec::new();
3823        let mut loca = Vec::new();
3824        push16(&mut loca, 0); // glyph 0 starts (and ends: it is empty)
3825        push16(&mut loca, 0);
3826        glyf.extend_from_slice(&glyph);
3827        push16(&mut loca, u16::try_from(glyf.len() / 2).unwrap());
3828        let mut tables = base_tables(
3829            2,
3830            1,
3831            1000,
3832            hmtx_long(&[(500, 0), (500, 1)]),
3833            cmap4_simple(&[]),
3834        );
3835        tables.push((b"loca", loca));
3836        tables.push((b"glyf", glyf));
3837        let font = parse(&tables);
3838
3839        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
3840        new_of[0] = 0;
3841        new_of[1] = 1;
3842        let out = font
3843            .subset_glyph_bytes(1, &new_of)
3844            .expect("dangling INSTRUCTIONS|MORE record is tolerated");
3845        // Both the MORE and the instruction claim are gone from the final
3846        // record's flags (offset 10).
3847        let flags = be_u16(&out, 10).expect("record flags readable");
3848        assert_eq!(
3849            flags & (0x0100 | 0x0020),
3850            0,
3851            "MORE and INSTRUCTIONS cleared"
3852        );
3853    }
3854
3855    #[test]
3856    fn subset_emits_format12_cmap_when_supplementary_plane_is_kept() {
3857        // A source cmap whose full-Unicode subtable maps both BMP letters and
3858        // script letters: the subset must carry every kept char across the
3859        // plane boundary, renumbering gids while keeping the format-12 table.
3860        let groups: &[(u32, u32, u32)] =
3861            &[(u32::from('A'), u32::from('B'), 1), (0x1D49C, 0x1D49D, 3)];
3862        let mut tables = base_tables(
3863            5,
3864            1,
3865            1000,
3866            hmtx_long(&[(500, 0), (505, 1), (510, 2), (515, 3)]),
3867            cmap12_table(groups),
3868        );
3869
3870        let mut glyf = Vec::new();
3871        let mut loca = Vec::new();
3872        push16(&mut loca, 0);
3873        for _ in 0..4 {
3874            let g = simple_glyph16();
3875            glyf.extend_from_slice(&g);
3876            push16(&mut loca, u16::try_from(glyf.len() / 2).unwrap());
3877        }
3878        while glyf.len() % 4 != 0 {
3879            glyf.push(0);
3880        }
3881        tables.push((b"loca", loca));
3882        tables.push((b"glyf", glyf));
3883        let font = parse(&tables);
3884        assert_ne!(font.glyph_index('A'), 0);
3885        assert_ne!(font.glyph_index('\u{1D49C}'), 0);
3886
3887        let subset = font
3888            .subset(&['A', 'B', '\u{1D49C}', '\u{1D49D}'])
3889            .expect("subset");
3890        let reparsed = Font::parse(subset).expect("subset re-parses");
3891        assert_ne!(reparsed.glyph_index('A'), 0, "BMP letter survives");
3892        assert_ne!(reparsed.glyph_index('B'), 0, "BMP letter survives");
3893        assert_ne!(
3894            reparsed.glyph_index('\u{1D49C}'),
3895            0,
3896            "script A must survive the subset"
3897        );
3898        assert_ne!(
3899            reparsed.glyph_index('\u{1D49D}'),
3900            0,
3901            "script B must survive the subset"
3902        );
3903    }
3904
3905    #[test]
3906    fn subset_strips_valid_composite_instructions_and_clears_the_flag() {
3907        let font = zoo_font();
3908        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
3909        new_of[0] = 0;
3910        new_of[5] = 1;
3911        new_of[12] = 2;
3912        let out = font
3913            .subset_glyph_bytes(12, &new_of)
3914            .expect("valid instructions strip");
3915        assert_eq!(out.len(), 16); // 20 minus the length field and 2 bytes
3916        assert_eq!(be_u16(&out, 10), Some(0)); // WE_HAVE_INSTRUCTIONS cleared
3917        assert_eq!(be_u16(&out, 12), Some(1)); // component 5 renumbered
3918        let (bytes, remap) = font.subset_glyphs(&[12], &[]).expect("subset");
3919        assert_eq!(remap.get(&12).copied(), Some(2));
3920        let sub = Font::parse(bytes).expect("subset re-parses");
3921        assert_eq!(sub.glyph_components(2), vec![1]);
3922    }
3923
3924    #[test]
3925    fn subset_cmap_skips_supplementary_plane_chars() {
3926        let font = zoo_font();
3927        let (bytes, _) = font.subset_glyphs(&[2], &['😀']).expect("subset");
3928        let sub = Font::parse(bytes).expect("subset re-parses");
3929        assert_eq!(sub.glyph_index('😀'), 0); // never entered the format-4 cmap
3930    }
3931
3932    #[test]
3933    fn subset_rejects_composite_with_overlong_instruction_claim() {
3934        let font = zoo_font();
3935        let mut new_of = vec![MISSING_GLYPH_REMAP; usize::from(font.num_glyphs)];
3936        new_of[0] = 0;
3937        new_of[5] = 1;
3938        new_of[8] = 2;
3939        assert_eq!(font.subset_glyph_bytes(8, &new_of), None);
3940        assert!(font.subset_glyphs(&[8], &[]).is_none());
3941    }
3942
3943    #[test]
3944    fn strip_simple_glyph_instructions_rejects_overlong_length() {
3945        let mut glyph = Vec::new();
3946        push_i16(&mut glyph, 1);
3947        glyph.extend_from_slice(&[0u8; 8]); // bbox
3948        push16(&mut glyph, 0); // endPtsOfContours[0]
3949        push16(&mut glyph, 255); // instructionLength reaching past the data
3950        assert_eq!(strip_simple_glyph_instructions(&glyph, 1), None);
3951    }
3952
3953    // --- cmap lookup paths --------------------------------------------------
3954
3955    #[test]
3956    fn cmap4_truncated_segment_arrays_fall_back_to_uncached_lookup() {
3957        // Six declared segments; idRangeOffset[5] is cut off by the file end,
3958        // so the parse-time cache fails and lookups walk the raw arrays.
3959        // idRangeOffsets of segments 1/2 alias later idRangeOffset entries as
3960        // their glyphIdArray storage.
3961        let segs = [
3962            (0x5Au16, 0x41u16, 1u16.wrapping_sub(0x41), 0u16), // 'A'..'Z' -> 1..26
3963            (0x61, 0x61, 1, 6),                                // 'a' -> array at iro[4]
3964            (0x62, 0x62, 0, 2),                                // 'b' -> array at iro[3] (0)
3965            (0x63, 0x63, 0, 0),                                // 'c' -> delta path
3966            (0x64, 0x64, 0, 7),                                // 'd' -> array past EOF
3967            (0x00FF, 0x00F0, 0, 0),                            // its iro entry is cut off
3968        ];
3969        let tables = base_tables(30, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_table(&segs, &[]));
3970        let mut bytes = sfnt(0x0001_0000, &tables);
3971        bytes.truncate(bytes.len() - 2); // drop idRangeOffset[5]
3972        let font = Font::parse(bytes).expect("cmap payload is lazily read");
3973        assert!(font.cmap4_cache.is_none());
3974        assert_eq!(font.glyph_index('A'), 1);
3975        assert_eq!(font.glyph_index('Z'), 26);
3976        assert_eq!(font.glyph_index('@'), 0); // below the first segment start
3977        assert_eq!(font.glyph_index('a'), 8); // glyphIdArray 7 + idDelta 1
3978        assert_eq!(font.glyph_index('b'), 0); // glyphIdArray slot holds 0
3979        assert_eq!(font.glyph_index('c'), 99); // idDelta 0 -> the code itself
3980        assert_eq!(font.glyph_index('d'), 0); // glyphIdArray slot beyond EOF
3981        assert_eq!(font.glyph_index('õ'), 0); // idRangeOffset entry beyond EOF
3982        assert_eq!(font.glyph_index('Ā'), 0); // above every segment
3983        assert_eq!(font.glyph_index('😀'), 0); // beyond the BMP
3984    }
3985
3986    #[test]
3987    fn cmap4_cached_lookup_reads_glyph_id_array() {
3988        let segs = [(0x42u16, 0x41u16, 3u16, 4u16), (0xFFFF, 0xFFFF, 1, 0)];
3989        let mut array = Vec::new();
3990        push16(&mut array, 7);
3991        push16(&mut array, 0);
3992        let font = parse(&base_tables(
3993            20,
3994            1,
3995            1000,
3996            hmtx_long(&[(500, 0)]),
3997            cmap4_table(&segs, &array),
3998        ));
3999        let cache = font.cmap4_cache.as_ref().expect("valid table caches");
4000        assert!(cache.sorted_by_end);
4001        assert_eq!(font.glyph_index('A'), 10); // glyphIdArray 7 + idDelta 3
4002        assert_eq!(font.glyph_index('B'), 0); // glyphIdArray slot holds 0
4003        assert_eq!(font.glyph_index('C'), 0); // below the final segment's start
4004    }
4005
4006    #[test]
4007    fn cmap4_unsorted_segments_use_first_match_linear_scan() {
4008        let segs = [
4009            (0x61u16, 0x61u16, 2u16.wrapping_sub(0x61), 0u16),
4010            (0x5A, 0x41, 1u16.wrapping_sub(0x41), 0),
4011            (0xFFFF, 0xFFFF, 1, 0),
4012        ];
4013        let font = parse(&base_tables(
4014            30,
4015            1,
4016            1000,
4017            hmtx_long(&[(500, 0)]),
4018            cmap4_table(&segs, &[]),
4019        ));
4020        let cache = font
4021            .cmap4_cache
4022            .as_ref()
4023            .expect("caches even when unsorted");
4024        assert!(!cache.sorted_by_end);
4025        assert_eq!(font.glyph_index('a'), 2);
4026        // The linear scan takes the FIRST segment whose end covers the code,
4027        // so the out-of-order table shadows 'A' behind the 'a' segment.
4028        assert_eq!(font.glyph_index('A'), 0);
4029        assert_eq!(font.glyph_index('p'), 0);
4030    }
4031
4032    #[test]
4033    fn cmap4_unsorted_lookup_misses_when_no_segment_covers_the_code() {
4034        // Malformed table: no final 0xFFFF segment AND out-of-order ends, so
4035        // the cached linear scan can run off the end of the segment list.
4036        let segs = [
4037            (0x61u16, 0x61u16, 2u16.wrapping_sub(0x61), 0u16),
4038            (0x5A, 0x41, 1u16.wrapping_sub(0x41), 0),
4039        ];
4040        let font = parse(&base_tables(
4041            30,
4042            1,
4043            1000,
4044            hmtx_long(&[(500, 0)]),
4045            cmap4_table(&segs, &[]),
4046        ));
4047        assert!(!font.cmap4_cache.as_ref().expect("caches").sorted_by_end);
4048        assert_eq!(font.glyph_index('a'), 2);
4049        assert_eq!(font.glyph_index('p'), 0); // beyond every segment end
4050    }
4051
4052    #[test]
4053    fn select_cmap_accepts_format4_under_a_non_bmp_encoding_record() {
4054        // A (3,10) record pointing at a format-4 subtable ranks lowest but is
4055        // still selected when nothing better exists.
4056        let mut cmap = cmap4_simple(&[(0x41, 1)]);
4057        cmap[6..8].copy_from_slice(&10u16.to_be_bytes()); // encodingID 1 -> 10
4058        let font = parse(&base_tables(5, 1, 1000, hmtx_long(&[(500, 0)]), cmap));
4059        assert_eq!(font.cmap_format, 4);
4060        assert_eq!(font.glyph_index('A'), 1);
4061    }
4062
4063    #[test]
4064    fn cmap12_groups_map_across_planes_and_truncate_gids() {
4065        let cmap = cmap12_table(&[
4066            (0x41, 0x5A, 100),
4067            (0x2000, 0x2000, 0x0001_2345),
4068            (0x1F600, 0x1F601, 7),
4069        ]);
4070        let font = parse(&base_tables(200, 1, 1000, hmtx_long(&[(500, 0)]), cmap));
4071        assert_eq!(font.cmap_format, 12);
4072        assert!(font.cmap4_cache.is_none());
4073        assert_eq!(font.glyph_index('A'), 100);
4074        assert_eq!(font.glyph_index('Z'), 125);
4075        assert_eq!(font.glyph_index('\u{2000}'), 0x2345); // gid wraps to u16
4076        assert_eq!(font.glyph_index('😀'), 7);
4077        assert_eq!(font.glyph_index('😁'), 8);
4078        assert_eq!(font.glyph_index('0'), 0); // in no group
4079    }
4080
4081    // --- GPOS -----------------------------------------------------------
4082
4083    #[test]
4084    fn gpos_extension_lookup_resolves_wrapped_pair_kerning() {
4085        let kern = gpos_font(1, 2, 0, 1);
4086        assert_eq!(kern.pair(5, 6), -40);
4087        assert_eq!(kern.pair(5, 7), 0);
4088        assert_eq!(kern.pair(6, 6), 0);
4089    }
4090
4091    #[test]
4092    fn gpos_skips_foreign_extensions_bad_formats_and_lookup_indices() {
4093        // Extension wrapping a non-pair lookup type is ignored.
4094        assert_eq!(gpos_font(1, 5, 0, 1).pair(5, 6), 0);
4095        // Extension subtable with an unknown format fails to resolve.
4096        assert_eq!(gpos_font(2, 2, 0, 1).pair(5, 6), 0);
4097        // Wrapped pair subtable with an unknown posFormat parses to nothing.
4098        assert_eq!(gpos_font(1, 2, 0, 3).pair(5, 6), 0);
4099        // A kern feature pointing past the lookup list is skipped.
4100        assert_eq!(gpos_font(1, 2, 9, 1).pair(5, 6), 0);
4101    }
4102
4103    #[test]
4104    fn gpos_truncated_structures_yield_empty_kerning() {
4105        // Each end point cuts the table just before a field the walker needs:
4106        // 16 the feature offset, 20 the lookup-index count, 22 the index slot,
4107        // 26 the lookup offset slot, 28 the lookup type, 32 the subtable
4108        // count, 34 the subtable offset slot.
4109        for end in [16usize, 20, 22, 26, 28, 32, 34] {
4110            let mut g = gpos_table(1, 2, 0, 1);
4111            g.truncate(end);
4112            assert_eq!(gpos_kerning_of(g).pair(5, 6), 0, "end={end}");
4113        }
4114        // featureCount over-claim: trailing phantom records read garbage tags
4115        // until the walk falls off the table; the real record still applies.
4116        let mut over = gpos_table(1, 2, 0, 1);
4117        over[10..12].copy_from_slice(&12u16.to_be_bytes());
4118        assert_eq!(gpos_kerning_of(over).pair(5, 6), -40);
4119        // A direct non-pair, non-extension lookup type is ignored.
4120        let mut direct = gpos_table(1, 2, 0, 1);
4121        direct[28..30].copy_from_slice(&1u16.to_be_bytes());
4122        assert_eq!(gpos_kerning_of(direct).pair(5, 6), 0);
4123    }
4124
4125    #[test]
4126    fn resolve_extension_requires_format_1() {
4127        let mut d = Vec::new();
4128        push16(&mut d, 2);
4129        push16(&mut d, 2);
4130        push32(&mut d, 8);
4131        assert_eq!(resolve_extension(&d, 0), None);
4132        d[0..2].copy_from_slice(&1u16.to_be_bytes());
4133        assert_eq!(resolve_extension(&d, 0), Some((2, 8)));
4134        // Unknown pair-subtable formats are rejected outright.
4135        assert!(parse_pair_subtable(&[0, 3], 0).is_none());
4136    }
4137
4138    #[test]
4139    fn value_record_x_advance_field_extraction() {
4140        // No X_ADVANCE bit: defined as zero without touching the data.
4141        assert_eq!(value_record_x_advance(&[], 0, 0), Some(0));
4142        // X/Y placement precede xAdvance: skip 4 bytes.
4143        let rec = [0, 0, 0, 0, 0x12, 0x34];
4144        assert_eq!(value_record_x_advance(&rec, 0, 0x0007), Some(0x1234));
4145        // Truncated record with the bit set: undecodable.
4146        assert_eq!(value_record_x_advance(&[0], 0, 0x0004), None);
4147    }
4148
4149    #[test]
4150    fn coverage_and_class_def_malformed_and_boundary_variants() {
4151        // Coverage format-2 range with end < start contributes nothing.
4152        let mut cov = Vec::new();
4153        push16(&mut cov, 2);
4154        push16(&mut cov, 1);
4155        push16(&mut cov, 20); // start
4156        push16(&mut cov, 10); // end < start
4157        push16(&mut cov, 0);
4158        assert_eq!(parse_coverage_glyphs(&cov, 0), Some(Vec::new()));
4159        // Coverage format 3 does not exist.
4160        assert_eq!(parse_coverage_glyphs(&[0, 3, 0, 0], 0), None);
4161        // ClassDef format 3 does not exist.
4162        assert!(parse_class_def(&[0, 3, 0, 0], 0).is_none());
4163
4164        // Format-1 class array: in-range indices map, everything else class 0.
4165        let mut cd = Vec::new();
4166        push16(&mut cd, 1);
4167        push16(&mut cd, 5); // startGlyphID
4168        push16(&mut cd, 2); // glyphCount
4169        push16(&mut cd, 7);
4170        push16(&mut cd, 9);
4171        let cd1 = parse_class_def(&cd, 0).expect("format 1 parses");
4172        assert_eq!(cd1.class(5), 7);
4173        assert_eq!(cd1.class(6), 9);
4174        assert_eq!(cd1.class(7), 0); // past the array
4175        assert_eq!(cd1.class(4), 0); // before startGlyphID
4176
4177        // Format-2 ranges: covered ranges map, gaps are class 0.
4178        let mut cd2b = Vec::new();
4179        push16(&mut cd2b, 2);
4180        push16(&mut cd2b, 1);
4181        push16(&mut cd2b, 10);
4182        push16(&mut cd2b, 20);
4183        push16(&mut cd2b, 3);
4184        let cd2 = parse_class_def(&cd2b, 0).expect("format 2 parses");
4185        assert_eq!(cd2.class(15), 3);
4186        assert_eq!(cd2.class(9), 0);
4187        assert_eq!(cd2.class(21), 0);
4188    }
4189
4190    #[test]
4191    fn kern_subtable_format2_guards_class_ranges_and_empty_matrix() {
4192        let st = KernSubtable::Format2 {
4193            coverage: vec![5, 9],
4194            class1: ClassDef::Format1 {
4195                start: 5,
4196                classes: vec![1, 0, 0, 0, 9],
4197            },
4198            class2: ClassDef::Format1 {
4199                start: 6,
4200                classes: vec![1, 7],
4201            },
4202            class1_count: 2,
4203            class2_count: 2,
4204            matrix: vec![0, 0, 0, -55],
4205        };
4206        assert_eq!(st.lookup(4, 6), None); // left glyph not covered
4207        assert_eq!(st.lookup(5, 6), Some(-55)); // classes (1, 1) -> cell 3
4208        assert_eq!(st.lookup(9, 6), Some(0)); // class1 out of declared range
4209        assert_eq!(st.lookup(5, 7), Some(0)); // class2 out of declared range
4210
4211        let empty = KernSubtable::Format2 {
4212            coverage: vec![5],
4213            class1: ClassDef::Format2 {
4214                ranges: Vec::new(),
4215                dense: true,
4216            },
4217            class2: ClassDef::Format2 {
4218                ranges: Vec::new(),
4219                dense: true,
4220            },
4221            class1_count: 1,
4222            class2_count: 1,
4223            matrix: Vec::new(),
4224        };
4225        assert_eq!(empty.lookup(5, 6), Some(0));
4226
4227        // A covered-but-zero first subtable still wins over later subtables.
4228        let kerning = Kerning {
4229            subtables: vec![empty, st],
4230        };
4231        assert_eq!(kerning.pair(5, 6), 0);
4232        assert_eq!(kerning.pair(4, 6), 0);
4233    }
4234
4235    #[test]
4236    fn for_each_ascii_pair_matches_brute_force_pair_on_bundled_faces() {
4237        // Every bundled face that ships GPOS kerning — IBM Plex Sans carries
4238        // the richest tables — must produce, via enumeration, exactly the
4239        // matrix that probing all 16,384 ASCII byte pairs through `pair`
4240        // produces. Also checks the "nonzero cells only" emission contract.
4241        let base = env!("CARGO_MANIFEST_DIR");
4242        let mut any_kerning_face = false;
4243        for path in [
4244            "/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
4245            "/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf",
4246            "/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf",
4247            "/fonts/computer-modern/cmunrm.ttf",
4248            "/fonts/computer-modern/cmuntt.ttf",
4249            "/fonts/noto-sans-math/NotoSansMathSymbols.ttf",
4250        ] {
4251            let Ok(bytes) = std::fs::read(format!("{base}{path}")) else {
4252                continue;
4253            };
4254            let Ok(font) = Font::parse(bytes) else {
4255                continue;
4256            };
4257            let kern = font.gpos_kerning();
4258            let glyphs: [u16; 128] = std::array::from_fn(|b| font.glyph_index(b as u8 as char));
4259            let nonzero_pairs = kern
4260                .subtables
4261                .iter()
4262                .map(|st| match st {
4263                    KernSubtable::Format1 { pairs } => pairs.len(),
4264                    KernSubtable::Format2 { coverage, .. } => coverage.len() * 128,
4265                })
4266                .sum::<usize>();
4267            if nonzero_pairs == 0 {
4268                continue;
4269            }
4270            any_kerning_face = true;
4271
4272            let mut enumerated = [0i16; 128 * 128];
4273            let mut emitted = 0usize;
4274            kern.for_each_ascii_pair(
4275                |b| glyphs[usize::from(b)],
4276                |l, r, v| {
4277                    enumerated[usize::from(l) * 128 + usize::from(r)] = v;
4278                    emitted += 1;
4279                },
4280            );
4281
4282            let mut brute = [0i16; 128 * 128];
4283            for l in 0..128usize {
4284                for r in 0..128usize {
4285                    brute[l * 128 + r] = kern.pair(glyphs[l], glyphs[r]);
4286                }
4287            }
4288            assert_eq!(enumerated, brute, "enumeration != brute force for {path}");
4289            assert_eq!(
4290                emitted,
4291                brute.iter().filter(|&&v| v != 0).count(),
4292                "emission count != nonzero cells for {path}"
4293            );
4294        }
4295        assert!(
4296            any_kerning_face,
4297            "test is vacuous: no bundled face has GPOS kerning"
4298        );
4299    }
4300
4301    #[test]
4302    fn for_each_ascii_pair_first_match_duplicate_glyphs_and_zero_shadowing() {
4303        // glyph_of: bytes 10..=12 -> gids 5..=7, bytes 13 and 14 both -> gid 9
4304        // (duplicate mapping), everything else -> its own gid.
4305        let glyph_of = |b: u8| -> u16 {
4306            match b {
4307                10..=12 => u16::from(b) - 5,
4308                13 | 14 => 9,
4309                other => u16::from(other),
4310            }
4311        };
4312
4313        let covered_all_rights = KernSubtable::Format2 {
4314            coverage: vec![5],
4315            class1: ClassDef::Format1 {
4316                start: 5,
4317                classes: vec![1],
4318            },
4319            class2: ClassDef::Format1 {
4320                start: 6,
4321                classes: vec![1, 1],
4322            },
4323            class1_count: 2,
4324            class2_count: 2,
4325            // (c1=1, c2=1) -> -25 for rights of class 1 (gids 6, 7);
4326            // (c1=1, c2=0) -> 30 for rights of class 0 (incl. gid 9).
4327            matrix: vec![0, 0, 30, -25],
4328        };
4329        let specific = KernSubtable::Format1 {
4330            pairs: PairMap::from_iter([(pair_key(5, 6), -99), (pair_key(9, 9), -12)]),
4331        };
4332        let out_of_range_class_zero = KernSubtable::Format2 {
4333            coverage: vec![9],
4334            class1: ClassDef::Format1 {
4335                start: 9,
4336                classes: vec![9], // class 9 >= class1_count 2 -> defined 0
4337            },
4338            class2: ClassDef::Format2 {
4339                ranges: Vec::new(),
4340                dense: true,
4341            },
4342            class1_count: 2,
4343            class2_count: 2,
4344            matrix: vec![0; 4],
4345        };
4346        let defined_zero = KernSubtable::Format1 {
4347            pairs: PairMap::from_iter([(pair_key(6, 6), 0)]),
4348        };
4349        let shadowed = KernSubtable::Format1 {
4350            pairs: PairMap::from_iter([(pair_key(6, 6), -77)]),
4351        };
4352
4353        let kerning = Kerning {
4354            subtables: vec![
4355                covered_all_rights,
4356                specific,
4357                out_of_range_class_zero,
4358                defined_zero,
4359                shadowed,
4360            ],
4361        };
4362
4363        let mut enumerated = [0i16; 128 * 128];
4364        kerning.for_each_ascii_pair(glyph_of, |l, r, v| {
4365            enumerated[usize::from(l) * 128 + usize::from(r)] = v;
4366        });
4367        let mut brute = [0i16; 128 * 128];
4368        for l in 0..128usize {
4369            for r in 0..128usize {
4370                brute[l * 128 + r] = kerning.pair(glyph_of(l as u8), glyph_of(r as u8));
4371            }
4372        }
4373        assert_eq!(enumerated, brute);
4374
4375        // Spot-check the first-match story: the format-2 subtable wins over
4376        // the format-1 (5,6) pair; both duplicate bytes carry (9,9); the
4377        // defined zero on (6,6) shadows the later -77; rights outside every
4378        // class stay 0.
4379        let cell = |l: u8, r: u8| enumerated[usize::from(l) * 128 + usize::from(r)];
4380        assert_eq!(cell(10, 11), -25); // gids (5,6): format 2 beats -99
4381        assert_eq!(cell(10, 12), -25); // gids (5,7)
4382        assert_eq!(cell(10, 13), 30); // gids (5,9): class-0 right
4383        assert_eq!(cell(13, 13), -12); // gids (9,9): format 1 beats the zero
4384        assert_eq!(cell(13, 14), -12);
4385        assert_eq!(cell(14, 13), -12);
4386        assert_eq!(cell(14, 14), -12);
4387        assert_eq!(cell(11, 11), 0); // gids (6,6): defined 0 shadows -77
4388        assert_eq!(cell(13, 11), 0); // gids (9,6): out-of-range class -> 0
4389
4390        // An empty kerning enumerates nothing.
4391        let mut calls = 0;
4392        Kerning::default().for_each_ascii_pair(glyph_of, |_, _, _| calls += 1);
4393        assert_eq!(calls, 0);
4394    }
4395
4396    #[test]
4397    fn pair_format1_skips_malformed_sets_and_truncated_records() {
4398        // pairSetCount 2 but the coverage names one glyph; the second set is
4399        // skipped while the first still yields (5, 6) -> -40.
4400        let mut d = Vec::new();
4401        push16(&mut d, 1); // posFormat
4402        push16(&mut d, 20); // coverage @20
4403        push16(&mut d, 0x0004); // valueFormat1
4404        push16(&mut d, 0); // valueFormat2
4405        push16(&mut d, 2); // pairSetCount
4406        push16(&mut d, 14); // pairSet[0] @14
4407        push16(&mut d, 14); // pairSet[1] (no coverage glyph -> skipped)
4408        push16(&mut d, 1); // pairValueCount
4409        push16(&mut d, 6); // secondGlyph
4410        push_i16(&mut d, -40);
4411        push16(&mut d, 1); // coverage format
4412        push16(&mut d, 1);
4413        push16(&mut d, 5);
4414        let st = parse_pair_subtable(&d, 0).expect("format 1 parses");
4415        assert_eq!(st.lookup(5, 6), Some(-40));
4416        assert_eq!(st.lookup(5, 7), None);
4417
4418        // A pair-set offset pointing past the data contributes nothing.
4419        let mut d2 = Vec::new();
4420        push16(&mut d2, 1);
4421        push16(&mut d2, 12); // coverage @12
4422        push16(&mut d2, 0x0004);
4423        push16(&mut d2, 0);
4424        push16(&mut d2, 1);
4425        push16(&mut d2, 0x4000); // pairSet[0]: far past the end
4426        push16(&mut d2, 1);
4427        push16(&mut d2, 1);
4428        push16(&mut d2, 5);
4429        let st2 = parse_pair_subtable(&d2, 0).expect("parses to an empty set");
4430        assert_eq!(st2.lookup(5, 6), None);
4431
4432        // pairValueCount claims 2 records but the data ends after the first.
4433        let mut d3 = Vec::new();
4434        push16(&mut d3, 1);
4435        push16(&mut d3, 12); // coverage @12
4436        push16(&mut d3, 0x0004);
4437        push16(&mut d3, 0);
4438        push16(&mut d3, 1);
4439        push16(&mut d3, 18); // pairSet @18
4440        push16(&mut d3, 1); // coverage format
4441        push16(&mut d3, 1);
4442        push16(&mut d3, 5);
4443        push16(&mut d3, 2); // pairValueCount (overlong)
4444        push16(&mut d3, 6);
4445        push_i16(&mut d3, -40);
4446        let st3 = parse_pair_subtable(&d3, 0).expect("parses the readable record");
4447        assert_eq!(st3.lookup(5, 6), Some(-40));
4448        assert_eq!(st3.lookup(5, 0), None);
4449    }
4450
4451    #[test]
4452    fn pair_format1_work_cap_stops_aliased_pair_set_expansion() {
4453        // Two pair sets alias one huge set. With 65 535 records the ceiling
4454        // trips between the sets; with 65 534 it trips inside the second one.
4455        for count in [65_535u16, 65_534] {
4456            let mut d = Vec::new();
4457            push16(&mut d, 1); // posFormat
4458            push16(&mut d, 14); // coverage @14
4459            push16(&mut d, 0); // valueFormat1: empty records
4460            push16(&mut d, 0); // valueFormat2
4461            push16(&mut d, 2); // pairSetCount
4462            push16(&mut d, 22); // pairSet[0] @22
4463            push16(&mut d, 22); // pairSet[1]: aliases the same set
4464            push16(&mut d, 1); // coverage format
4465            push16(&mut d, 2);
4466            push16(&mut d, 5);
4467            push16(&mut d, 6);
4468            push16(&mut d, count); // pairValueCount
4469            d.resize(d.len() + usize::from(count) * 2, 0); // secondGlyph = 0 each
4470            let st = parse_pair_subtable(&d, 0).expect("parses under the work cap");
4471            // The first set registers (5, 0); the ceiling stops the aliased
4472            // second set before it can register (6, 0).
4473            assert_eq!(st.lookup(5, 0), Some(0), "count={count}");
4474            assert_eq!(st.lookup(6, 0), None, "count={count}");
4475        }
4476    }
4477
4478    #[test]
4479    fn pair_format2_empty_value_formats_and_oversized_matrix() {
4480        // Both value formats empty: the matrix is elided and every covered
4481        // pair resolves to zero.
4482        let mut d = Vec::new();
4483        push16(&mut d, 2); // posFormat
4484        push16(&mut d, 16); // coverage @16
4485        push16(&mut d, 0); // valueFormat1
4486        push16(&mut d, 0); // valueFormat2
4487        push16(&mut d, 22); // classDef1 @22
4488        push16(&mut d, 22); // classDef2 @22 (shared)
4489        push16(&mut d, 1); // class1Count
4490        push16(&mut d, 1); // class2Count
4491        push16(&mut d, 1); // coverage format
4492        push16(&mut d, 1);
4493        push16(&mut d, 3);
4494        push16(&mut d, 1); // classdef format 1, empty array
4495        push16(&mut d, 0);
4496        push16(&mut d, 0);
4497        let st = parse_pair_subtable(&d, 0).expect("empty-value format 2 parses");
4498        assert!(matches!(
4499            &st,
4500            KernSubtable::Format2 { matrix, .. } if matrix.is_empty()
4501        ));
4502        assert_eq!(st.lookup(3, 42), Some(0));
4503        assert_eq!(st.lookup(4, 42), None);
4504
4505        // A declared matrix larger than the whole table is rejected.
4506        let mut big = Vec::new();
4507        push16(&mut big, 2);
4508        push16(&mut big, 16);
4509        push16(&mut big, 0x0004);
4510        push16(&mut big, 0);
4511        push16(&mut big, 22);
4512        push16(&mut big, 22);
4513        push16(&mut big, 0xFFFF);
4514        push16(&mut big, 0xFFFF);
4515        assert!(parse_pair_subtable(&big, 0).is_none());
4516    }
4517
4518    // --- GSUB -----------------------------------------------------------
4519
4520    #[test]
4521    fn gsub_extension_lookup_parses_greedy_ligatures() {
4522        let ligs = gsub_font(1, 4, 0);
4523        assert!(!ligs.is_empty());
4524        assert!(Ligatures::default().is_empty());
4525        assert_eq!(ligs.substitute(&[10, 11, 12]), vec![99]);
4526        assert_eq!(ligs.substitute(&[10, 11, 7]), vec![77, 7]);
4527        assert_eq!(ligs.substitute(&[10, 7]), vec![10, 7]);
4528        assert_eq!(
4529            ligs.substitute_with_spans(&[10, 11, 12, 10, 11]),
4530            vec![(99, 3), (77, 2)]
4531        );
4532    }
4533
4534    #[test]
4535    fn substitute_with_spans_into_matches_allocating_variant() {
4536        let ligs = gsub_font(1, 4, 0);
4537        let corpora: [&[u16]; 7] = [
4538            &[],
4539            &[10, 11, 12, 10, 11],
4540            &[10, 11, 7],
4541            &[10, 7],
4542            &[99, 99, 99],
4543            &[10, 11, 12, 10, 11, 12, 10, 11],
4544            &[7, 8, 9, 10],
4545        ];
4546        // Start from a dirty buffer: the into-scratch variant must clear it,
4547        // and reuse across back-to-back calls must keep matching.
4548        let mut scratch = vec![(u16::MAX, usize::MAX); 4];
4549        for gids in corpora {
4550            let mut into = Vec::new();
4551            ligs.substitute_with_spans_into(gids, &mut into);
4552            assert_eq!(into, ligs.substitute_with_spans(gids));
4553            ligs.substitute_with_spans_into(gids, &mut scratch);
4554            assert_eq!(scratch, into);
4555        }
4556    }
4557
4558    #[test]
4559    fn max_rule_len_reports_longest_rule() {
4560        // gsub_table registers 10 + [11, 12] -> 99 (3 glyphs) and
4561        // 10 + [11] -> 77 (2 glyphs), so the window is 3.
4562        assert_eq!(gsub_font(1, 4, 0).max_rule_len(), 3);
4563        // No rules: the window degenerates to 1 (every decision final).
4564        assert_eq!(Ligatures::default().max_rule_len(), 1);
4565    }
4566
4567    #[test]
4568    fn gsub_skips_foreign_extensions_and_bad_lookup_indices() {
4569        // Extension wrapping a non-ligature lookup type is ignored.
4570        assert!(gsub_font(1, 2, 0).is_empty());
4571        // Extension subtable with an unknown format fails to resolve.
4572        assert!(gsub_font(2, 4, 0).is_empty());
4573        // A liga feature pointing past the lookup list is skipped.
4574        assert!(gsub_font(1, 4, 9).is_empty());
4575    }
4576
4577    #[test]
4578    fn gsub_truncated_structures_yield_no_ligatures() {
4579        // Same cut points as the GPOS walker: the two table layouts share
4580        // their header/feature/lookup shape.
4581        for end in [16usize, 20, 22, 26, 28, 32, 34] {
4582            let mut g = gsub_table(1, 4, 0);
4583            g.truncate(end);
4584            assert!(gsub_ligatures_of(g).is_empty(), "end={end}");
4585        }
4586        // featureCount over-claim: phantom records break the walk after the
4587        // real record already registered its ligatures.
4588        let mut over = gsub_table(1, 4, 0);
4589        over[10..12].copy_from_slice(&12u16.to_be_bytes());
4590        assert_eq!(gsub_ligatures_of(over).substitute(&[10, 11]), vec![77]);
4591        // A direct non-ligature, non-extension lookup type is ignored.
4592        let mut direct = gsub_table(1, 4, 0);
4593        direct[28..30].copy_from_slice(&1u16.to_be_bytes());
4594        assert!(gsub_ligatures_of(direct).is_empty());
4595    }
4596
4597    #[test]
4598    fn ligature_subst_skips_malformed_entries() {
4599        let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
4600            std::collections::BTreeMap::new();
4601
4602        // Unknown subtable format: ignored outright.
4603        parse_ligature_subst(&[0, 2, 0, 0], 0, &mut rules);
4604        assert!(rules.is_empty());
4605
4606        // Header reads running off the end return without any rules.
4607        parse_ligature_subst(&[], 0, &mut rules); // no format
4608        parse_ligature_subst(&[0, 1], 0, &mut rules); // no coverage offset
4609        parse_ligature_subst(&[0, 1, 0, 8], 0, &mut rules); // no ligSetCount
4610        parse_ligature_subst(&[0, 1, 0, 6, 0, 1, 0, 3], 0, &mut rules); // coverage fmt 3
4611        assert!(rules.is_empty());
4612
4613        // LigatureSet offset far past the data: no rules.
4614        let mut d = Vec::new();
4615        push16(&mut d, 1); // substFormat
4616        push16(&mut d, 8); // coverage @8
4617        push16(&mut d, 1); // ligSetCount
4618        push16(&mut d, 0x4000); // ligatureSet: far past the end
4619        push16(&mut d, 1); // coverage format
4620        push16(&mut d, 1);
4621        push16(&mut d, 10);
4622        parse_ligature_subst(&d, 0, &mut rules);
4623        assert!(rules.is_empty());
4624
4625        // ligSetCount 2 with a single-glyph coverage: the second set has no
4626        // coverage glyph, the first still parses (10, 11) -> 77.
4627        let mut d2 = Vec::new();
4628        push16(&mut d2, 1); // substFormat
4629        push16(&mut d2, 20); // coverage @20
4630        push16(&mut d2, 2); // ligSetCount
4631        push16(&mut d2, 10); // set[0] @10
4632        push16(&mut d2, 10); // set[1] (never reached)
4633        push16(&mut d2, 1); // ligatureCount
4634        push16(&mut d2, 4); // ligature @14
4635        push16(&mut d2, 77); // ligatureGlyph
4636        push16(&mut d2, 2); // componentCount
4637        push16(&mut d2, 11); // component[1]
4638        push16(&mut d2, 1); // coverage format
4639        push16(&mut d2, 1);
4640        push16(&mut d2, 10);
4641        parse_ligature_subst(&d2, 0, &mut rules);
4642        assert_eq!(rules.len(), 1);
4643        assert_eq!(rules[&10].len(), 1);
4644        assert_eq!(rules[&10][0].components, vec![11]);
4645        assert_eq!(rules[&10][0].ligature, 77);
4646
4647        // Zero component count, comp-count/glyph reads past the end, and a
4648        // truncated component array: each entry drops without a rule.
4649        rules.clear();
4650        let mut d3 = Vec::new();
4651        push16(&mut d3, 1); // substFormat
4652        push16(&mut d3, 8); // coverage @8
4653        push16(&mut d3, 1); // ligSetCount
4654        push16(&mut d3, 14); // ligatureSet @14
4655        push16(&mut d3, 1); // coverage format
4656        push16(&mut d3, 1);
4657        push16(&mut d3, 10);
4658        push16(&mut d3, 4); // ligatureCount
4659        push16(&mut d3, 10); // @24: zero componentCount
4660        push16(&mut d3, 20); // @34: componentCount past the end
4661        push16(&mut d3, 0x4000); // unreadable ligature glyph
4662        push16(&mut d3, 14); // @28: component array past the end
4663        push16(&mut d3, 33); // ligature @24
4664        push16(&mut d3, 0); // componentCount 0
4665        push16(&mut d3, 88); // ligature @28
4666        push16(&mut d3, 5); // componentCount 5, components cut off
4667        push16(&mut d3, 11);
4668        push16(&mut d3, 12);
4669        assert_eq!(d3.len(), 36);
4670        parse_ligature_subst(&d3, 0, &mut rules);
4671        assert!(rules.is_empty());
4672    }
4673
4674    #[test]
4675    fn ligature_subst_work_cap_stops_aliased_sets() {
4676        // Two ligature sets alias one set whose declared count is huge and
4677        // whose offset array is entirely missing. With 65 535 the ceiling
4678        // trips between the sets; with 65 534 inside the second one.
4679        for lig_count in [65_535u16, 65_534] {
4680            let mut d = Vec::new();
4681            push16(&mut d, 1); // substFormat
4682            push16(&mut d, 10); // coverage @10
4683            push16(&mut d, 2); // ligSetCount
4684            push16(&mut d, 18); // set[0] @18
4685            push16(&mut d, 18); // set[1]: aliases set[0]
4686            push16(&mut d, 1); // coverage format
4687            push16(&mut d, 2);
4688            push16(&mut d, 10);
4689            push16(&mut d, 11);
4690            push16(&mut d, lig_count); // every ligature offset is unreadable
4691            let mut rules: std::collections::BTreeMap<u16, Vec<LigRule>> =
4692                std::collections::BTreeMap::new();
4693            parse_ligature_subst(&d, 0, &mut rules);
4694            assert!(rules.is_empty(), "lig_count={lig_count}");
4695        }
4696    }
4697
4698    // --- fvar / avar --------------------------------------------------------
4699
4700    fn f32_to_fixed(v: f32) -> i32 {
4701        (f64::from(v) * 65536.0).round() as i32
4702    }
4703
4704    fn f32_to_f2dot14(v: f32) -> i16 {
4705        (v * 16384.0).round() as i16
4706    }
4707
4708    fn push_i32(out: &mut Vec<u8>, v: i32) {
4709        out.extend_from_slice(&v.to_be_bytes());
4710    }
4711
4712    /// `fvar` with `axes` of `(tag, min, default, max, name_id)` and named
4713    /// instances of `(subfamily_name_id, coords_in_user_space)`.
4714    fn fvar_table(
4715        axes: &[(&[u8; 4], f32, f32, f32, u16)],
4716        instances: &[(u16, &[f32])],
4717        with_ps_name: bool,
4718    ) -> Vec<u8> {
4719        let axis_count = u16::try_from(axes.len()).unwrap();
4720        let instance_count = u16::try_from(instances.len()).unwrap();
4721        let instance_size = 4 + 4 * axis_count + u16::from(with_ps_name) * 2;
4722        let mut t = Vec::new();
4723        push16(&mut t, 1); // major
4724        push16(&mut t, 0); // minor
4725        push16(&mut t, 16); // axesArrayOffset
4726        push16(&mut t, 0); // reserved
4727        push16(&mut t, axis_count);
4728        push16(&mut t, 20); // axisSize
4729        push16(&mut t, instance_count);
4730        push16(&mut t, instance_size);
4731        for &(tag, min, default, max, name_id) in axes {
4732            t.extend_from_slice(&tag[..]);
4733            push_i32(&mut t, f32_to_fixed(min));
4734            push_i32(&mut t, f32_to_fixed(default));
4735            push_i32(&mut t, f32_to_fixed(max));
4736            push16(&mut t, 0); // flags
4737            push16(&mut t, name_id);
4738        }
4739        for &(name_id, coords) in instances {
4740            push16(&mut t, name_id);
4741            push16(&mut t, 0); // flags
4742            for &c in coords {
4743                push_i32(&mut t, f32_to_fixed(c));
4744            }
4745            if with_ps_name {
4746                push16(&mut t, name_id.saturating_add(1000));
4747            }
4748        }
4749        t
4750    }
4751
4752    /// Identity or custom `avar` maps, one `&[(from, to)]` per axis.
4753    fn avar_table(maps: &[&[(f32, f32)]]) -> Vec<u8> {
4754        let mut t = Vec::new();
4755        push16(&mut t, 1); // major
4756        push16(&mut t, 0); // minor
4757        push16(&mut t, 0); // reserved
4758        push16(&mut t, u16::try_from(maps.len()).unwrap());
4759        for axis in maps {
4760            push16(&mut t, u16::try_from(axis.len()).unwrap());
4761            for &(from, to) in *axis {
4762                push_i16(&mut t, f32_to_f2dot14(from));
4763                push_i16(&mut t, f32_to_f2dot14(to));
4764            }
4765        }
4766        t
4767    }
4768
4769    fn variation_font(fvar: Vec<u8>, avar: Option<Vec<u8>>) -> Font {
4770        let mut tables = base_tables(1, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_simple(&[]));
4771        tables.push((b"fvar", fvar));
4772        if let Some(avar) = avar {
4773            tables.push((b"avar", avar));
4774        }
4775        parse(&tables)
4776    }
4777
4778    /// Project-authored OFL test face: one `wght` axis 100..400..900, Regular
4779    /// (400) and Bold (700) named instances, identity `avar`.
4780    fn fmd_test_vf_bytes() -> Vec<u8> {
4781        let fvar = fvar_table(
4782            &[(b"wght", 100.0, 400.0, 900.0, 256)],
4783            &[(258, &[400.0]), (259, &[700.0])],
4784            true,
4785        );
4786        let avar = avar_table(&[&[(-1.0, -1.0), (0.0, 0.0), (1.0, 1.0)]]);
4787        let mut tables = base_tables(1, 1, 1000, hmtx_long(&[(500, 0)]), cmap4_simple(&[]));
4788        tables.push((b"fvar", fvar));
4789        tables.push((b"avar", avar));
4790        sfnt(0x0001_0000, &tables)
4791    }
4792
4793    fn log_check(id: &str, subject: &str, ok: bool) {
4794        eprintln!(
4795            "check id={id} subject={subject} outcome={}",
4796            if ok { "PASS" } else { "FAIL" }
4797        );
4798        assert!(ok, "{id}: {subject}");
4799    }
4800
4801    #[test]
4802    fn fvar_axes_tags_and_named_instances() {
4803        let font = variation_font(
4804            fvar_table(
4805                &[
4806                    (b"wght", 100.0, 400.0, 900.0, 256),
4807                    (b"wdth", 75.0, 100.0, 125.0, 257),
4808                ],
4809                &[(258, &[400.0, 100.0]), (259, &[700.0, 100.0])],
4810                true,
4811            ),
4812            None,
4813        );
4814        let axes = font.axes();
4815        log_check("gk3v.1.axes.count", "two axes", axes.len() == 2);
4816        log_check(
4817            "gk3v.1.axes.wght",
4818            "first tag wght",
4819            axes[0].tag == *b"wght",
4820        );
4821        log_check(
4822            "gk3v.1.axes.wdth",
4823            "second tag wdth",
4824            axes[1].tag == *b"wdth",
4825        );
4826        let wght = font.instance_bounds(*b"wght").expect("wght present");
4827        log_check(
4828            "gk3v.1.bounds.wght",
4829            "wght 100/400/900",
4830            (wght.min - 100.0).abs() < 1e-4
4831                && (wght.default - 400.0).abs() < 1e-4
4832                && (wght.max - 900.0).abs() < 1e-4,
4833        );
4834        log_check(
4835            "gk3v.1.bounds.missing",
4836            "unknown tag is None",
4837            font.instance_bounds(*b"opsz").is_none(),
4838        );
4839        let inst = font.named_instances();
4840        log_check("gk3v.1.inst.count", "two named instances", inst.len() == 2);
4841        log_check(
4842            "gk3v.1.inst.regular",
4843            "Regular at wght=400",
4844            inst[0].subfamily_name_id == 258
4845                && (inst[0].coordinates[0] - 400.0).abs() < 1e-4
4846                && inst[0].postscript_name_id == Some(1258),
4847        );
4848        log_check(
4849            "gk3v.1.inst.bold",
4850            "Bold at wght=700",
4851            inst[1].subfamily_name_id == 259 && (inst[1].coordinates[0] - 700.0).abs() < 1e-4,
4852        );
4853        log_check(
4854            "gk3v.1.static",
4855            "static face has no axes",
4856            parse(&base_tables(
4857                1,
4858                1,
4859                1000,
4860                hmtx_long(&[(500, 0)]),
4861                cmap4_simple(&[]),
4862            ))
4863            .axes()
4864            .is_empty(),
4865        );
4866    }
4867
4868    #[test]
4869    fn avar_clamp_edges_map_to_endpoints() {
4870        // Compress the positive side: 0.5 → 0.25, endpoints stay ±1.
4871        let font = variation_font(
4872            fvar_table(&[(b"wght", 100.0, 400.0, 900.0, 256)], &[], false),
4873            Some(avar_table(&[&[
4874                (-1.0, -1.0),
4875                (0.0, 0.0),
4876                (0.5, 0.25),
4877                (1.0, 1.0),
4878            ]])),
4879        );
4880        let below = font.normalized_axis(*b"wght", 0.0);
4881        let at_min = font.normalized_axis(*b"wght", 100.0);
4882        let at_def = font.normalized_axis(*b"wght", 400.0);
4883        let at_max = font.normalized_axis(*b"wght", 900.0);
4884        let above = font.normalized_axis(*b"wght", 2000.0);
4885        log_check(
4886            "gk3v.1.avar.below",
4887            "below-min → -1",
4888            below.is_some_and(|v| (v + 1.0).abs() < 1e-4),
4889        );
4890        log_check(
4891            "gk3v.1.avar.min",
4892            "min → -1",
4893            at_min.is_some_and(|v| (v + 1.0).abs() < 1e-4),
4894        );
4895        log_check(
4896            "gk3v.1.avar.default",
4897            "default → 0",
4898            at_def.is_some_and(|v| v.abs() < 1e-4),
4899        );
4900        log_check(
4901            "gk3v.1.avar.max",
4902            "max → +1",
4903            at_max.is_some_and(|v| (v - 1.0).abs() < 1e-4),
4904        );
4905        log_check(
4906            "gk3v.1.avar.above",
4907            "above-max → +1",
4908            above.is_some_and(|v| (v - 1.0).abs() < 1e-4),
4909        );
4910        // Mid-positive user 650 is halfway 400→900 → 0.5, avar compresses to 0.25.
4911        let mid = font.normalized_axis(*b"wght", 650.0);
4912        log_check(
4913            "gk3v.1.avar.mid",
4914            "650 → avar(0.5)=0.25",
4915            mid.is_some_and(|v| (v - 0.25).abs() < 1e-3),
4916        );
4917    }
4918
4919    #[test]
4920    fn fvar_avar_truncation_and_hostile_headers() {
4921        let good = fvar_table(&[(b"wght", 100.0, 400.0, 900.0, 256)], &[], false);
4922        let font = variation_font(good[..10].to_vec(), None);
4923        log_check(
4924            "gk3v.1.trunc.header",
4925            "truncated fvar header → no axes",
4926            font.axes().is_empty(),
4927        );
4928
4929        let mut bad_ver = good.clone();
4930        bad_ver[0..2].copy_from_slice(&2u16.to_be_bytes());
4931        log_check(
4932            "gk3v.1.trunc.version",
4933            "fvar major!=1 → no axes",
4934            variation_font(bad_ver, None).axes().is_empty(),
4935        );
4936
4937        let mut tiny_axis = good.clone();
4938        tiny_axis[10..12].copy_from_slice(&8u16.to_be_bytes());
4939        log_check(
4940            "gk3v.1.trunc.axisSize",
4941            "axisSize < 20 → no axes",
4942            variation_font(tiny_axis, None).axes().is_empty(),
4943        );
4944
4945        // avar axisCount mismatch is ignored; fvar still parses.
4946        let mismatched = variation_font(
4947            fvar_table(&[(b"wght", 100.0, 400.0, 900.0, 256)], &[], false),
4948            Some(avar_table(&[
4949                &[(-1.0, -1.0), (1.0, 1.0)],
4950                &[(-1.0, -1.0), (1.0, 1.0)],
4951            ])),
4952        );
4953        log_check(
4954            "gk3v.1.avar.mismatch",
4955            "avar axisCount mismatch → identity",
4956            mismatched.axes().len() == 1
4957                && mismatched
4958                    .normalized_axis(*b"wght", 100.0)
4959                    .is_some_and(|v| (v + 1.0).abs() < 1e-4),
4960        );
4961    }
4962
4963    #[test]
4964    fn fvar_avar_lcg_mutation_never_panics() {
4965        let base = fmd_test_vf_bytes();
4966        let mut state = 0xC0FF_EE00u64;
4967        let mut lcg = move || {
4968            state = state
4969                .wrapping_mul(6_364_136_223_846_793_005)
4970                .wrapping_add(1);
4971            (state >> 33) as usize
4972        };
4973        for round in 0..128 {
4974            let mut mutated = base.clone();
4975            for _ in 0..8 {
4976                let pos = lcg() % mutated.len();
4977                let bit = 1u8 << (lcg() % 8);
4978                mutated[pos] ^= bit;
4979            }
4980            let outcome = std::panic::catch_unwind(move || {
4981                if let Ok(font) = Font::parse(mutated) {
4982                    let _ = font.axes();
4983                    let _ = font.named_instances();
4984                    let _ = font.instance_bounds(*b"wght");
4985                    let _ = font.normalized_axis(*b"wght", 0.0);
4986                    let _ = font.normalized_axis(*b"wght", 400.0);
4987                    let _ = font.normalized_axis(*b"wght", 9999.0);
4988                }
4989            });
4990            log_check(
4991                "gk3v.1.lcg",
4992                &format!("round {round} no panic"),
4993                outcome.is_ok(),
4994            );
4995        }
4996        for cut in (0..base.len()).step_by(7) {
4997            let truncated = base[..cut].to_vec();
4998            let outcome = std::panic::catch_unwind(move || {
4999                if let Ok(font) = Font::parse(truncated) {
5000                    let _ = font.axes();
5001                    let _ = font.normalized_axis(*b"wght", 100.0);
5002                }
5003            });
5004            log_check(
5005                "gk3v.1.trunc.sweep",
5006                &format!("cut {cut} no panic"),
5007                outcome.is_ok(),
5008            );
5009        }
5010    }
5011
5012    #[test]
5013    fn fmd_test_vf_fixture_round_trip() {
5014        let bytes = fmd_test_vf_bytes();
5015        let committed = include_bytes!("../fonts/test-variable/FmdTestVF.ttf");
5016        log_check(
5017            "gk3v.1.fixture.bytes",
5018            "committed TTF matches generator",
5019            bytes.as_slice() == committed,
5020        );
5021        let font = Font::parse(bytes).expect("test VF parses");
5022        log_check(
5023            "gk3v.1.fixture.axes",
5024            "committed-shape VF has wght",
5025            font.axes().len() == 1 && font.axes()[0].tag == *b"wght",
5026        );
5027        log_check(
5028            "gk3v.1.fixture.inst",
5029            "Regular + Bold instances",
5030            font.named_instances().len() == 2,
5031        );
5032        if std::env::var("FMD_DUMP_TEST_VF").ok().as_deref() == Some("1") {
5033            std::fs::write("/tmp/FmdTestVF.ttf", fmd_test_vf_bytes()).unwrap();
5034            eprintln!("check id=gk3v.1.dump subject=/tmp/FmdTestVF.ttf outcome=PASS");
5035        }
5036    }
5037}