Skip to main content

oxideav_ttf/tables/
kern.rs

1//! `kern` — legacy kerning table (predates GPOS).
2//!
3//! Two on-disk header variants coexist:
4//!
5//! - **Microsoft / OpenType `kern`** (used by every Windows-authored
6//!   TTF and most Adobe / Google fonts): `u16 version` followed by
7//!   `u16 nTables`. The `version` field is `0`, so the first 16 bits
8//!   of the table read as zero.
9//! - **Apple `kern`** (used by macOS-bundled TTFs and most Apple-
10//!   authored fonts): `u32 version` followed by `u32 nTables`. The
11//!   `version` field is `0x00010000`, so the first 16 bits read as
12//!   `0x0001` (NOT zero) — this is what distinguishes the two
13//!   variants at parse time.
14//!
15//! Per-subtable layouts differ between the two variants. The
16//! Microsoft per-subtable header is `u16 version, u16 length, u16
17//! coverage` (coverage's high byte carries the format, low byte the
18//! flags). Apple's per-subtable header is `u32 length, u16 coverage,
19//! u16 tupleIndex` and its coverage byte order is mirrored (format in
20//! the low byte, flags in the high byte) — the byte-level details
21//! aren't fully covered by the staged spec docs, so this parser
22//! accepts the Apple header at the table level but does not decode
23//! the Apple subtable bodies; an Apple-headered `kern` parses as a
24//! valid table with zero pairs (lookup → 0) rather than being
25//! rejected outright.
26//!
27//! For the Microsoft variant this crate decodes both subtable formats the
28//! OFF spec defines (§5.7.3): **Format 0** (a sorted list of explicit
29//! `(left, right) → value` kerning pairs) and **Format 2** (a class-based
30//! two-dimensional array, where left and right glyphs map to classes and
31//! the value is the array cell at `(leftClass, rightClass)`). Formats 1
32//! and 3..255 are reserved by the spec and skipped. Horizontal kerning
33//! subtables are honoured; "minimum" subtables (a floor rather than a
34//! delta) and non-horizontal / cross-stream subtables are skipped.
35//! Kerning subtables are additive, so [`KernTable::lookup`] sums every
36//! matching subtable's contribution.
37
38use crate::parser::{read_i16, read_u16, read_u32};
39use crate::Error;
40
41#[derive(Debug, Clone)]
42// internal — exposed for tests/fuzz; not part of the stable API
43#[doc(hidden)]
44pub struct KernTable<'a> {
45    /// All format-0 pair lists collected at parse time, sorted by
46    /// `(left << 16 | right)` for binary search.
47    pairs: Vec<KernPair>,
48    /// All format-2 (class-based two-dimensional array) horizontal
49    /// kerning subtables collected at parse time. The spec (§5.7.3) makes
50    /// kerning subtables additive, so a lookup sums every matching
51    /// subtable; in practice a font ships either format 0 or format 2.
52    format2: Vec<Format2Subtable>,
53    /// Which on-disk header variant the input used. Distinguishing
54    /// the two at parse time matters because subtable layouts differ;
55    /// the field is also surfaced via [`KernTable::header_variant`]
56    /// for callers that want to know whether the source font ships an
57    /// Apple-format table whose per-subtable bodies this crate does
58    /// not decode.
59    variant: HeaderVariant,
60    _phantom: core::marker::PhantomData<&'a ()>,
61}
62
63/// Which `kern` header layout the input table uses. Exposed so callers
64/// can tell apart Microsoft-format fonts (whose Format-0 subtables this
65/// crate decodes) from Apple-format fonts (whose subtable bodies are
66/// currently surfaced as "no kerning pairs available" rather than
67/// rejected at parse time).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum HeaderVariant {
70    /// Microsoft / OpenType layout: `u16 version` (= 0), `u16 nTables`,
71    /// then `nTables` subtables. Per-subtable header is `u16 version,
72    /// u16 length, u16 coverage`. This crate decodes Format-0
73    /// horizontal kerning subtables.
74    Microsoft,
75    /// Apple layout: `u32 version` (= 0x00010000), `u32 nTables`, then
76    /// `nTables` subtables with a different per-subtable header
77    /// layout. The subtable bodies are not decoded by this crate;
78    /// callers that need Apple-kern data should hold the fixed Apple
79    /// `kerx` clean-room reference and submit a follow-up.
80    Apple,
81}
82
83#[derive(Debug, Clone, Copy)]
84struct KernPair {
85    key: u32,
86    value: i16,
87}
88
89/// One decoded format-2 (class-based two-dimensional array) horizontal
90/// kerning subtable (ISO/IEC 14496-22:2019 §5.7.3 "Format 2").
91///
92/// Glyphs are mapped to left- and right-hand classes; the kerning value
93/// for a pair is the array cell at `(leftClass, rightClass)`. The spec
94/// pre-multiplies the stored class values — left-class values by
95/// `rowWidth` (bytes per row) and right-class values by the kerning-value
96/// size (2) — so a cell address is `array + leftClassValue +
97/// rightClassValue`. We store the pre-multiplied class values verbatim and
98/// reproduce that addressing, validating every resulting cell offset lands
99/// inside the subtable.
100#[derive(Debug, Clone)]
101struct Format2Subtable {
102    /// `firstGlyph` / pre-multiplied class values for the left-hand class
103    /// table. A glyph outside `[first, first + values.len())` uses class 0
104    /// (the "does not kern" row, all zeros per spec).
105    left: ClassTable,
106    /// Same for the right-hand class table (column index).
107    right: ClassTable,
108    /// The flattened kerning array: `array.len()` FWord cells, row-major.
109    array: Vec<i16>,
110    /// Width of one row in bytes (the `rowWidth` header field); used to
111    /// validate the pre-multiplied left-class addressing.
112    row_width: usize,
113}
114
115/// A kern format-2 class table: a glyph-id range mapped to pre-multiplied
116/// class values.
117#[derive(Debug, Clone)]
118struct ClassTable {
119    first_glyph: u16,
120    /// Pre-multiplied class value per glyph in the range. `values[g -
121    /// first_glyph]` is the byte offset contribution for glyph `g`.
122    values: Vec<u16>,
123}
124
125impl ClassTable {
126    /// The pre-multiplied class value for `glyph`, or `0` (class 0, "does
127    /// not kern") when the glyph is outside the table's range.
128    fn value_for(&self, glyph: u16) -> u16 {
129        if glyph < self.first_glyph {
130            return 0;
131        }
132        let idx = (glyph - self.first_glyph) as usize;
133        self.values.get(idx).copied().unwrap_or(0)
134    }
135}
136
137impl Format2Subtable {
138    /// Look up the additive kerning contribution for an ordered glyph pair.
139    /// Returns 0 when either glyph is unmapped (class 0) or the addressed
140    /// cell does not fall on a valid array index.
141    fn lookup(&self, left: u16, right: u16) -> i16 {
142        // Stored class values are pre-multiplied: left by rowWidth (bytes),
143        // right by the 2-byte kerning-value size. The cell byte offset from
144        // the array start is therefore left_value + right_value; dividing
145        // by 2 yields the FWord index.
146        let lo = self.left.value_for(left) as usize;
147        let ro = self.right.value_for(right) as usize;
148        // Class 0 on either axis means "does not kern".
149        if lo == 0 || ro == 0 {
150            return 0;
151        }
152        let byte_off = lo + ro;
153        // The left value is a multiple of rowWidth and the right value a
154        // multiple of 2, so the sum is even; guard anyway.
155        if self.row_width == 0 || byte_off % 2 != 0 {
156            return 0;
157        }
158        let idx = byte_off / 2;
159        self.array.get(idx).copied().unwrap_or(0)
160    }
161}
162
163impl<'a> KernTable<'a> {
164    pub fn parse(bytes: &'a [u8]) -> Result<Self, Error> {
165        if bytes.len() < 4 {
166            return Err(Error::UnexpectedEof);
167        }
168        // Sniff version. Microsoft format: `u16 version` (= 0) — first
169        // 16 bits read as 0. Apple format: `u32 version` (= 0x00010000,
170        // big-endian → bytes 00 01 00 00) — first 16 bits read as
171        // 0x0001 (NOT zero). The two are mutually exclusive at the
172        // first u16: any other value is malformed.
173        let v0 = read_u16(bytes, 0)?;
174        let (mut off, n_subtables, variant) = match v0 {
175            0 => {
176                // Microsoft layout: u16 version, u16 nTables.
177                let n = read_u16(bytes, 2)?;
178                (4usize, n as u32, HeaderVariant::Microsoft)
179            }
180            1 => {
181                // Apple layout: u32 version (= 0x00010000), u32 nTables.
182                // Confirm the low half of the version u32 is also zero
183                // to defuse fonts that mis-encode the field.
184                if bytes.len() < 8 {
185                    return Err(Error::UnexpectedEof);
186                }
187                let v_lo = read_u16(bytes, 2)?;
188                if v_lo != 0 {
189                    return Err(Error::BadStructure("kern: bad version"));
190                }
191                let n = read_u32(bytes, 4)?;
192                (8usize, n, HeaderVariant::Apple)
193            }
194            _ => return Err(Error::BadStructure("kern: bad version")),
195        };
196
197        let mut pairs = Vec::new();
198        if matches!(variant, HeaderVariant::Apple) {
199            // Apple per-subtable layout is not covered by the spec docs
200            // staged under `docs/text/opentype/`. Accept the table
201            // structurally (so the host font still parses) but do not
202            // walk the subtable list — the `length` field placement
203            // differs from the Microsoft variant and a mis-parsed walk
204            // would either fabricate bogus pairs or panic.
205            let _ = n_subtables;
206            let _ = off;
207            return Ok(Self {
208                pairs,
209                format2: Vec::new(),
210                variant,
211                _phantom: core::marker::PhantomData,
212            });
213        }
214        let mut format2 = Vec::new();
215        for _ in 0..n_subtables {
216            // Subtable header (Microsoft format):
217            //   u16 version, u16 length, u16 coverage.
218            // Coverage low byte: bit 0 = horizontal, bit 1 = minimum
219            // (else kerning), bit 2 = cross-stream, bit 3 = override.
220            // High byte: format (0..3).
221            if off + 6 > bytes.len() {
222                return Err(Error::UnexpectedEof);
223            }
224            let _sub_version = read_u16(bytes, off)?;
225            let length = read_u16(bytes, off + 2)? as usize;
226            let coverage = read_u16(bytes, off + 4)?;
227            let format = (coverage >> 8) & 0xFF;
228            // Sanity-check sub-table length so we always advance.
229            if length < 6 || off + length > bytes.len() {
230                // Malformed — bail out of the loop rather than spin.
231                break;
232            }
233            let next_off = off + length;
234            // Only horizontal kerning, only format 0, skip "minimum"
235            // tables (those provide a floor, not a delta).
236            let horizontal = (coverage & 1) != 0;
237            let is_kerning = (coverage & 2) == 0;
238            if horizontal && is_kerning {
239                match format {
240                    0 => parse_format0(bytes, off + 6, &mut pairs)?,
241                    2 => {
242                        // The format-2 body begins right after the 6-byte
243                        // subtable header; its internal offsets are measured
244                        // from the *subtable* start (`off`), per §5.7.3.
245                        if let Some(sub) = parse_format2(bytes, off, length)? {
246                            format2.push(sub);
247                        }
248                    }
249                    // Formats 1 and 3..255 are reserved per §5.7.3; skip.
250                    _ => {}
251                }
252            }
253            off = next_off;
254        }
255        pairs.sort_by_key(|p| p.key);
256        Ok(Self {
257            pairs,
258            format2,
259            variant,
260            _phantom: core::marker::PhantomData,
261        })
262    }
263
264    /// Which on-disk header layout the input table used. Useful for
265    /// callers that want to report "this font ships an Apple-format
266    /// `kern` whose subtable bodies are not decoded".
267    pub fn header_variant(&self) -> HeaderVariant {
268        self.variant
269    }
270
271    /// Number of decoded kerning pairs available for [`Self::lookup`].
272    /// Returns `0` for Apple-headered tables (whose subtable bodies
273    /// this crate does not decode) and for Microsoft-headered tables
274    /// that ship only non-horizontal / non-Format-0 subtables.
275    pub fn pair_count(&self) -> usize {
276        self.pairs.len()
277    }
278
279    /// Number of decoded format-2 (class-based two-dimensional array)
280    /// horizontal kerning subtables. A font usually ships either format 0
281    /// or format 2, not both; this is `0` for the common format-0 case.
282    pub fn format2_subtable_count(&self) -> usize {
283        self.format2.len()
284    }
285
286    /// Look up the kerning between an ordered glyph pair, in font units.
287    /// Returns 0 when no rule matches.
288    ///
289    /// Per §5.7.3 kerning subtables are *additive*, so the result is the
290    /// sum of the matching format-0 pair (if any) and every format-2
291    /// class-array cell the pair addresses. In practice a font ships one
292    /// form, so the sum reduces to a single contribution.
293    pub fn lookup(&self, left: u16, right: u16) -> i16 {
294        let key = ((left as u32) << 16) | right as u32;
295        let mut value: i32 = match self.pairs.binary_search_by_key(&key, |p| p.key) {
296            Ok(i) => self.pairs[i].value as i32,
297            Err(_) => 0,
298        };
299        for sub in &self.format2 {
300            value += sub.lookup(left, right) as i32;
301        }
302        value.clamp(i16::MIN as i32, i16::MAX as i32) as i16
303    }
304}
305
306/// Parse a format-2 (class-based two-dimensional array) kerning subtable.
307/// `sub_off` is the byte offset of the *subtable* (its 6-byte header), and
308/// `length` is the subtable length from that header; the format-2 internal
309/// offsets are measured from `sub_off`. Returns `Ok(None)` for a subtable
310/// whose offsets or array do not fit inside the declared length (a
311/// malformed subtable is skipped, not fatal).
312fn parse_format2(
313    bytes: &[u8],
314    sub_off: usize,
315    length: usize,
316) -> Result<Option<Format2Subtable>, Error> {
317    // Body header (after the 6-byte shared subtable header):
318    //   u16 rowWidth, Offset16 leftClassTable, Offset16 rightClassTable,
319    //   Offset16 array. All offsets are from the subtable start.
320    let body = sub_off + 6;
321    if body + 8 > bytes.len() || sub_off + length > bytes.len() {
322        return Ok(None);
323    }
324    let row_width = read_u16(bytes, body)? as usize;
325    let left_off = read_u16(bytes, body + 2)? as usize;
326    let right_off = read_u16(bytes, body + 4)? as usize;
327    let array_off = read_u16(bytes, body + 6)? as usize;
328    // Bound every offset to within the subtable.
329    let sub_end = sub_off + length;
330    let left = match parse_class_table(bytes, sub_off, left_off, sub_end)? {
331        Some(t) => t,
332        None => return Ok(None),
333    };
334    let right = match parse_class_table(bytes, sub_off, right_off, sub_end)? {
335        Some(t) => t,
336        None => return Ok(None),
337    };
338    let array_start = sub_off + array_off;
339    if array_off == 0 || array_start > sub_end {
340        return Ok(None);
341    }
342    // The array runs from array_start to the subtable end; decode all whole
343    // FWord cells that fit. Pre-multiplied class addressing indexes into
344    // this flat array, so we keep every cell the subtable carries.
345    let array_bytes = sub_end - array_start;
346    let cell_count = array_bytes / 2;
347    let mut array = Vec::with_capacity(cell_count);
348    for i in 0..cell_count {
349        array.push(read_i16(bytes, array_start + i * 2)?);
350    }
351    Ok(Some(Format2Subtable {
352        left,
353        right,
354        array,
355        row_width,
356    }))
357}
358
359/// Parse one kern format-2 class table at `sub_off + rel_off`:
360///   u16 firstGlyph, u16 nGlyphs, u16 classValues[nGlyphs].
361/// Returns `Ok(None)` when the table runs past `sub_end`.
362fn parse_class_table(
363    bytes: &[u8],
364    sub_off: usize,
365    rel_off: usize,
366    sub_end: usize,
367) -> Result<Option<ClassTable>, Error> {
368    if rel_off == 0 {
369        return Ok(None);
370    }
371    let start = sub_off + rel_off;
372    if start + 4 > sub_end {
373        return Ok(None);
374    }
375    let first_glyph = read_u16(bytes, start)?;
376    let n_glyphs = read_u16(bytes, start + 2)? as usize;
377    let arr = start + 4;
378    if arr + n_glyphs * 2 > sub_end {
379        return Ok(None);
380    }
381    let mut values = Vec::with_capacity(n_glyphs);
382    for i in 0..n_glyphs {
383        values.push(read_u16(bytes, arr + i * 2)?);
384    }
385    Ok(Some(ClassTable {
386        first_glyph,
387        values,
388    }))
389}
390
391fn parse_format0(bytes: &[u8], start: usize, out: &mut Vec<KernPair>) -> Result<(), Error> {
392    // Format-0 subtable body:
393    //   u16 nPairs, u16 searchRange/entrySelector/rangeShift (3 * u16 — ignored).
394    //   nPairs * (u16 left, u16 right, FWord value).
395    if start + 8 > bytes.len() {
396        return Err(Error::UnexpectedEof);
397    }
398    let n_pairs = read_u16(bytes, start)? as usize;
399    let mut p = start + 8;
400    for _ in 0..n_pairs {
401        if p + 6 > bytes.len() {
402            return Err(Error::UnexpectedEof);
403        }
404        let l = read_u16(bytes, p)?;
405        let r = read_u16(bytes, p + 2)?;
406        let v = read_i16(bytes, p + 4)?;
407        out.push(KernPair {
408            key: ((l as u32) << 16) | r as u32,
409            value: v,
410        });
411        p += 6;
412    }
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn build_kern_with_one_pair(l: u16, r: u16, v: i16) -> Vec<u8> {
421        // Microsoft header.
422        let mut t = vec![0u8; 4];
423        t[0..2].copy_from_slice(&0u16.to_be_bytes()); // version
424        t[2..4].copy_from_slice(&1u16.to_be_bytes()); // nTables
425                                                      // Subtable (header 6 + body 8 + 1*6 = 20 bytes).
426        let mut sub = vec![0u8; 20];
427        sub[0..2].copy_from_slice(&0u16.to_be_bytes()); // sub-version
428        sub[2..4].copy_from_slice(&20u16.to_be_bytes()); // length
429                                                         // coverage = 0x0001 (horizontal, format 0)
430        sub[4..6].copy_from_slice(&1u16.to_be_bytes());
431        // body: nPairs=1
432        sub[6..8].copy_from_slice(&1u16.to_be_bytes());
433        // 6 bytes searchRange/entrySelector/rangeShift skipped
434        sub[14..16].copy_from_slice(&l.to_be_bytes());
435        sub[16..18].copy_from_slice(&r.to_be_bytes());
436        sub[18..20].copy_from_slice(&v.to_be_bytes());
437        t.extend_from_slice(&sub);
438        t
439    }
440
441    #[test]
442    fn round_trips_one_pair() {
443        let bytes = build_kern_with_one_pair(38, 57, -100);
444        let k = KernTable::parse(&bytes).unwrap();
445        assert_eq!(k.lookup(38, 57), -100);
446        assert_eq!(k.lookup(38, 58), 0);
447        assert_eq!(k.header_variant(), HeaderVariant::Microsoft);
448        assert_eq!(k.pair_count(), 1);
449    }
450
451    /// Apple-format `kern` (the layout shipped by every macOS-bundled
452    /// `.ttf` — Helvetica, Lucida, Times, etc.). The previous version
453    /// of the header sniffer matched both Microsoft and Apple on
454    /// `first u16 == 0` and dispatched both into the Microsoft body
455    /// walker; Apple's u32-wide `version` field has high u16 = `0x0001`
456    /// (NOT zero), so the correct dispatch picks it up here, accepts
457    /// the table without rejecting the host font, and exposes zero
458    /// kerning pairs (the subtable body layout differs from the
459    /// Microsoft variant and isn't decoded by this crate yet).
460    #[test]
461    fn apple_header_parses_as_empty_table() {
462        let mut bytes = vec![0u8; 8];
463        // u32 version = 0x00010000 (big-endian bytes 00 01 00 00).
464        bytes[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
465        // u32 nTables = 0.
466        bytes[4..8].copy_from_slice(&0u32.to_be_bytes());
467        let k = KernTable::parse(&bytes).unwrap();
468        assert_eq!(k.header_variant(), HeaderVariant::Apple);
469        assert_eq!(k.pair_count(), 0);
470        // Any lookup returns the no-data sentinel (0), so consumer-
471        // crate shapers degrade to "no legacy kerning" rather than
472        // panicking on an out-of-bounds slice into a misparsed body.
473        assert_eq!(k.lookup(38, 57), 0);
474        assert_eq!(k.lookup(0, 0), 0);
475    }
476
477    /// An Apple-headered table that claims a non-zero subtable count
478    /// also parses cleanly: this crate doesn't walk the Apple subtable
479    /// list so the bogus nTables field is harmless. The point of the
480    /// test is to prove the header sniff doesn't crash on the field —
481    /// real-world Apple `kern` tables routinely list 2-3 subtables.
482    #[test]
483    fn apple_header_with_nonzero_n_tables_parses() {
484        let mut bytes = vec![0u8; 8];
485        bytes[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
486        bytes[4..8].copy_from_slice(&3u32.to_be_bytes());
487        let k = KernTable::parse(&bytes).unwrap();
488        assert_eq!(k.header_variant(), HeaderVariant::Apple);
489        assert_eq!(k.pair_count(), 0);
490    }
491
492    /// Truncated Apple header — version reads as 0x0001 but the table
493    /// ends before the u32 nTables field. The parser must surface
494    /// `UnexpectedEof` instead of indexing out of bounds.
495    #[test]
496    fn apple_header_truncated_returns_eof() {
497        // Only 4 bytes — high half of the version is there (forcing
498        // the Apple branch), but nTables and the rest are missing.
499        let mut bytes = vec![0u8; 4];
500        bytes[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
501        bytes[2..4].copy_from_slice(&0u16.to_be_bytes()); // version low half
502        assert!(matches!(
503            KernTable::parse(&bytes),
504            Err(Error::UnexpectedEof)
505        ));
506    }
507
508    /// A first-u16 sentinel that's neither 0 (Microsoft) nor 0x0001
509    /// (Apple's version high-half) is malformed. Reject with a typed
510    /// `BadStructure` rather than mis-dispatching into one of the two
511    /// walkers and corrupting state.
512    #[test]
513    fn unknown_version_rejected() {
514        let mut bytes = vec![0u8; 8];
515        bytes[0..2].copy_from_slice(&0x1234u16.to_be_bytes());
516        let r = KernTable::parse(&bytes);
517        assert!(matches!(r, Err(Error::BadStructure(_))));
518    }
519
520    /// Apple version high-half matches (0x0001) but the low half of
521    /// the u32 version is non-zero — i.e. the value on disk is some
522    /// 0x0001XXXX where XXXX != 0. The real Apple `kern` table version
523    /// is exactly 0x00010000, so anything else is malformed and we
524    /// reject it as a structural error rather than dispatching into
525    /// the Apple body path.
526    #[test]
527    fn apple_header_with_dirty_low_half_rejected() {
528        let mut bytes = vec![0u8; 8];
529        bytes[0..2].copy_from_slice(&0x0001u16.to_be_bytes());
530        bytes[2..4].copy_from_slice(&0xBEEFu16.to_be_bytes()); // dirty low half
531        bytes[4..8].copy_from_slice(&0u32.to_be_bytes());
532        assert!(matches!(
533            KernTable::parse(&bytes),
534            Err(Error::BadStructure(_))
535        ));
536    }
537
538    /// Build a Microsoft-headered `kern` carrying one format-2 (class-based
539    /// 2D array) horizontal subtable with 2 left classes and 2 right
540    /// classes. Glyph 10 → left class 1, glyph 20 → right class 1; the
541    /// `(class1, class1)` cell holds `cell_value`. Everything else maps to
542    /// class 0 ("does not kern").
543    ///
544    /// Layout (subtable starts at file offset 4):
545    /// ```text
546    ///  [4..10)   subtable header: version(2) length(2) coverage(2)=0x0201
547    ///  [10..18)  body header: rowWidth(2) leftOff(2) rightOff(2) arrayOff(2)
548    ///  [18..26)  left class table:  firstGlyph=10 nGlyphs=2 values=[4,0]
549    ///  [26..34)  right class table: firstGlyph=20 nGlyphs=2 values=[2,0]
550    ///  [34..42)  array: 4 FWord cells [r0c0,r0c1,r1c0,r1c1]
551    /// ```
552    /// rowWidth = 2 right-classes × 2-byte value = 4. Left class 1 is
553    /// pre-multiplied by rowWidth → 4; right class 1 by value size 2 → 2.
554    /// Cell byte offset = 4 + 2 = 6 → FWord index 3 (= r1c1).
555    fn build_kern_format2(cell_value: i16) -> Vec<u8> {
556        let mut t = vec![0u8; 4];
557        t[0..2].copy_from_slice(&0u16.to_be_bytes()); // version
558        t[2..4].copy_from_slice(&1u16.to_be_bytes()); // nTables
559        let mut sub = vec![0u8; 38];
560        sub[0..2].copy_from_slice(&0u16.to_be_bytes()); // sub version
561        sub[2..4].copy_from_slice(&38u16.to_be_bytes()); // length
562        sub[4..6].copy_from_slice(&0x0201u16.to_be_bytes()); // format 2, horizontal
563                                                             // body header (offsets are from the subtable start)
564        sub[6..8].copy_from_slice(&4u16.to_be_bytes()); // rowWidth
565        sub[8..10].copy_from_slice(&14u16.to_be_bytes()); // leftClassTable offset
566        sub[10..12].copy_from_slice(&22u16.to_be_bytes()); // rightClassTable offset
567        sub[12..14].copy_from_slice(&30u16.to_be_bytes()); // array offset
568                                                           // left class table at sub+14
569        sub[14..16].copy_from_slice(&10u16.to_be_bytes()); // firstGlyph
570        sub[16..18].copy_from_slice(&2u16.to_be_bytes()); // nGlyphs
571        sub[18..20].copy_from_slice(&4u16.to_be_bytes()); // glyph10 -> class1*rowWidth
572        sub[20..22].copy_from_slice(&0u16.to_be_bytes()); // glyph11 -> class0
573                                                          // right class table at sub+22
574        sub[22..24].copy_from_slice(&20u16.to_be_bytes()); // firstGlyph
575        sub[24..26].copy_from_slice(&2u16.to_be_bytes()); // nGlyphs
576        sub[26..28].copy_from_slice(&2u16.to_be_bytes()); // glyph20 -> class1*2
577        sub[28..30].copy_from_slice(&0u16.to_be_bytes()); // glyph21 -> class0
578                                                          // array at sub+30: 4 cells, r1c1 = index 3
579        sub[36..38].copy_from_slice(&cell_value.to_be_bytes());
580        t.extend_from_slice(&sub);
581        t
582    }
583
584    #[test]
585    fn format2_class_array_lookup() {
586        let bytes = build_kern_format2(-50);
587        let k = KernTable::parse(&bytes).unwrap();
588        assert_eq!(k.header_variant(), HeaderVariant::Microsoft);
589        assert_eq!(k.pair_count(), 0);
590        assert_eq!(k.format2_subtable_count(), 1);
591        // glyph 10 (left class 1) before glyph 20 (right class 1) -> r1c1.
592        assert_eq!(k.lookup(10, 20), -50);
593        // glyph 11 maps to left class 0 ("does not kern") -> 0.
594        assert_eq!(k.lookup(11, 20), 0);
595        // glyph 21 maps to right class 0 -> 0.
596        assert_eq!(k.lookup(10, 21), 0);
597        // glyphs outside either class table -> class 0 -> 0.
598        assert_eq!(k.lookup(99, 99), 0);
599    }
600
601    #[test]
602    fn format2_minimum_subtable_skipped() {
603        // A format-2 subtable with the "minimum" coverage bit set provides
604        // a floor, not a kerning delta; it must be skipped like format 0's
605        // minimum tables.
606        let mut bytes = build_kern_format2(-50);
607        // coverage byte is at file offset 8..10 (subtable starts at 4,
608        // coverage at +4). Set bit 1 (minimum) -> 0x0203.
609        bytes[8..10].copy_from_slice(&0x0203u16.to_be_bytes());
610        let k = KernTable::parse(&bytes).unwrap();
611        assert_eq!(k.format2_subtable_count(), 0);
612        assert_eq!(k.lookup(10, 20), 0);
613    }
614
615    #[test]
616    fn format2_and_format0_are_additive() {
617        // Two subtables: a format-0 pair (10,20)=-30 and the format-2 table
618        // with (10,20)=-50. §5.7.3 makes them additive -> -80.
619        let mut t = vec![0u8; 4];
620        t[0..2].copy_from_slice(&0u16.to_be_bytes());
621        t[2..4].copy_from_slice(&2u16.to_be_bytes()); // nTables = 2
622                                                      // format-0 subtable (length 20): pair (10,20) = -30.
623        let mut f0 = vec![0u8; 20];
624        f0[2..4].copy_from_slice(&20u16.to_be_bytes());
625        f0[4..6].copy_from_slice(&1u16.to_be_bytes()); // coverage: format 0, horizontal
626        f0[6..8].copy_from_slice(&1u16.to_be_bytes()); // nPairs
627        f0[14..16].copy_from_slice(&10u16.to_be_bytes());
628        f0[16..18].copy_from_slice(&20u16.to_be_bytes());
629        f0[18..20].copy_from_slice(&(-30i16).to_be_bytes());
630        t.extend_from_slice(&f0);
631        // append the format-2 subtable (skip its own 4-byte kern header).
632        let f2 = build_kern_format2(-50);
633        t.extend_from_slice(&f2[4..]);
634        let k = KernTable::parse(&t).unwrap();
635        assert_eq!(k.pair_count(), 1);
636        assert_eq!(k.format2_subtable_count(), 1);
637        assert_eq!(k.lookup(10, 20), -80);
638    }
639
640    #[test]
641    fn format2_malformed_offsets_skipped() {
642        // An array offset past the subtable end is skipped, not fatal.
643        let mut bytes = build_kern_format2(-50);
644        // array offset field at file offset 4+12=16.
645        bytes[16..18].copy_from_slice(&9999u16.to_be_bytes());
646        let k = KernTable::parse(&bytes).unwrap();
647        assert_eq!(k.format2_subtable_count(), 0);
648    }
649}