Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.46% covered (warning)
77.46%
110 / 142
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Woff2Parser
77.46% covered (warning)
77.46%
110 / 142
77.78% covered (warning)
77.78%
7 / 9
64.16
0.00% covered (danger)
0.00%
0 / 1
 decompress
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decompressBytes
62.67% covered (warning)
62.67%
47 / 75
0.00% covered (danger)
0.00%
0 / 1
37.78
 isWoff2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 detectFlavor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 brotliDecompress
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 readUIntBase128
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 buildSfnt
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
5
 readUint32
100.00% covered (success)
100.00%
1 / 1
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
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\FontParser;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * WOFF 2.0 (Web Open Font Format 2.0) decompressor.
11 *
12 * Parses a WOFF2 container, decompresses the Brotli-compressed table
13 * data, and reconstructs the original sfnt (TrueType/OpenType) bytes.
14 * The result can be passed to TrueTypeParser::fromBytes() or
15 * OpenTypeParser::fromBytes().
16 *
17 * WOFF2 uses Brotli compression and optional table transforms
18 * (glyf/loca/hmtx). This implementation handles the basic decompression
19 * and skips table transforms (the transformed tables are stored as-is
20 * when the transform flag indicates no transformation).
21 *
22 * Brotli decompression requires ext-brotli (PECL). This class deliberately
23 * does not shell out to external `brotli` binaries — font input is treated
24 * as untrusted and the security cost of `proc_open` outweighs the
25 * convenience. If ext-brotli is not installed, decompression throws.
26 *
27 * @see https://www.w3.org/TR/WOFF2/
28 */
29final class Woff2Parser
30{
31    private const WOFF2_SIGNATURE = 0x774F4632; // 'wOF2'
32
33    // Known table tags for compact tag encoding (Table 1 in spec)
34    private const KNOWN_TAGS = [
35        0  => 'cmap', 1  => 'head', 2  => 'hhea', 3  => 'hmtx',
36        4  => 'maxp', 5  => 'name', 6  => 'OS/2', 7  => 'post',
37        8  => 'cvt ', 9  => 'fpgm', 10 => 'glyf', 11 => 'loca',
38        12 => 'prep', 13 => 'CFF ', 14 => 'VORG', 15 => 'EBDT',
39        16 => 'EBLC', 17 => 'gasp', 18 => 'hdmx', 19 => 'kern',
40        20 => 'LTSH', 21 => 'PCLT', 22 => 'VDMX', 23 => 'vhea',
41        24 => 'vmtx', 25 => 'BASE', 26 => 'GDEF', 27 => 'GPOS',
42        28 => 'GSUB', 29 => 'EBSC', 30 => 'JSTF', 31 => 'MATH',
43        32 => 'CBDT', 33 => 'CBLC', 34 => 'COLR', 35 => 'CPAL',
44        36 => 'SVG ', 37 => 'sbix', 38 => 'acnt', 39 => 'avar',
45        40 => 'bdat', 41 => 'bloc', 42 => 'bsln', 43 => 'cvar',
46        44 => 'fdsc', 45 => 'feat', 46 => 'fmtx', 47 => 'fvar',
47        48 => 'gvar', 49 => 'hsty', 50 => 'just', 51 => 'lcar',
48        52 => 'mort', 53 => 'morx', 54 => 'opbd', 55 => 'prop',
49        56 => 'trak', 57 => 'Zapf', 58 => 'Silf', 59 => 'Glat',
50        60 => 'Gloc', 61 => 'Feat', 62 => 'Sill',
51    ];
52
53    /**
54     * Decompress a WOFF2 file to raw sfnt (TTF/OTF) bytes.
55     */
56    public static function decompress(string $woff2Path): string
57    {
58        $data = LocalFilesystem::readFile($woff2Path, "font file");
59        return self::decompressBytes($data);
60    }
61
62    /**
63     * Decompress WOFF2 bytes to raw sfnt (TTF/OTF) bytes.
64     */
65    public static function decompressBytes(string $data): string
66    {
67        if (strlen($data) < 48) {
68            throw new \RuntimeException('WOFF2 data too short for header');
69        }
70
71        // WOFF2 Header (48 bytes)
72        $signature = self::readUint32($data, 0);
73        if ($signature !== self::WOFF2_SIGNATURE) {
74            throw new \RuntimeException(sprintf(
75                'Not a WOFF2 file (signature=0x%08X); expected 0x%08X',
76                $signature,
77                self::WOFF2_SIGNATURE,
78            ));
79        }
80
81        $flavor = self::readUint32($data, 4);           // sfnt flavor
82        // $woffLength = self::readUint32($data, 8);     // total WOFF2 size
83        $numTables = self::readUint16($data, 12);
84        // $reserved = self::readUint16($data, 14);
85        $totalSfntSize = self::readUint32($data, 16);    // uncompressed sfnt size
86        $totalCompressedSize = self::readUint32($data, 20);
87        // $majorVersion = self::readUint16($data, 24);
88        // $minorVersion = self::readUint16($data, 26);
89        // $metaOffset = self::readUint32($data, 28);
90        // $metaLength = self::readUint32($data, 32);
91        // $metaOrigLength = self::readUint32($data, 36);
92        // $privOffset = self::readUint32($data, 40);
93        // $privLength = self::readUint32($data, 44);
94
95        // Parse table directory (variable-length entries starting at offset 48)
96        $offset = 48;
97        $tables = [];
98
99        for ($i = 0; $i < $numTables; $i++) {
100            if ($offset >= strlen($data)) {
101                throw new \RuntimeException('WOFF2 table directory truncated');
102            }
103
104            $flags = ord($data[$offset]);
105            $offset++;
106
107            // Bits 0-5: tag index (0-62 = known tag, 63 = arbitrary 4-byte tag)
108            $tagIndex = $flags & 0x3F;
109            if ($tagIndex === 63) {
110                // Read 4-byte tag
111                $tag = substr($data, $offset, 4);
112                $offset += 4;
113            } else {
114                $tag = $tagIndex < count(self::KNOWN_TAGS)
115                    ? self::KNOWN_TAGS[$tagIndex]
116                    : sprintf('T%03d', $tagIndex);
117            }
118
119            // Bits 6-7: preprocessing transform (0=none for most, or transform-specific)
120            $transformVersion = ($flags >> 6) & 0x03;
121
122            // origLength (UIntBase128)
123            [$origLength, $offset] = self::readUIntBase128($data, $offset);
124
125            // transformLength only present when transform is applied
126            // For glyf and loca, transform version 0 means transformed (default)
127            // For other tables, transform version 0 means no transform
128            $transformLength = null;
129            $isTransformed = false;
130            if ($tag === 'glyf' || $tag === 'loca') {
131                $isTransformed = ($transformVersion === 0); // 0 = transformed for glyf/loca
132                if ($isTransformed) {
133                    [$transformLength, $offset] = self::readUIntBase128($data, $offset);
134                }
135            } elseif ($transformVersion !== 0) {
136                $isTransformed = true;
137                [$transformLength, $offset] = self::readUIntBase128($data, $offset);
138            }
139
140            $tables[] = [
141                'tag' => $tag,
142                'flags' => $flags,
143                'transformVersion' => $transformVersion,
144                'origLength' => $origLength,
145                'transformLength' => $transformLength,
146                'isTransformed' => $isTransformed,
147            ];
148        }
149
150        // The compressed data stream starts right after the table directory
151        $compressedData = substr($data, $offset, $totalCompressedSize);
152
153        // Decompress with Brotli
154        $decompressed = self::brotliDecompress($compressedData);
155
156        // Split the decompressed stream into individual tables
157        $streamOffset = 0;
158        $decompressedTables = [];
159
160        // WOFF2 §4.1 — `origLength` / `transformLength` are UIntBase128
161        // values read straight out of the file, so a malformed font can
162        // declare a table larger than any real font. The reconstructed
163        // sfnt cannot exceed the header's `totalSfntSize`, and neither
164        // can any single table within it, so that is the bound. Without
165        // it a declared 0xFFFFFFFF sends `str_pad()` below at a 4 GB
166        // allocation and the process dies on an untrusted input — the
167        // WOFF2 conformance suite supplies exactly such files and
168        // requires them to be REJECTED, not to abort the renderer.
169        $maxTableLength = max($totalSfntSize, strlen($decompressed));
170        foreach ($tables as $table) {
171            $tableLength = $table['isTransformed']
172                ? ($table['transformLength'] ?? $table['origLength'])
173                : $table['origLength'];
174
175            if ($tableLength > $maxTableLength || $table['origLength'] > $maxTableLength) {
176                throw new \RuntimeException(sprintf(
177                    'WOFF2 table `%s` declares length %d, exceeding the %d-byte reconstructed font',
178                    $table['tag'],
179                    max($tableLength, $table['origLength']),
180                    $maxTableLength,
181                ));
182            }
183
184            if ($streamOffset + $tableLength > strlen($decompressed)) {
185                // Truncated — use what we have
186                $tableData = substr($decompressed, $streamOffset);
187            } else {
188                $tableData = substr($decompressed, $streamOffset, $tableLength);
189            }
190            $streamOffset += $tableLength;
191
192            // For transformed glyf/loca tables, we store them as-is.
193            // A full implementation would reverse the transform, but for
194            // PDF embedding purposes the font bytes are re-parsed from
195            // the reconstructed sfnt which handles this.
196            // If the table is NOT transformed, origLength == actual length.
197            // If transformed, we need to pad/truncate to origLength.
198            if ($table['isTransformed'] && ($table['tag'] === 'glyf' || $table['tag'] === 'loca')) {
199                // Skip transformed glyf/loca — they can't be directly embedded
200                // Instead, pad to origLength for sfnt reconstruction
201                $tableData = str_pad($tableData, $table['origLength'], "\x00");
202                $tableData = substr($tableData, 0, $table['origLength']);
203            }
204
205            $decompressedTables[] = [
206                'tag' => $table['tag'],
207                'checksum' => 0, // will be recalculated
208                'data' => substr($tableData, 0, $table['origLength']),
209            ];
210        }
211
212        // Reconstruct sfnt
213        return self::buildSfnt($flavor, $decompressedTables);
214    }
215
216    /**
217     * Detect whether bytes are a WOFF2 file.
218     */
219    public static function isWoff2(string $data): bool
220    {
221        return strlen($data) >= 4 && self::readUint32($data, 0) === self::WOFF2_SIGNATURE;
222    }
223
224    /**
225     * Detect the flavor (TrueType or OpenType CFF) of a WOFF2 file.
226     */
227    public static function detectFlavor(string $data): string
228    {
229        if (strlen($data) < 8) {
230            return 'unknown';
231        }
232        $flavor = self::readUint32($data, 4);
233        return match ($flavor) {
234            0x00010000 => 'truetype',
235            0x4F54544F => 'opentype',
236            default => 'unknown',
237        };
238    }
239
240    /**
241     * Decompress data using Brotli.
242     *
243     * Uses ext-brotli. Production code intentionally does not shell out
244     * to external Brotli binaries for untrusted font input.
245     */
246    private static function brotliDecompress(string $data): string
247    {
248        if (function_exists('brotli_uncompress')) {
249            // Suppressed: a malformed or truncated stream is an
250            // EXPECTED input here — the WOFF2 conformance suite feeds
251            // deliberately broken files and requires them to be
252            // rejected. The false return is checked immediately and
253            // turned into an exception; the raw PHP warning would
254            // otherwise corrupt any caller writing machine-readable
255            // output to stdout.
256            $result = @brotli_uncompress($data);
257            if ($result === false) {
258                throw new \RuntimeException('Brotli decompression failed (ext-brotli)');
259            }
260            return $result;
261        }
262
263        throw new \RuntimeException(
264            'WOFF2 requires Brotli decompression. Install ext-brotli.',
265        );
266    }
267
268    /**
269     * Read a UIntBase128 variable-length integer (WOFF2 spec §5.2).
270     *
271     * @return array{int, int} [value, newOffset]
272     */
273    private static function readUIntBase128(string $data, int $offset): array
274    {
275        $result = 0;
276        for ($i = 0; $i < 5; $i++) {
277            if ($offset >= strlen($data)) {
278                throw new \RuntimeException('UIntBase128 read past end of data');
279            }
280            $byte = ord($data[$offset]);
281            $offset++;
282
283            // Leading zeros are not allowed (except for value 0)
284            if ($i === 0 && $byte === 0x80) {
285                throw new \RuntimeException('UIntBase128 has leading zero');
286            }
287
288            $result = ($result << 7) | ($byte & 0x7F);
289
290            if (($byte & 0x80) === 0) {
291                return [$result, $offset];
292            }
293        }
294        throw new \RuntimeException('UIntBase128 exceeds 5 bytes');
295    }
296
297    /**
298     * Reconstruct an sfnt file from decompressed tables.
299     *
300     * Reuses the same logic as WoffParser::buildSfnt().
301     */
302    /** @param array<array{tag: string, checksum: int, data: string}> $tables */
303    private static function buildSfnt(int $flavor, array $tables): string
304    {
305        $numTables = count($tables);
306        if ($numTables === 0) {
307            throw new \RuntimeException('No tables found in WOFF2 data');
308        }
309
310        $entrySelector = (int) floor(log($numTables, 2));
311        $searchRange = (int) pow(2, $entrySelector) * 16;
312        $rangeShift = $numTables * 16 - $searchRange;
313
314        $headerSize = 12 + $numTables * 16;
315
316        $offset = $headerSize;
317        $tableEntries = [];
318        foreach ($tables as $table) {
319            $tableEntries[] = [
320                'tag' => $table['tag'],
321                'checksum' => $table['checksum'],
322                'offset' => $offset,
323                'length' => strlen($table['data']),
324                'data' => $table['data'],
325            ];
326            $offset += strlen($table['data']);
327            $offset += (4 - ($offset % 4)) % 4;
328        }
329
330        $sfnt = '';
331        $sfnt .= pack('N', $flavor);
332        $sfnt .= pack('n', $numTables);
333        $sfnt .= pack('n', $searchRange);
334        $sfnt .= pack('n', $entrySelector);
335        $sfnt .= pack('n', $rangeShift);
336
337        foreach ($tableEntries as $entry) {
338            $sfnt .= str_pad($entry['tag'], 4, "\x00");
339            $sfnt .= pack('N', $entry['checksum']);
340            $sfnt .= pack('N', $entry['offset']);
341            $sfnt .= pack('N', $entry['length']);
342        }
343
344        foreach ($tableEntries as $entry) {
345            $sfnt .= $entry['data'];
346            $padding = (4 - (strlen($entry['data']) % 4)) % 4;
347            $sfnt .= str_repeat("\x00", $padding);
348        }
349
350        return $sfnt;
351    }
352
353    private static function readUint32(string $data, int $offset): int
354    {
355        return unpack('N', $data, $offset)[1];
356    }
357
358    private static function readUint16(string $data, int $offset): int
359    {
360        return unpack('n', $data, $offset)[1];
361    }
362}