Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.22% covered (warning)
89.22%
91 / 102
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WoffParser
89.22% covered (warning)
89.22%
91 / 102
71.43% covered (warning)
71.43%
5 / 7
23.66
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
82.14% covered (warning)
82.14%
46 / 56
0.00% covered (danger)
0.00%
0 / 1
9.46
 isWoff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 detectFlavor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 buildSfnt
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
4
 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 (Web Open Font Format 1.0) decompressor.
11 *
12 * Parses a WOFF container, decompresses each table, and reconstructs
13 * the original sfnt (TrueType/OpenType) font bytes. The result can be
14 * passed to TrueTypeParser::fromBytes() or OpenTypeParser::fromBytes().
15 *
16 * WOFF 1.0 uses zlib (gzcompress) for per-table compression.
17 *
18 * @see https://www.w3.org/TR/WOFF/
19 */
20final class WoffParser
21{
22    private const WOFF_SIGNATURE = 0x774F4646; // 'wOFF'
23
24    /**
25     * Decompress a WOFF file to raw sfnt (TTF/OTF) bytes.
26     *
27     * @param string $woffPath Path to the WOFF file
28     * @return string Raw sfnt bytes
29     */
30    public static function decompress(string $woffPath): string
31    {
32        $data = LocalFilesystem::readFile($woffPath, "font file");
33
34        return self::decompressBytes($data);
35    }
36
37    /**
38     * Decompress WOFF bytes to raw sfnt (TTF/OTF) bytes.
39     */
40    public static function decompressBytes(string $data): string
41    {
42        if (strlen($data) < 44) {
43            throw new \RuntimeException('WOFF data too short for header');
44        }
45
46        // WOFF Header (44 bytes)
47        $signature = self::readUint32($data, 0);
48        if ($signature !== self::WOFF_SIGNATURE) {
49            throw new \RuntimeException(sprintf(
50                'Not a WOFF file (signature=0x%08X); expected 0x%08X',
51                $signature,
52                self::WOFF_SIGNATURE,
53            ));
54        }
55
56        $flavor = self::readUint32($data, 4);         // Original sfVersion
57        // $length = self::readUint32($data, 8);       // Total WOFF file size
58        $numTables = self::readUint16($data, 12);
59        // $reserved = self::readUint16($data, 14);    // Must be 0
60        $totalSfntSize = self::readUint32($data, 16);  // Original sfnt total size
61        // Remaining header fields: majorVersion, minorVersion, metaOffset, metaLength, metaOrigLength, privOffset, privLength
62
63        // Parse table directory (20 bytes per entry, starting at offset 44)
64        $tables = [];
65        for ($i = 0; $i < $numTables; $i++) {
66            $entryOffset = 44 + $i * 20;
67            if ($entryOffset + 20 > strlen($data)) {
68                throw new \RuntimeException('WOFF table directory truncated');
69            }
70
71            $tag = substr($data, $entryOffset, 4);
72            $woffOffset = self::readUint32($data, $entryOffset + 4);
73            $compLength = self::readUint32($data, $entryOffset + 8);
74            $origLength = self::readUint32($data, $entryOffset + 12);
75            $origChecksum = self::readUint32($data, $entryOffset + 16);
76
77            $tables[] = [
78                'tag' => $tag,
79                'offset' => $woffOffset,
80                'compLength' => $compLength,
81                'origLength' => $origLength,
82                'checksum' => $origChecksum,
83            ];
84        }
85
86        // Decompress each table
87        $decompressedTables = [];
88        foreach ($tables as $table) {
89            $compressed = substr($data, $table['offset'], $table['compLength']);
90
91            if ($table['compLength'] === $table['origLength']) {
92                // Not compressed â€” use raw data
93                $decompressedTables[] = [
94                    'tag' => $table['tag'],
95                    'checksum' => $table['checksum'],
96                    'data' => $compressed,
97                ];
98            } else {
99                // zlib compressed
100                $decompressed = @gzuncompress($compressed);
101                if ($decompressed === false) {
102                    throw new \RuntimeException(
103                        "Failed to decompress WOFF table '{$table['tag']}'",
104                    );
105                }
106                if (strlen($decompressed) !== $table['origLength']) {
107                    throw new \RuntimeException(sprintf(
108                        "Decompressed size mismatch for table '%s': got %d, expected %d",
109                        $table['tag'],
110                        strlen($decompressed),
111                        $table['origLength'],
112                    ));
113                }
114                $decompressedTables[] = [
115                    'tag' => $table['tag'],
116                    'checksum' => $table['checksum'],
117                    'data' => $decompressed,
118                ];
119            }
120        }
121
122        // Reconstruct sfnt
123        return self::buildSfnt($flavor, $decompressedTables);
124    }
125
126    /**
127     * Detect whether bytes are a WOFF file.
128     */
129    public static function isWoff(string $data): bool
130    {
131        return strlen($data) >= 4 && self::readUint32($data, 0) === self::WOFF_SIGNATURE;
132    }
133
134    /**
135     * Detect the flavor (TrueType or OpenType CFF) of a WOFF file.
136     *
137     * @return string 'truetype' or 'opentype', or 'unknown'
138     */
139    public static function detectFlavor(string $data): string
140    {
141        if (strlen($data) < 8) {
142            return 'unknown';
143        }
144        $flavor = self::readUint32($data, 4);
145        return match ($flavor) {
146            0x00010000 => 'truetype',
147            0x4F54544F => 'opentype',
148            default => 'unknown',
149        };
150    }
151
152    /**
153     * Reconstruct an sfnt file from decompressed tables.
154     *
155     * @param int $flavor Original sfVersion
156     * @param array<array{tag: string, checksum: int, data: string}> $tables
157     */
158    private static function buildSfnt(int $flavor, array $tables): string
159    {
160        $numTables = count($tables);
161
162        // Compute search parameters
163        $entrySelector = (int) floor(log($numTables, 2));
164        $searchRange = (int) pow(2, $entrySelector) * 16;
165        $rangeShift = $numTables * 16 - $searchRange;
166
167        // Offset table (12 bytes) + table directory (16 bytes per table)
168        $headerSize = 12 + $numTables * 16;
169
170        // Compute table offsets (4-byte aligned)
171        $offset = $headerSize;
172        $tableEntries = [];
173        foreach ($tables as $table) {
174            $tableEntries[] = [
175                'tag' => $table['tag'],
176                'checksum' => $table['checksum'],
177                'offset' => $offset,
178                'length' => strlen($table['data']),
179                'data' => $table['data'],
180            ];
181            // Pad to 4-byte boundary
182            $offset += strlen($table['data']);
183            $padding = (4 - ($offset % 4)) % 4;
184            $offset += $padding;
185        }
186
187        // Build the sfnt
188        $sfnt = '';
189
190        // Offset table
191        $sfnt .= pack('N', $flavor);
192        $sfnt .= pack('n', $numTables);
193        $sfnt .= pack('n', $searchRange);
194        $sfnt .= pack('n', $entrySelector);
195        $sfnt .= pack('n', $rangeShift);
196
197        // Table directory
198        foreach ($tableEntries as $entry) {
199            $sfnt .= $entry['tag'];
200            $sfnt .= pack('N', $entry['checksum']);
201            $sfnt .= pack('N', $entry['offset']);
202            $sfnt .= pack('N', $entry['length']);
203        }
204
205        // Table data (4-byte aligned)
206        foreach ($tableEntries as $entry) {
207            $sfnt .= $entry['data'];
208            $padding = (4 - (strlen($entry['data']) % 4)) % 4;
209            $sfnt .= str_repeat("\x00", $padding);
210        }
211
212        return $sfnt;
213    }
214
215    private static function readUint32(string $data, int $offset): int
216    {
217        return unpack('N', $data, $offset)[1];
218    }
219
220    private static function readUint16(string $data, int $offset): int
221    {
222        return unpack('n', $data, $offset)[1];
223    }
224}