Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.85% |
239 / 292 |
|
46.15% |
6 / 13 |
CRAP | |
0.00% |
0 / 1 |
| TrueTypeParser | |
81.85% |
239 / 292 |
|
46.15% |
6 / 13 |
120.23 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fromBytes | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| parse | |
94.41% |
169 / 179 |
|
0.00% |
0 / 1 |
46.37 | |||
| parseCmapFormat4 | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
11 | |||
| parseCmapFormat12 | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| win1252ToUnicode | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| readUint16 | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| readInt16 | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| readUint32 | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| readInt32 | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| parseFvar | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
20 | |||
| readFixed | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| tableOffset | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\FontParser; |
| 6 | |
| 7 | use Phpdftk\Filesystem\LocalFilesystem; |
| 8 | |
| 9 | class 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 | } |