Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.72% covered (success)
90.72%
313 / 345
56.52% covered (warning)
56.52%
13 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrueTypeSubsetter
90.72% covered (success)
90.72%
313 / 345
56.52% covered (warning)
56.52%
13 / 23
88.50
0.00% covered (danger)
0.00%
0 / 1
 subset
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
3
 getGidMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseTables
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
3
 resolveComposites
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
10.16
 readLocaTable
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 buildGlyf
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 remapCompositeGlyph
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
5.12
 canUseShortLoca
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 buildLoca
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildHmtx
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
6.50
 buildCmap
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 buildCmapFormat4
83.61% covered (warning)
83.61%
51 / 61
0.00% covered (danger)
0.00%
0 / 1
11.53
 buildCmapFormat12
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 buildMaxp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildHhea
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildHead
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getTableData
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 assembleFont
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
6
 calculateChecksum
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 readUint32FromString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 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%
4 / 4
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 * Produces a minimal valid TrueType font containing only requested glyphs.
9 *
10 * Takes raw TTF bytes and a set of glyph IDs, and emits a new TTF with
11 * only those glyphs (plus GID 0 / .notdef and any composite components).
12 */
13class TrueTypeSubsetter
14{
15    private string $data;
16
17    /** @var array<string, array{offset:int, length:int, checksum:int}> */
18    private array $tables = [];
19
20    private int $numGlyphs;
21    private int $numberOfHMetrics;
22    private int $indexToLocFormat;
23
24    /**
25     * Old GID → new GID map, populated by the most recent subset() call.
26     * Callers need this to translate any pre-subset GIDs they hold (Unicode
27     * → GID maps from the unsubset font, for instance) into the renumbered
28     * GIDs that actually live in the emitted subset.
29     *
30     * @var array<int, int>
31     */
32    private array $gidMap = [];
33
34    /**
35     * @param string   $fontBytes Raw TTF file bytes
36     * @param int[]    $glyphIds  GIDs to keep (GID 0 is always included)
37     * @param array<int, int> $unicodeToGid Unicode codepoint => GID map (for rebuilding cmap)
38     * @return string  Subset TTF bytes
39     */
40    public function subset(string $fontBytes, array $glyphIds, array $unicodeToGid = []): string
41    {
42        $this->data = $fontBytes;
43        $this->parseTables();
44
45        // Always include GID 0
46        $glyphIds = array_unique(array_merge([0], $glyphIds));
47
48        // Resolve composite glyph components recursively
49        $glyphIds = $this->resolveComposites($glyphIds);
50        sort($glyphIds);
51
52        // Build old GID => new GID map (also kept on $this so callers can
53        // retrieve it after subset() via getGidMap()).
54        $gidMap = [];
55        foreach ($glyphIds as $newGid => $oldGid) {
56            $gidMap[$oldGid] = $newGid;
57        }
58        $this->gidMap = $gidMap;
59
60        // Build subset tables
61        $newGlyf = $this->buildGlyf($glyphIds, $gidMap);
62        $useShortLoca = $this->canUseShortLoca($newGlyf['offsets']);
63        $newLoca = $this->buildLoca($newGlyf['offsets'], $useShortLoca);
64        $newHmtx = $this->buildHmtx($glyphIds);
65        $newCmap = $this->buildCmap($glyphIds, $gidMap, $unicodeToGid);
66        $newMaxp = $this->buildMaxp(count($glyphIds));
67        $newHhea = $this->buildHhea(count($glyphIds));
68        $newHead = $this->buildHead($useShortLoca ? 0 : 1);
69        $newPost = $this->getTableData('post');
70        $newName = $this->getTableData('name');
71        $newOs2 = $this->getTableData('OS/2');
72
73        // Assemble the new font
74        $tableDefs = [
75            'head' => $newHead,
76            'hhea' => $newHhea,
77            'maxp' => $newMaxp,
78            'OS/2' => $newOs2,
79            'name' => $newName,
80            'cmap' => $newCmap,
81            'post' => $newPost,
82            'hmtx' => $newHmtx,
83            'loca' => $newLoca,
84            'glyf' => $newGlyf['data'],
85        ];
86
87        return $this->assembleFont($tableDefs);
88    }
89
90    /**
91     * The old → new GID map from the most recent `subset()` call. Returns
92     * an empty array if `subset()` has not been called yet. Callers use this
93     * to translate any pre-subset GIDs they hold into the renumbered GIDs
94     * that live in the emitted subset font.
95     *
96     * @return array<int, int>
97     */
98    public function getGidMap(): array
99    {
100        return $this->gidMap;
101    }
102
103    private function parseTables(): void
104    {
105        $sfVersion = $this->readUint32(0);
106        if ($sfVersion !== 0x00010000) {
107            throw new \RuntimeException('Not a TrueType font');
108        }
109
110        $numTables = $this->readUint16(4);
111        $dirOffset = 12;
112
113        for ($i = 0; $i < $numTables; $i++) {
114            $base = $dirOffset + $i * 16;
115            $tag = substr($this->data, $base, 4);
116            $checksum = $this->readUint32($base + 4);
117            $offset = $this->readUint32($base + 8);
118            $length = $this->readUint32($base + 12);
119            $this->tables[rtrim($tag)] = [
120                'offset' => $offset,
121                'length' => $length,
122                'checksum' => $checksum,
123            ];
124        }
125
126        // Read key values from existing tables
127        $headBase = $this->tables['head']['offset'];
128        $this->indexToLocFormat = $this->readInt16($headBase + 50);
129
130        $maxpBase = $this->tables['maxp']['offset'];
131        $this->numGlyphs = $this->readUint16($maxpBase + 4);
132
133        $hheaBase = $this->tables['hhea']['offset'];
134        $this->numberOfHMetrics = $this->readUint16($hheaBase + 34);
135    }
136
137    /**
138     * @param int[] $glyphIds
139     * @return int[]
140     */
141    private function resolveComposites(array $glyphIds): array
142    {
143        $locaOffsets = $this->readLocaTable();
144        $glyfBase = $this->tables['glyf']['offset'];
145        $resolved = array_flip($glyphIds);
146        $queue = $glyphIds;
147
148        while ($queue !== []) {
149            $gid = array_pop($queue);
150            if ($gid >= $this->numGlyphs) {
151                continue;
152            }
153
154            $glyphOffset = $locaOffsets[$gid];
155            $glyphLength = $locaOffsets[$gid + 1] - $glyphOffset;
156            if ($glyphLength === 0) {
157                continue;
158            }
159
160            $offset = $glyfBase + $glyphOffset;
161            $numberOfContours = $this->readInt16($offset);
162
163            if ($numberOfContours >= 0) {
164                continue; // Simple glyph
165            }
166
167            // Composite glyph - parse components
168            $ptr = $offset + 10; // skip header (numberOfContours + bbox)
169            do {
170                $flags = $this->readUint16($ptr);
171                $componentGid = $this->readUint16($ptr + 2);
172                $ptr += 4;
173
174                if (!isset($resolved[$componentGid])) {
175                    $resolved[$componentGid] = true;
176                    $queue[] = $componentGid;
177                }
178
179                // Skip arguments based on flags
180                if ($flags & 0x0001) { // ARG_1_AND_2_ARE_WORDS
181                    $ptr += 4;
182                } else {
183                    $ptr += 2;
184                }
185
186                // Skip transform based on flags
187                if ($flags & 0x0008) { // WE_HAVE_A_SCALE
188                    $ptr += 2;
189                } elseif ($flags & 0x0040) { // WE_HAVE_AN_X_AND_Y_SCALE
190                    $ptr += 4;
191                } elseif ($flags & 0x0080) { // WE_HAVE_A_TWO_BY_TWO
192                    $ptr += 8;
193                }
194            } while ($flags & 0x0020); // MORE_COMPONENTS
195        }
196
197        return array_keys($resolved);
198    }
199
200    /**
201     * @return int[] GID => offset in glyf table
202     */
203    private function readLocaTable(): array
204    {
205        $locaBase = $this->tables['loca']['offset'];
206        $offsets = [];
207
208        if ($this->indexToLocFormat === 0) {
209            // Short format: uint16 values, multiply by 2
210            for ($i = 0; $i <= $this->numGlyphs; $i++) {
211                $offsets[$i] = $this->readUint16($locaBase + $i * 2) * 2;
212            }
213        } else {
214            // Long format: uint32 values
215            for ($i = 0; $i <= $this->numGlyphs; $i++) {
216                $offsets[$i] = $this->readUint32($locaBase + $i * 4);
217            }
218        }
219
220        return $offsets;
221    }
222
223    /**
224     * @param int[] $glyphIds
225     * @param array<int, int> $gidMap old => new
226     * @return array{data: string, offsets: int[]}
227     */
228    private function buildGlyf(array $glyphIds, array $gidMap): array
229    {
230        $locaOffsets = $this->readLocaTable();
231        $glyfBase = $this->tables['glyf']['offset'];
232
233        $newData = '';
234        $newOffsets = [];
235
236        foreach ($glyphIds as $oldGid) {
237            $newOffsets[] = strlen($newData);
238
239            if ($oldGid >= $this->numGlyphs) {
240                continue;
241            }
242
243            $glyphOffset = $locaOffsets[$oldGid];
244            $glyphLength = $locaOffsets[$oldGid + 1] - $glyphOffset;
245
246            if ($glyphLength === 0) {
247                continue;
248            }
249
250            $glyphData = substr($this->data, $glyfBase + $glyphOffset, $glyphLength);
251            $numberOfContours = $this->readInt16($glyfBase + $glyphOffset);
252
253            if ($numberOfContours < 0) {
254                // Composite glyph - remap component GIDs
255                $glyphData = $this->remapCompositeGlyph($glyphData, $gidMap);
256            }
257
258            // Pad to 4-byte boundary
259            $padding = (4 - (strlen($glyphData) % 4)) % 4;
260            $newData .= $glyphData . str_repeat("\0", $padding);
261        }
262
263        // Final offset (end of last glyph)
264        $newOffsets[] = strlen($newData);
265
266        return ['data' => $newData, 'offsets' => $newOffsets];
267    }
268
269    /**
270     * @param array<int, int> $gidMap
271     */
272    private function remapCompositeGlyph(string $glyphData, array $gidMap): string
273    {
274        $ptr = 10; // skip header
275
276        do {
277            $flags = (ord($glyphData[$ptr]) << 8) | ord($glyphData[$ptr + 1]);
278            $oldComponentGid = (ord($glyphData[$ptr + 2]) << 8) | ord($glyphData[$ptr + 3]);
279            $newComponentGid = $gidMap[$oldComponentGid] ?? 0;
280
281            // Write new GID
282            $glyphData[$ptr + 2] = chr(($newComponentGid >> 8) & 0xFF);
283            $glyphData[$ptr + 3] = chr($newComponentGid & 0xFF);
284
285            $ptr += 4;
286
287            if ($flags & 0x0001) {
288                $ptr += 4;
289            } else {
290                $ptr += 2;
291            }
292
293            if ($flags & 0x0008) {
294                $ptr += 2;
295            } elseif ($flags & 0x0040) {
296                $ptr += 4;
297            } elseif ($flags & 0x0080) {
298                $ptr += 8;
299            }
300        } while ($flags & 0x0020);
301
302        return $glyphData;
303    }
304
305    /**
306     * @param int[] $offsets
307     */
308    private function canUseShortLoca(array $offsets): bool
309    {
310        foreach ($offsets as $o) {
311            if ($o > 0x1FFFE) { // max uint16 * 2
312                return false;
313            }
314            if ($o % 2 !== 0) {
315                return false; // short loca requires even offsets (we pad to 4)
316            }
317        }
318        return true;
319    }
320
321    /**
322     * @param int[] $offsets
323     */
324    private function buildLoca(array $offsets, bool $shortFormat): string
325    {
326        $loca = '';
327        foreach ($offsets as $o) {
328            if ($shortFormat) {
329                $loca .= pack('n', $o / 2);
330            } else {
331                $loca .= pack('N', $o);
332            }
333        }
334        return $loca;
335    }
336
337    /**
338     * @param int[] $glyphIds
339     */
340    private function buildHmtx(array $glyphIds): string
341    {
342        $hmtxBase = $this->tables['hmtx']['offset'];
343        $hmtx = '';
344
345        foreach ($glyphIds as $oldGid) {
346            if ($oldGid < $this->numberOfHMetrics) {
347                $hmtx .= substr($this->data, $hmtxBase + $oldGid * 4, 4);
348            } else {
349                // Use last advance width + lsb from monospaced section
350                $lastAdvanceOffset = $hmtxBase + ($this->numberOfHMetrics - 1) * 4;
351                $advanceWidth = substr($this->data, $lastAdvanceOffset, 2);
352                $lsbOffset = $hmtxBase + $this->numberOfHMetrics * 4 + ($oldGid - $this->numberOfHMetrics) * 2;
353                if ($lsbOffset + 2 <= strlen($this->data)) {
354                    $lsb = substr($this->data, $lsbOffset, 2);
355                } else {
356                    $lsb = "\0\0";
357                }
358                $hmtx .= $advanceWidth . $lsb;
359            }
360        }
361
362        return $hmtx;
363    }
364
365    /**
366     * @param int[] $glyphIds
367     * @param array<int, int> $gidMap old => new
368     * @param array<int, int> $unicodeToGid codepoint => old GID
369     */
370    private function buildCmap(array $glyphIds, array $gidMap, array $unicodeToGid): string
371    {
372        // Filter unicodeToGid to only kept glyphs and remap to new GIDs
373        $mappings = [];
374        foreach ($unicodeToGid as $cp => $oldGid) {
375            if (isset($gidMap[$oldGid])) {
376                $mappings[$cp] = $gidMap[$oldGid];
377            }
378        }
379        ksort($mappings);
380
381        // Check if any codepoint exceeds BMP (U+FFFF)
382        $hasSupplementary = false;
383        foreach ($mappings as $cp => $_) {
384            if ($cp > 0xFFFF) {
385                $hasSupplementary = true;
386                break;
387            }
388        }
389
390        if ($hasSupplementary) {
391            // Use format 12 for full Unicode range
392            $subtable = $this->buildCmapFormat12($mappings);
393
394            // cmap header: version=0, numTables=1
395            $header = pack('nn', 0, 1);
396            // Encoding record: platformID=3 (Windows), encodingID=10 (UCS-4), offset=12
397            $header .= pack('nnN', 3, 10, 12);
398
399            return $header . $subtable;
400        }
401
402        // Build format 4 subtable for BMP-only mappings
403        $format4 = $this->buildCmapFormat4($mappings);
404
405        // cmap header: version=0, numTables=1
406        $header = pack('nn', 0, 1);
407        // Encoding record: platformID=3 (Windows), encodingID=1 (Unicode BMP), offset=12
408        $header .= pack('nnN', 3, 1, 12);
409
410        return $header . $format4;
411    }
412
413    /**
414     * @param array<int, int> $mappings Unicode codepoint => new GID (sorted by codepoint)
415     */
416    private function buildCmapFormat4(array $mappings): string
417    {
418        // Build segments from mappings
419        $segments = [];
420        $glyphIdArray = [];
421
422        if ($mappings !== []) {
423            $codepoints = array_keys($mappings);
424            $segStart = $codepoints[0];
425            $segEnd = $codepoints[0];
426            $segMappings = [$codepoints[0] => $mappings[$codepoints[0]]];
427
428            for ($i = 1; $i < count($codepoints); $i++) {
429                if ($codepoints[$i] === $segEnd + 1) {
430                    $segEnd = $codepoints[$i];
431                    $segMappings[$codepoints[$i]] = $mappings[$codepoints[$i]];
432                } else {
433                    $segments[] = ['start' => $segStart, 'end' => $segEnd, 'mappings' => $segMappings];
434                    $segStart = $codepoints[$i];
435                    $segEnd = $codepoints[$i];
436                    $segMappings = [$codepoints[$i] => $mappings[$codepoints[$i]]];
437                }
438            }
439            $segments[] = ['start' => $segStart, 'end' => $segEnd, 'mappings' => $segMappings];
440        }
441
442        // Add sentinel segment
443        $segments[] = ['start' => 0xFFFF, 'end' => 0xFFFF, 'mappings' => []];
444
445        $segCount = count($segments);
446        $segCountX2 = $segCount * 2;
447        $searchRange = 1;
448        $entrySelector = 0;
449        while ($searchRange * 2 <= $segCount) {
450            $searchRange *= 2;
451            $entrySelector++;
452        }
453        $searchRange *= 2;
454        $rangeShift = $segCountX2 - $searchRange;
455
456        $endCodes = '';
457        $startCodes = '';
458        $idDeltas = '';
459        $idRangeOffsets = '';
460        $glyphIdBytes = '';
461
462        foreach ($segments as $idx => $seg) {
463            $endCodes .= pack('n', $seg['end']);
464            $startCodes .= pack('n', $seg['start']);
465
466            if ($seg['start'] === 0xFFFF) {
467                $idDeltas .= pack('n', 1);
468                $idRangeOffsets .= pack('n', 0);
469                continue;
470            }
471
472            // Check if we can use idDelta (contiguous GID mapping)
473            $canUseDelta = true;
474            $firstCp = $seg['start'];
475            $firstGid = $seg['mappings'][$firstCp];
476            $delta = $firstGid - $firstCp;
477
478            foreach ($seg['mappings'] as $cp => $gid) {
479                if ($gid - $cp !== $delta) {
480                    $canUseDelta = false;
481                    break;
482                }
483            }
484
485            if ($canUseDelta) {
486                $idDeltas .= pack('n', $delta & 0xFFFF);
487                $idRangeOffsets .= pack('n', 0);
488            } else {
489                $idDeltas .= pack('n', 0);
490                // idRangeOffset = byte offset from this position to glyphIdArray entry
491                $remainingSegments = $segCount - $idx;
492                $currentGlyphIdOffset = strlen($glyphIdBytes) / 2;
493                $offset = ($remainingSegments + $currentGlyphIdOffset) * 2;
494                $idRangeOffsets .= pack('n', $offset);
495
496                for ($cp = $seg['start']; $cp <= $seg['end']; $cp++) {
497                    $gid = $seg['mappings'][$cp] ?? 0;
498                    $glyphIdBytes .= pack('n', $gid);
499                }
500            }
501        }
502
503        $subtableLength = 14 + $segCountX2 * 4 + 2 + strlen($glyphIdBytes);
504
505        $header = pack('nnn', 4, $subtableLength, 0); // format, length, language
506        $header .= pack('nnnn', $segCountX2, $searchRange, $entrySelector, $rangeShift);
507
508        return $header . $endCodes . pack('n', 0) . $startCodes . $idDeltas . $idRangeOffsets . $glyphIdBytes;
509    }
510
511    /**
512     * @param array<int, int> $mappings Unicode codepoint => new GID (sorted by codepoint)
513     */
514    private function buildCmapFormat12(array $mappings): string
515    {
516        // Build sequential groups: merge consecutive codepoints with sequential GIDs
517        $groups = [];
518        $codepoints = array_keys($mappings);
519
520        if ($codepoints !== []) {
521            $groupStart = $codepoints[0];
522            $groupEnd = $codepoints[0];
523            $groupStartGid = $mappings[$codepoints[0]];
524
525            for ($i = 1; $i < count($codepoints); $i++) {
526                $cp = $codepoints[$i];
527                $expectedGid = $groupStartGid + ($cp - $groupStart);
528
529                if ($cp === $groupEnd + 1 && $mappings[$cp] === $expectedGid) {
530                    $groupEnd = $cp;
531                } else {
532                    $groups[] = [$groupStart, $groupEnd, $groupStartGid];
533                    $groupStart = $cp;
534                    $groupEnd = $cp;
535                    $groupStartGid = $mappings[$cp];
536                }
537            }
538            $groups[] = [$groupStart, $groupEnd, $groupStartGid];
539        }
540
541        $nGroups = count($groups);
542        // Header: format(2) + reserved(2) + length(4) + language(4) + nGroups(4) = 16 bytes
543        $length = 16 + $nGroups * 12;
544
545        $header = pack('nnNN', 12, 0, $length, 0); // format=12, reserved=0, length, language=0
546        $header .= pack('N', $nGroups);
547
548        $body = '';
549        foreach ($groups as [$startCharCode, $endCharCode, $startGlyphID]) {
550            $body .= pack('NNN', $startCharCode, $endCharCode, $startGlyphID);
551        }
552
553        return $header . $body;
554    }
555
556    private function buildMaxp(int $numGlyphs): string
557    {
558        $maxpData = $this->getTableData('maxp');
559        // Overwrite numGlyphs at offset 4
560        $maxpData[4] = chr(($numGlyphs >> 8) & 0xFF);
561        $maxpData[5] = chr($numGlyphs & 0xFF);
562        return $maxpData;
563    }
564
565    private function buildHhea(int $numberOfHMetrics): string
566    {
567        $hheaData = $this->getTableData('hhea');
568        // Overwrite numberOfHMetrics at offset 34
569        $hheaData[34] = chr(($numberOfHMetrics >> 8) & 0xFF);
570        $hheaData[35] = chr($numberOfHMetrics & 0xFF);
571        return $hheaData;
572    }
573
574    private function buildHead(int $indexToLocFormat): string
575    {
576        $headData = $this->getTableData('head');
577        // Set checkSumAdjustment to 0 at offset 8
578        $headData[8] = "\0";
579        $headData[9] = "\0";
580        $headData[10] = "\0";
581        $headData[11] = "\0";
582        // Set indexToLocFormat at offset 50
583        $headData[50] = chr(($indexToLocFormat >> 8) & 0xFF);
584        $headData[51] = chr($indexToLocFormat & 0xFF);
585        return $headData;
586    }
587
588    private function getTableData(string $tag): string
589    {
590        if (!isset($this->tables[$tag])) {
591            throw new \RuntimeException("Table '{$tag}' not found");
592        }
593        $t = $this->tables[$tag];
594        return substr($this->data, $t['offset'], $t['length']);
595    }
596
597    /**
598     * @param array<string, string> $tableDefs tag => data
599     */
600    private function assembleFont(array $tableDefs): string
601    {
602        $numTables = count($tableDefs);
603        $searchRange = 1;
604        $entrySelector = 0;
605        while ($searchRange * 2 <= $numTables) {
606            $searchRange *= 2;
607            $entrySelector++;
608        }
609        $searchRange *= 16;
610        $rangeShift = $numTables * 16 - $searchRange;
611
612        // Offset table (12 bytes)
613        $header = pack('Nnnnn', 0x00010000, $numTables, $searchRange, $entrySelector, $rangeShift);
614
615        // Calculate data start offset
616        $dataOffset = 12 + $numTables * 16;
617
618        // Build table directory and data
619        $directory = '';
620        $tableData = '';
621
622        foreach ($tableDefs as $tag => $data) {
623            // Pad tag to 4 bytes
624            $paddedTag = str_pad($tag, 4, ' ');
625
626            $checksum = $this->calculateChecksum($data);
627            $offset = $dataOffset + strlen($tableData);
628            $length = strlen($data);
629
630            $directory .= $paddedTag;
631            $directory .= pack('NNN', $checksum, $offset, $length);
632
633            // Pad data to 4-byte boundary
634            $padding = (4 - ($length % 4)) % 4;
635            $tableData .= $data . str_repeat("\0", $padding);
636        }
637
638        $output = $header . $directory . $tableData;
639
640        // Compute and patch checkSumAdjustment in head table.
641        // Per spec: checkSumAdjustment = 0xB1B0AFBA - checksum_of_entire_file
642        $fileChecksum = 0;
643        $padded = $output . str_repeat("\0", (4 - (strlen($output) % 4)) % 4);
644        $padLen = strlen($padded);
645        for ($i = 0; $i < $padLen; $i += 4) {
646            $fileChecksum = ($fileChecksum + $this->readUint32FromString($padded, $i)) & 0xFFFFFFFF;
647        }
648        $adjustment = (0xB1B0AFBA - $fileChecksum) & 0xFFFFFFFF;
649
650        // Find head table offset in the assembled font
651        for ($t = 0; $t < $numTables; $t++) {
652            $dirBase = 12 + $t * 16;
653            $tag = substr($output, $dirBase, 4);
654            if (rtrim($tag) === 'head') {
655                $headOffset = $this->readUint32FromString($output, $dirBase + 8);
656                // checkSumAdjustment is at offset 8 within head table
657                $pos = $headOffset + 8;
658                $output[$pos]     = chr(($adjustment >> 24) & 0xFF);
659                $output[$pos + 1] = chr(($adjustment >> 16) & 0xFF);
660                $output[$pos + 2] = chr(($adjustment >> 8) & 0xFF);
661                $output[$pos + 3] = chr($adjustment & 0xFF);
662                break;
663            }
664        }
665
666        return $output;
667    }
668
669    private function calculateChecksum(string $data): int
670    {
671        // Pad to 4-byte boundary
672        $padding = (4 - (strlen($data) % 4)) % 4;
673        $data .= str_repeat("\0", $padding);
674
675        $sum = 0;
676        $len = strlen($data);
677        for ($i = 0; $i < $len; $i += 4) {
678            $sum = ($sum + $this->readUint32FromString($data, $i)) & 0xFFFFFFFF;
679        }
680        return $sum;
681    }
682
683    private function readUint32FromString(string $data, int $offset): int
684    {
685        return ((ord($data[$offset]) << 24)
686            | (ord($data[$offset + 1]) << 16)
687            | (ord($data[$offset + 2]) << 8)
688            | ord($data[$offset + 3])) & 0xFFFFFFFF;
689    }
690
691    private function readUint16(int $offset): int
692    {
693        return (ord($this->data[$offset]) << 8) | ord($this->data[$offset + 1]);
694    }
695
696    private function readInt16(int $offset): int
697    {
698        $v = $this->readUint16($offset);
699        if ($v >= 0x8000) {
700            $v -= 0x10000;
701        }
702        return $v;
703    }
704
705    private function readUint32(int $offset): int
706    {
707        return ((ord($this->data[$offset]) << 24)
708            | (ord($this->data[$offset + 1]) << 16)
709            | (ord($this->data[$offset + 2]) << 8)
710            | ord($this->data[$offset + 3])) & 0xFFFFFFFF;
711    }
712}