Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.23% covered (success)
92.23%
190 / 206
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
KerningParser
92.23% covered (success)
92.23%
190 / 206
52.94% covered (warning)
52.94%
9 / 17
77.64
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 parseGpos
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 findKernFeatureIndices
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getLookupIndicesFromFeatures
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 parsePairPosLookups
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
8.05
 parsePairPosSubtable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parsePairPosFormat1
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
6.03
 parsePairPosFormat2
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
10.14
 parseCoverage
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 parseClassDef
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 valueRecordSize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 xAdvanceOffsetInValueRecord
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 parseKernTable
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 parseKernFormat0
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 readUint16
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readInt16
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 readUint32
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\FontParser;
6
7/**
8 * Parses kerning data from GPOS table (or legacy kern table).
9 *
10 * Extracts horizontal kerning pairs for the "kern" feature. Supports:
11 * - GPOS PairPosFormat1 (individual pairs)
12 * - GPOS PairPosFormat2 (class-based pairs)
13 * - GPOS Extension lookups (LookupType 9)
14 * - Legacy kern table format 0
15 *
16 * Returns leftGid => [rightGid => xAdvanceAdjust] in font design units.
17 * Negative values = tighten (move glyphs closer).
18 */
19final class KerningParser
20{
21    private string $data;
22
23    /**
24     * @param string $fontBytes Raw font file bytes
25     * @param array<string, array{offset:int, length:int}> $tables Table directory
26     * @return array<int, array<int, int>> leftGid => [rightGid => xAdvanceAdjust]
27     */
28    public function parse(string $fontBytes, array $tables): array
29    {
30        $this->data = $fontBytes;
31
32        // Try GPOS first
33        if (isset($tables['GPOS'])) {
34            $result = $this->parseGpos($tables['GPOS']['offset'], $tables['GPOS']['length']);
35            if ($result !== []) {
36                return $result;
37            }
38        }
39
40        // Fall back to legacy kern table
41        if (isset($tables['kern'])) {
42            return $this->parseKernTable($tables['kern']['offset'], $tables['kern']['length']);
43        }
44
45        return [];
46    }
47
48    /**
49     * @return array<int, array<int, int>>
50     */
51    private function parseGpos(int $offset, int $length): array
52    {
53        // GPOS header: Version(4) ScriptListOffset(2) FeatureListOffset(2) LookupListOffset(2)
54        $scriptListOffset = $offset + $this->readUint16($offset + 4);
55        $featureListOffset = $offset + $this->readUint16($offset + 6);
56        $lookupListOffset = $offset + $this->readUint16($offset + 8);
57
58        // Find "kern" feature indices via ScriptList
59        $kernFeatureIndices = $this->findKernFeatureIndices($scriptListOffset, $featureListOffset);
60        if ($kernFeatureIndices === []) {
61            return [];
62        }
63
64        // Get lookup indices from kern features
65        $lookupIndices = $this->getLookupIndicesFromFeatures($featureListOffset, $kernFeatureIndices);
66        if ($lookupIndices === []) {
67            return [];
68        }
69
70        // Parse lookups for PairPos (type 2)
71        return $this->parsePairPosLookups($lookupListOffset, $lookupIndices);
72    }
73
74    /**
75     * Find feature indices for the "kern" feature from ScriptList.
76     *
77     * @return int[] Feature indices
78     */
79    private function findKernFeatureIndices(int $scriptListOffset, int $featureListOffset): array
80    {
81        // Parse FeatureList to find "kern" features by tag
82        $featureCount = $this->readUint16($featureListOffset);
83        $kernFeatureIndices = [];
84
85        for ($i = 0; $i < $featureCount; $i++) {
86            $recOffset = $featureListOffset + 2 + $i * 6;
87            $tag = substr($this->data, $recOffset, 4);
88            if ($tag === 'kern') {
89                $kernFeatureIndices[] = $i;
90            }
91        }
92
93        return $kernFeatureIndices;
94    }
95
96    /**
97     * @param int[] $featureIndices
98     * @return int[] Lookup list indices
99     */
100    private function getLookupIndicesFromFeatures(int $featureListOffset, array $featureIndices): array
101    {
102        $lookupIndices = [];
103        $featureCount = $this->readUint16($featureListOffset);
104
105        foreach ($featureIndices as $fi) {
106            if ($fi >= $featureCount) {
107                continue;
108            }
109            $recOffset = $featureListOffset + 2 + $fi * 6;
110            $featureTableOffset = $featureListOffset + $this->readUint16($recOffset + 4);
111
112            // Feature table: FeatureParams(2) LookupCount(2) LookupListIndex[]
113            $lookupCount = $this->readUint16($featureTableOffset + 2);
114            for ($j = 0; $j < $lookupCount; $j++) {
115                $lookupIndices[] = $this->readUint16($featureTableOffset + 4 + $j * 2);
116            }
117        }
118
119        return array_unique($lookupIndices);
120    }
121
122    /**
123     * @param int[] $lookupIndices
124     * @return array<int, array<int, int>>
125     */
126    private function parsePairPosLookups(int $lookupListOffset, array $lookupIndices): array
127    {
128        $kernPairs = [];
129        $lookupCount = $this->readUint16($lookupListOffset);
130
131        foreach ($lookupIndices as $li) {
132            if ($li >= $lookupCount) {
133                continue;
134            }
135            $lookupOffset = $lookupListOffset + $this->readUint16($lookupListOffset + 2 + $li * 2);
136            $lookupType = $this->readUint16($lookupOffset);
137            $subtableCount = $this->readUint16($lookupOffset + 4);
138
139            for ($s = 0; $s < $subtableCount; $s++) {
140                $subtableOffset = $lookupOffset + $this->readUint16($lookupOffset + 6 + $s * 2);
141
142                if ($lookupType === 2) {
143                    // PairPos
144                    $this->parsePairPosSubtable($subtableOffset, $kernPairs);
145                } elseif ($lookupType === 9) {
146                    // Extension
147                    $extType = $this->readUint16($subtableOffset + 2);
148                    $extOffset = $subtableOffset + $this->readUint32($subtableOffset + 4);
149                    if ($extType === 2) {
150                        $this->parsePairPosSubtable($extOffset, $kernPairs);
151                    }
152                }
153            }
154        }
155
156        return $kernPairs;
157    }
158
159    /**
160     * @param array<int, array<int, int>> &$kernPairs
161     */
162    private function parsePairPosSubtable(int $offset, array &$kernPairs): void
163    {
164        $format = $this->readUint16($offset);
165
166        if ($format === 1) {
167            $this->parsePairPosFormat1($offset, $kernPairs);
168        } elseif ($format === 2) {
169            $this->parsePairPosFormat2($offset, $kernPairs);
170        }
171    }
172
173    /**
174     * PairPosFormat1: individual glyph pairs.
175     *
176     * @param array<int, array<int, int>> &$kernPairs
177     */
178    private function parsePairPosFormat1(int $offset, array &$kernPairs): void
179    {
180        $coverageOffset = $offset + $this->readUint16($offset + 2);
181        $valueFormat1 = $this->readUint16($offset + 4);
182        $valueFormat2 = $this->readUint16($offset + 6);
183        $pairSetCount = $this->readUint16($offset + 8);
184
185        // We only care about xAdvance in value1 (bit 2 = 0x0004)
186        if (($valueFormat1 & 0x0004) === 0) {
187            return; // No xAdvance in value1
188        }
189
190        $valueSize1 = $this->valueRecordSize($valueFormat1);
191        $valueSize2 = $this->valueRecordSize($valueFormat2);
192        $xAdvanceOffset1 = $this->xAdvanceOffsetInValueRecord($valueFormat1);
193
194        $coveredGlyphs = $this->parseCoverage($coverageOffset);
195
196        for ($i = 0; $i < $pairSetCount; $i++) {
197            if ($i >= count($coveredGlyphs)) {
198                break;
199            }
200            $leftGid = $coveredGlyphs[$i];
201            $pairSetOffset = $offset + $this->readUint16($offset + 10 + $i * 2);
202            $pairCount = $this->readUint16($pairSetOffset);
203
204            for ($j = 0; $j < $pairCount; $j++) {
205                $pairBase = $pairSetOffset + 2 + $j * (2 + $valueSize1 + $valueSize2);
206                $rightGid = $this->readUint16($pairBase);
207                $xAdvance = $this->readInt16($pairBase + 2 + $xAdvanceOffset1);
208
209                if ($xAdvance !== 0) {
210                    $kernPairs[$leftGid][$rightGid] = $xAdvance;
211                }
212            }
213        }
214    }
215
216    /**
217     * PairPosFormat2: class-based pairs.
218     *
219     * @param array<int, array<int, int>> &$kernPairs
220     */
221    private function parsePairPosFormat2(int $offset, array &$kernPairs): void
222    {
223        $coverageOffset = $offset + $this->readUint16($offset + 2);
224        $valueFormat1 = $this->readUint16($offset + 4);
225        $valueFormat2 = $this->readUint16($offset + 6);
226        $classDef1Offset = $offset + $this->readUint16($offset + 8);
227        $classDef2Offset = $offset + $this->readUint16($offset + 10);
228        $class1Count = $this->readUint16($offset + 12);
229        $class2Count = $this->readUint16($offset + 14);
230
231        if (($valueFormat1 & 0x0004) === 0) {
232            return;
233        }
234
235        $valueSize1 = $this->valueRecordSize($valueFormat1);
236        $valueSize2 = $this->valueRecordSize($valueFormat2);
237        $xAdvanceOffset1 = $this->xAdvanceOffsetInValueRecord($valueFormat1);
238        $recordSize = $valueSize1 + $valueSize2;
239
240        $coveredGlyphs = $this->parseCoverage($coverageOffset);
241        $classDef1 = $this->parseClassDef($classDef1Offset);
242        $classDef2 = $this->parseClassDef($classDef2Offset);
243
244        // Build reverse map: class => [gid, gid, ...]
245        $class2Glyphs = [];
246        foreach ($classDef2 as $gid => $cls) {
247            $class2Glyphs[$cls][] = $gid;
248        }
249
250        // Read the Class1Record Ã— Class2Record matrix
251        $matrixBase = $offset + 16;
252
253        foreach ($coveredGlyphs as $leftGid) {
254            $class1 = $classDef1[$leftGid] ?? 0;
255            if ($class1 >= $class1Count) {
256                continue;
257            }
258
259            $class1Base = $matrixBase + $class1 * $class2Count * $recordSize;
260
261            for ($c2 = 0; $c2 < $class2Count; $c2++) {
262                $recBase = $class1Base + $c2 * $recordSize;
263                $xAdvance = $this->readInt16($recBase + $xAdvanceOffset1);
264
265                if ($xAdvance === 0) {
266                    continue;
267                }
268
269                // Get all glyphs in class2
270                if ($c2 === 0) {
271                    // Class 0 = all glyphs not explicitly assigned to a class.
272                    // Skip â€” too many glyphs, and class 0 pairs are rarely meaningful.
273                    continue;
274                }
275
276                if (!isset($class2Glyphs[$c2])) {
277                    continue;
278                }
279
280                foreach ($class2Glyphs[$c2] as $rightGid) {
281                    $kernPairs[$leftGid][$rightGid] = $xAdvance;
282                }
283            }
284        }
285    }
286
287    /**
288     * @return int[] Covered glyph IDs (ordered by coverage index)
289     */
290    private function parseCoverage(int $offset): array
291    {
292        $format = $this->readUint16($offset);
293
294        if ($format === 1) {
295            // Format 1: list of glyph IDs
296            $count = $this->readUint16($offset + 2);
297            $glyphs = [];
298            for ($i = 0; $i < $count; $i++) {
299                $glyphs[] = $this->readUint16($offset + 4 + $i * 2);
300            }
301            return $glyphs;
302        }
303
304        if ($format === 2) {
305            // Format 2: ranges
306            $rangeCount = $this->readUint16($offset + 2);
307            $glyphs = [];
308            for ($i = 0; $i < $rangeCount; $i++) {
309                $rangeBase = $offset + 4 + $i * 6;
310                $startGid = $this->readUint16($rangeBase);
311                $endGid = $this->readUint16($rangeBase + 2);
312                for ($gid = $startGid; $gid <= $endGid; $gid++) {
313                    $glyphs[] = $gid;
314                }
315            }
316            return $glyphs;
317        }
318
319        return [];
320    }
321
322    /**
323     * @return array<int, int> glyph ID => class value
324     */
325    private function parseClassDef(int $offset): array
326    {
327        $format = $this->readUint16($offset);
328
329        if ($format === 1) {
330            // Format 1: array starting at startGlyphID
331            $startGid = $this->readUint16($offset + 2);
332            $glyphCount = $this->readUint16($offset + 4);
333            $classes = [];
334            for ($i = 0; $i < $glyphCount; $i++) {
335                $classes[$startGid + $i] = $this->readUint16($offset + 6 + $i * 2);
336            }
337            return $classes;
338        }
339
340        if ($format === 2) {
341            // Format 2: ranges
342            $rangeCount = $this->readUint16($offset + 2);
343            $classes = [];
344            for ($i = 0; $i < $rangeCount; $i++) {
345                $rangeBase = $offset + 4 + $i * 6;
346                $startGid = $this->readUint16($rangeBase);
347                $endGid = $this->readUint16($rangeBase + 2);
348                $classValue = $this->readUint16($rangeBase + 4);
349                for ($gid = $startGid; $gid <= $endGid; $gid++) {
350                    $classes[$gid] = $classValue;
351                }
352            }
353            return $classes;
354        }
355
356        return [];
357    }
358
359    /**
360     * Compute the byte size of a ValueRecord from its ValueFormat bitmask.
361     * Each set bit contributes 2 bytes.
362     */
363    private function valueRecordSize(int $valueFormat): int
364    {
365        $size = 0;
366        for ($bit = 0; $bit < 8; $bit++) {
367            if ($valueFormat & (1 << $bit)) {
368                $size += 2;
369            }
370        }
371        return $size;
372    }
373
374    /**
375     * Compute the byte offset of xAdvance within a ValueRecord.
376     * xAdvance is bit 2. We count how many bits before it are set.
377     */
378    private function xAdvanceOffsetInValueRecord(int $valueFormat): int
379    {
380        $offset = 0;
381        // bit 0 = xPlacement, bit 1 = yPlacement, bit 2 = xAdvance
382        if ($valueFormat & 0x0001) {
383            $offset += 2; // xPlacement
384        }
385        if ($valueFormat & 0x0002) {
386            $offset += 2; // yPlacement
387        }
388        // xAdvance is at this offset
389        return $offset;
390    }
391
392    // --- Legacy kern table ---
393
394    /**
395     * @return array<int, array<int, int>>
396     */
397    private function parseKernTable(int $offset, int $length): array
398    {
399        $kernPairs = [];
400        $version = $this->readUint16($offset);
401
402        if ($version === 0) {
403            // Microsoft kern table format
404            $nTables = $this->readUint16($offset + 2);
405            $pos = $offset + 4;
406
407            for ($t = 0; $t < $nTables; $t++) {
408                $subtableVersion = $this->readUint16($pos);
409                $subtableLength = $this->readUint16($pos + 2);
410                $coverage = $this->readUint16($pos + 4);
411                $format = $coverage >> 8;
412
413                // Only format 0 (ordered list of kern pairs), horizontal, not cross-stream
414                if ($format === 0 && ($coverage & 0x0001) !== 0 && ($coverage & 0x0004) === 0) {
415                    $this->parseKernFormat0($pos + 6, $kernPairs);
416                }
417
418                $pos += $subtableLength;
419            }
420        } elseif ($version === 1) {
421            // Apple kern table format (big-endian version as uint32)
422            $nTables = $this->readUint32($offset + 4);
423            $pos = $offset + 8;
424
425            for ($t = 0; $t < $nTables; $t++) {
426                $subtableLength = $this->readUint32($pos);
427                $coverage = $this->readUint16($pos + 4);
428                $format = $this->readUint16($pos + 6);
429
430                if ($format === 0 && ($coverage & 0x0002) === 0) { // horizontal
431                    $this->parseKernFormat0($pos + 8, $kernPairs);
432                }
433
434                $pos += $subtableLength;
435            }
436        }
437
438        return $kernPairs;
439    }
440
441    /**
442     * @param array<int, array<int, int>> &$kernPairs
443     */
444    private function parseKernFormat0(int $offset, array &$kernPairs): void
445    {
446        $nPairs = $this->readUint16($offset);
447
448        for ($i = 0; $i < $nPairs; $i++) {
449            $pairBase = $offset + 8 + $i * 6; // 8 = nPairs(2) + searchRange(2) + entrySelector(2) + rangeShift(2)
450            $leftGid = $this->readUint16($pairBase);
451            $rightGid = $this->readUint16($pairBase + 2);
452            $value = $this->readInt16($pairBase + 4);
453
454            if ($value !== 0) {
455                $kernPairs[$leftGid][$rightGid] = $value;
456            }
457        }
458    }
459
460    // --- Binary readers ---
461
462    private function readUint16(int $offset): int
463    {
464        return (ord($this->data[$offset]) << 8) | ord($this->data[$offset + 1]);
465    }
466
467    private function readInt16(int $offset): int
468    {
469        $v = $this->readUint16($offset);
470        return $v >= 0x8000 ? $v - 0x10000 : $v;
471    }
472
473    private function readUint32(int $offset): int
474    {
475        return ((ord($this->data[$offset]) << 24)
476            | (ord($this->data[$offset + 1]) << 16)
477            | (ord($this->data[$offset + 2]) << 8)
478            | ord($this->data[$offset + 3])) & 0xFFFFFFFF;
479    }
480}