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}