Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.85% covered (warning)
81.85%
239 / 292
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrueTypeParser
81.85% covered (warning)
81.85%
239 / 292
46.15% covered (danger)
46.15%
6 / 13
120.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromBytes
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 parse
94.41% covered (success)
94.41%
169 / 179
0.00% covered (danger)
0.00%
0 / 1
46.37
 parseCmapFormat4
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
11
 parseCmapFormat12
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 win1252ToUnicode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 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
 readInt32
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parseFvar
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
20
 readFixed
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 tableOffset
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\FontParser;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9class TrueTypeParser
10{
11    private string $data;
12
13    /** @var array<string, array{offset:int, length:int}> */
14    private array $tables = [];
15
16    // Windows-1252 byte => Unicode codepoint for bytes 128-159
17    private const WIN1252_MAP = [
18        128 => 0x20AC,
19        130 => 0x201A,
20        131 => 0x0192,
21        132 => 0x201E,
22        133 => 0x2026,
23        134 => 0x2020,
24        135 => 0x2021,
25        136 => 0x02C6,
26        137 => 0x2030,
27        138 => 0x0160,
28        139 => 0x2039,
29        140 => 0x0152,
30        142 => 0x017D,
31        145 => 0x2018,
32        146 => 0x2019,
33        147 => 0x201C,
34        148 => 0x201D,
35        149 => 0x2022,
36        150 => 0x2013,
37        151 => 0x2014,
38        152 => 0x02DC,
39        153 => 0x2122,
40        154 => 0x0161,
41        155 => 0x203A,
42        156 => 0x0153,
43        158 => 0x017E,
44        159 => 0x0178,
45    ];
46
47    public function __construct(private readonly string $path) {}
48
49    /**
50     * Create a parser from raw font bytes instead of a file path.
51     */
52    public static function fromBytes(string $fontBytes): self
53    {
54        $tmp = tempnam(sys_get_temp_dir(), 'phpdftk_ttf_');
55        if ($tmp === false) {
56            throw new \RuntimeException('Cannot create temp file for font data');
57        }
58        file_put_contents($tmp, $fontBytes);
59        return new self($tmp);
60    }
61
62    public function parse(): TrueTypeData
63    {
64        $this->data = LocalFilesystem::readFile($this->path, "font file");
65
66        // Parse offset table
67        $sfVersion = $this->readUint32(0);
68        if ($sfVersion !== 0x00010000) {
69            throw new \RuntimeException(sprintf(
70                'Not a TrueType font (sfVersion=0x%08X); expected 0x00010000',
71                $sfVersion,
72            ));
73        }
74
75        $numTables = $this->readUint16(4);
76
77        // Parse table directory
78        $dirOffset = 12;
79        for ($i = 0; $i < $numTables; $i++) {
80            $base = $dirOffset + $i * 16;
81            $tag = rtrim(substr($this->data, $base, 4));
82            $tableOffset = $this->readUint32($base + 8);
83            $tableLength = $this->readUint32($base + 12);
84            $this->tables[$tag] = ['offset' => $tableOffset, 'length' => $tableLength];
85        }
86
87        // Parse head table
88        $headBase = $this->tableOffset('head');
89        $unitsPerEm = $this->readUint16($headBase + 18);
90        $xMin = $this->readInt16($headBase + 36);
91        $yMin = $this->readInt16($headBase + 38);
92        $xMax = $this->readInt16($headBase + 40);
93        $yMax = $this->readInt16($headBase + 42);
94
95        // Parse hhea table
96        $hheaBase = $this->tableOffset('hhea');
97        $hheaAscender = $this->readInt16($hheaBase + 4);
98        $hheaDescender = $this->readInt16($hheaBase + 6);
99        $numberOfHMetrics = $this->readUint16($hheaBase + 34);
100
101        // Parse OS/2 table
102        $os2Base = $this->tableOffset('OS/2');
103        $os2Version = $this->readUint16($os2Base + 0);
104        $usWeightClass = $this->readUint16($os2Base + 4);
105        $fsType = $this->readUint16($os2Base + 8);
106        $fsSelection = $this->readUint16($os2Base + 62);
107        $sTypoAscender = $this->readInt16($os2Base + 68);
108        $sTypoDescender = $this->readInt16($os2Base + 70);
109
110        $sxHeight = 0;
111        $sCapHeight = 0;
112        if ($os2Version >= 2) {
113            $sxHeight = $this->readInt16($os2Base + 86);
114            $sCapHeight = $this->readInt16($os2Base + 88);
115        }
116
117        // Parse post table
118        $postBase = $this->tableOffset('post');
119        // italicAngle is a Fixed (int32 / 65536.0) at offset 4
120        $italicAngleFixed = $this->readInt32($postBase + 4);
121        $italicAngle = $italicAngleFixed / 65536.0;
122        $isFixedPitch = $this->readUint32($postBase + 12);
123
124        // Parse name table
125        $nameBase = $this->tableOffset('name');
126        $nameCount = $this->readUint16($nameBase + 2);
127        $nameStringOffset = $this->readUint16($nameBase + 4);
128        $nameStorageBase = $nameBase + $nameStringOffset;
129
130        $familyName = '';
131        $postScriptName = '';
132
133        // Collect all name records, prefer platformID=3,encodingID=1, fallback to platformID=1
134        $nameRecords = [
135            1 => ['win' => null, 'mac' => null],
136            6 => ['win' => null, 'mac' => null],
137        ];
138
139        for ($i = 0; $i < $nameCount; $i++) {
140            $recBase = $nameBase + 6 + $i * 12;
141            $platformID = $this->readUint16($recBase + 0);
142            $encodingID = $this->readUint16($recBase + 2);
143            $nameID = $this->readUint16($recBase + 6);
144            $nameLen = $this->readUint16($recBase + 8);
145            $nameOff = $this->readUint16($recBase + 10);
146
147            if (!isset($nameRecords[$nameID])) {
148                continue;
149            }
150
151            $raw = substr($this->data, $nameStorageBase + $nameOff, $nameLen);
152
153            if ($platformID === 3 && $encodingID === 1) {
154                $nameRecords[$nameID]['win'] = mb_convert_encoding($raw, 'UTF-8', 'UTF-16BE');
155            } elseif ($platformID === 1 && $nameRecords[$nameID]['mac'] === null) {
156                $nameRecords[$nameID]['mac'] = $raw;
157            }
158        }
159
160        $familyName = $nameRecords[1]['win'] ?? $nameRecords[1]['mac'] ?? '';
161        $postScriptName = $nameRecords[6]['win'] ?? $nameRecords[6]['mac'] ?? '';
162
163        // Parse maxp table
164        $maxpBase = $this->tableOffset('maxp');
165        $numGlyphs = $this->readUint16($maxpBase + 4);
166
167        // Parse hmtx table â€” build GID => advanceWidth map
168        $hmtxBase = $this->tableOffset('hmtx');
169        $hmtxWidths = [];
170        $lastAdvanceWidth = 0;
171        for ($gid = 0; $gid < $numberOfHMetrics; $gid++) {
172            $lastAdvanceWidth = $this->readUint16($hmtxBase + $gid * 4);
173            $hmtxWidths[$gid] = $lastAdvanceWidth;
174        }
175        // Glyphs >= numberOfHMetrics reuse last advance width
176        for ($gid = $numberOfHMetrics; $gid < $numGlyphs; $gid++) {
177            $hmtxWidths[$gid] = $lastAdvanceWidth;
178        }
179
180        // Parse cmap table â€” find best Unicode subtable
181        $cmapBase = $this->tableOffset('cmap');
182        $cmapNumTables = $this->readUint16($cmapBase + 2);
183
184        $bestOffset = null;
185        $bestPriority = -1;
186        $bestFormat = 0;
187
188        for ($i = 0; $i < $cmapNumTables; $i++) {
189            $recBase = $cmapBase + 4 + $i * 8;
190            $platID = $this->readUint16($recBase + 0);
191            $encID = $this->readUint16($recBase + 2);
192            $subtableOffset = $this->readUint32($recBase + 4);
193            $subtableFormat = $this->readUint16($cmapBase + $subtableOffset);
194
195            $priority = -1;
196            if ($platID === 3 && $encID === 10 && $subtableFormat === 12) {
197                $priority = 4; // Best: Windows UCS-4 format 12 (full Unicode)
198            } elseif ($platID === 0 && $encID === 4 && $subtableFormat === 12) {
199                $priority = 3; // Unicode full repertoire format 12
200            } elseif ($platID === 3 && $encID === 1) {
201                $priority = 2; // Windows Unicode BMP
202            } elseif ($platID === 0 && $encID === 3) {
203                $priority = 1; // Unicode BMP
204            } elseif ($platID === 0 && $encID === 0) {
205                $priority = 0; // Unicode fallback
206            }
207
208            if ($priority > $bestPriority) {
209                $bestPriority = $priority;
210                $bestOffset = $cmapBase + $subtableOffset;
211                $bestFormat = $subtableFormat;
212            }
213        }
214
215        if ($bestOffset === null) {
216            throw new \RuntimeException('No suitable cmap subtable found in font');
217        }
218
219        if ($bestFormat === 4) {
220            $unicodeToGid = $this->parseCmapFormat4($bestOffset);
221        } elseif ($bestFormat === 12) {
222            $unicodeToGid = $this->parseCmapFormat12($bestOffset);
223        } else {
224            throw new \RuntimeException("Unsupported cmap format {$bestFormat}; only formats 4 and 12 are supported");
225        }
226
227        // Scale helper
228        $scale = fn(int $v): int => (int) round($v * 1000 / $unitsPerEm);
229
230        // Build metrics
231        $ascent = $scale($sTypoAscender !== 0 ? $sTypoAscender : $hheaAscender);
232        $descent = $scale($sTypoDescender !== 0 ? $sTypoDescender : $hheaDescender);
233        $capHeight = $os2Version >= 2 ? $scale($sCapHeight) : (int) round($ascent * 0.7);
234        $xHeight = $os2Version >= 2 ? $scale($sxHeight) : (int) round($ascent * 0.5);
235
236        $stemV = max(50, min(220, 50 + (int) ($usWeightClass / 65.0)));
237
238        // PDF flags bitmask
239        $flags = 0;
240        if ($isFixedPitch !== 0) {
241            $flags |= 1; // bit 0: FixedPitch
242        }
243        $flags |= 32; // bit 5: Nonsymbolic (always set for Latin fonts)
244        if ($italicAngle !== 0.0 || ($fsSelection & 0x01)) {
245            $flags |= 64; // bit 6: Italic
246        }
247        if ($fsSelection & 0x20) {
248            $flags |= 262144; // bit 18: ForceBold
249        }
250
251        $fontBBox = [
252            $scale($xMin),
253            $scale($yMin),
254            $scale($xMax),
255            $scale($yMax),
256        ];
257
258        // Build charWidths and unicodeMap from WinAnsi bytes 32-255
259        $charWidths = [];
260        $unicodeMap = [];
261
262        for ($byte = 32; $byte <= 255; $byte++) {
263            $codepoint = $this->win1252ToUnicode($byte);
264            if ($codepoint === null) {
265                $charWidths[$byte] = 0;
266                continue;
267            }
268
269            if (isset($unicodeToGid[$codepoint])) {
270                $gid = $unicodeToGid[$codepoint];
271                $advance = $hmtxWidths[$gid] ?? 0;
272                $charWidths[$byte] = $scale($advance);
273                $unicodeMap[$byte] = $codepoint;
274            } else {
275                $charWidths[$byte] = 0;
276            }
277        }
278
279        $embeddingAllowed = ($fsType & 0x000E) !== 2;
280
281        // Parse kerning data (GPOS or legacy kern table)
282        $kernPairs = null;
283        if (isset($this->tables['GPOS']) || isset($this->tables['kern'])) {
284            $kernPairs = (new KerningParser())->parse($this->data, $this->tables) ?: null;
285        }
286
287        // Parse GSUB ligature data
288        $ligatures = null;
289        if (isset($this->tables['GSUB'])) {
290            $ligatures = (new GsubParser())->parse($this->data, $this->tables) ?: null;
291        }
292
293        // Parse fvar table for variable font axes and named instances
294        $isVariableFont = isset($this->tables['fvar']);
295        $variationAxes = null;
296        $namedInstances = null;
297        if ($isVariableFont) {
298            [$variationAxes, $namedInstances] = $this->parseFvar();
299        }
300
301        return new TrueTypeData(
302            postScriptName: $postScriptName,
303            familyName: $familyName,
304            ascent: $ascent,
305            descent: $descent,
306            capHeight: $capHeight,
307            xHeight: $xHeight,
308            italicAngle: $italicAngle,
309            stemV: $stemV,
310            flags: $flags,
311            fontBBox: $fontBBox,
312            charWidths: $charWidths,
313            unicodeMap: $unicodeMap,
314            fontBytes: $this->data,
315            embeddingAllowed: $embeddingAllowed,
316            unitsPerEm: $unitsPerEm,
317            fullUnicodeToGid: $unicodeToGid,
318            glyphWidths: $hmtxWidths,
319            kernPairs: $kernPairs,
320            ligatures: $ligatures,
321            isVariableFont: $isVariableFont,
322            variationAxes: $variationAxes,
323            namedInstances: $namedInstances,
324        );
325    }
326
327    /**
328     * @return array<int, int> Unicode codepoint => GID
329     */
330    private function parseCmapFormat4(int $offset): array
331    {
332        $segCountX2 = $this->readUint16($offset + 6);
333        $segCount = $segCountX2 / 2;
334
335        $endCodesBase = $offset + 14;
336        $startCodesBase = $offset + 16 + $segCountX2;
337        $idDeltaBase = $offset + 16 + 2 * $segCountX2;
338        $idRangeOffsetBase = $offset + 16 + 3 * $segCountX2;
339        $glyphIdArrayBase = $offset + 16 + 4 * $segCountX2;
340
341        $endCodes = [];
342        $startCodes = [];
343        $idDelta = [];
344        $idRangeOffset = [];
345
346        for ($i = 0; $i < $segCount; $i++) {
347            $endCodes[$i] = $this->readUint16($endCodesBase + $i * 2);
348            $startCodes[$i] = $this->readUint16($startCodesBase + $i * 2);
349            $idDelta[$i] = $this->readInt16($idDeltaBase + $i * 2);
350            $idRangeOffset[$i] = $this->readUint16($idRangeOffsetBase + $i * 2);
351        }
352
353        $subtableLength = $this->readUint16($offset + 2);
354        $glyphIdArrayLen = ($subtableLength - (16 + 4 * $segCountX2)) / 2;
355
356        $glyphIdArray = [];
357        for ($j = 0; $j < $glyphIdArrayLen; $j++) {
358            $glyphIdArray[$j] = $this->readUint16($glyphIdArrayBase + $j * 2);
359        }
360
361        $unicodeToGid = [];
362        for ($i = 0; $i < $segCount; $i++) {
363            if ($startCodes[$i] === 0xFFFF) {
364                continue;
365            }
366            for ($cp = $startCodes[$i]; $cp <= $endCodes[$i]; $cp++) {
367                if ($idRangeOffset[$i] === 0) {
368                    $gid = ($cp + $idDelta[$i]) & 0xFFFF;
369                } else {
370                    $idx = $idRangeOffset[$i] / 2 + ($cp - $startCodes[$i]) + $i - $segCount;
371                    if ($idx < 0 || $idx >= count($glyphIdArray)) {
372                        $gid = 0;
373                    } else {
374                        $gid = $glyphIdArray[$idx];
375                        if ($gid !== 0) {
376                            $gid = ($gid + $idDelta[$i]) & 0xFFFF;
377                        }
378                    }
379                }
380                if ($gid !== 0) {
381                    $unicodeToGid[$cp] = $gid;
382                }
383            }
384        }
385
386        return $unicodeToGid;
387    }
388
389    /**
390     * @return array<int, int> Unicode codepoint => GID
391     */
392    private function parseCmapFormat12(int $offset): array
393    {
394        $nGroups = $this->readUint32($offset + 12);
395        $map = [];
396        for ($i = 0; $i < $nGroups; $i++) {
397            $base = $offset + 16 + $i * 12;
398            $startCharCode = $this->readUint32($base);
399            $endCharCode = $this->readUint32($base + 4);
400            $startGlyphID = $this->readUint32($base + 8);
401            for ($cp = $startCharCode; $cp <= $endCharCode; $cp++) {
402                $map[$cp] = $startGlyphID + ($cp - $startCharCode);
403            }
404        }
405        return $map;
406    }
407
408    private function win1252ToUnicode(int $byte): ?int
409    {
410        if ($byte >= 32 && $byte <= 127) {
411            return $byte;
412        }
413        if ($byte >= 160 && $byte <= 255) {
414            return $byte;
415        }
416        // bytes 128-159 special mapping
417        return self::WIN1252_MAP[$byte] ?? null;
418    }
419
420    private function readUint16(int $offset): int
421    {
422        return (ord($this->data[$offset]) << 8) | ord($this->data[$offset + 1]);
423    }
424
425    private function readInt16(int $offset): int
426    {
427        $v = $this->readUint16($offset);
428        if ($v >= 0x8000) {
429            $v -= 0x10000;
430        }
431        return $v;
432    }
433
434    private function readUint32(int $offset): int
435    {
436        return ((ord($this->data[$offset]) << 24)
437            | (ord($this->data[$offset + 1]) << 16)
438            | (ord($this->data[$offset + 2]) << 8)
439            | ord($this->data[$offset + 3])) & 0xFFFFFFFF;
440    }
441
442    private function readInt32(int $offset): int
443    {
444        $v = $this->readUint32($offset);
445        if ($v >= 0x80000000) {
446            $v -= 0x100000000;
447        }
448        return (int) $v;
449    }
450
451    /**
452     * Parse the fvar table for variable font axes and named instances.
453     *
454     * fvar table structure (OpenType spec Â§7.3.3):
455     *   - majorVersion (uint16), minorVersion (uint16)
456     *   - axesArrayOffset (uint16) â€” offset to the axis array
457     *   - reserved (uint16)
458     *   - axisCount (uint16)
459     *   - axisSize (uint16) â€” bytes per axis record (20)
460     *   - instanceCount (uint16)
461     *   - instanceSize (uint16) â€” bytes per instance record
462     *
463     * Each axis record (20 bytes):
464     *   - tag (4 bytes), minValue (Fixed), defaultValue (Fixed),
465     *     maxValue (Fixed), flags (uint16), nameId (uint16)
466     *
467     * Each instance record (variable):
468     *   - subfamilyNameId (uint16), flags (uint16),
469     *     coordinates (Fixed Ã— axisCount), [postScriptNameId (uint16)]
470     *
471     * @return array{list<array{tag: string, minValue: float, defaultValue: float, maxValue: float, nameId: int}>, list<array{subfamilyNameId: int, coordinates: array<string, float>}>}
472     */
473    private function parseFvar(): array
474    {
475        $base = $this->tableOffset('fvar');
476
477        // $majorVersion = $this->readUint16($base);
478        // $minorVersion = $this->readUint16($base + 2);
479        $axesArrayOffset = $this->readUint16($base + 4);
480        // $reserved = $this->readUint16($base + 6);
481        $axisCount = $this->readUint16($base + 8);
482        $axisSize = $this->readUint16($base + 10);
483        $instanceCount = $this->readUint16($base + 12);
484        $instanceSize = $this->readUint16($base + 14);
485
486        // Parse axes
487        $axes = [];
488        $axisTags = [];
489        $axisBase = $base + $axesArrayOffset;
490        for ($i = 0; $i < $axisCount; $i++) {
491            $axisOffset = $axisBase + $i * $axisSize;
492            $tag = substr($this->data, $axisOffset, 4);
493            $minValue = $this->readFixed($axisOffset + 4);
494            $defaultValue = $this->readFixed($axisOffset + 8);
495            $maxValue = $this->readFixed($axisOffset + 12);
496            // $flags = $this->readUint16($axisOffset + 16);
497            $nameId = $this->readUint16($axisOffset + 18);
498
499            $axisTags[] = $tag;
500            $axes[] = [
501                'tag' => $tag,
502                'minValue' => $minValue,
503                'defaultValue' => $defaultValue,
504                'maxValue' => $maxValue,
505                'nameId' => $nameId,
506            ];
507        }
508
509        // Parse named instances
510        $instances = [];
511        $instanceBase = $axisBase + $axisCount * $axisSize;
512        for ($i = 0; $i < $instanceCount; $i++) {
513            $instOffset = $instanceBase + $i * $instanceSize;
514            $subfamilyNameId = $this->readUint16($instOffset);
515            // $flags = $this->readUint16($instOffset + 2);
516
517            $coordinates = [];
518            for ($a = 0; $a < $axisCount; $a++) {
519                $coordinates[$axisTags[$a]] = $this->readFixed($instOffset + 4 + $a * 4);
520            }
521
522            $instances[] = [
523                'subfamilyNameId' => $subfamilyNameId,
524                'coordinates' => $coordinates,
525            ];
526        }
527
528        return [$axes, $instances];
529    }
530
531    /**
532     * Read a Fixed (16.16) value as a float.
533     */
534    private function readFixed(int $offset): float
535    {
536        $raw = $this->readInt32($offset);
537        return $raw / 65536.0;
538    }
539
540    private function tableOffset(string $tag): int
541    {
542        if (!isset($this->tables[$tag])) {
543            throw new \RuntimeException("Required table '{$tag}' not found in font");
544        }
545        return $this->tables[$tag]['offset'];
546    }
547}