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