Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.72% |
313 / 345 |
|
56.52% |
13 / 23 |
CRAP | |
0.00% |
0 / 1 |
| TrueTypeSubsetter | |
90.72% |
313 / 345 |
|
56.52% |
13 / 23 |
88.50 | |
0.00% |
0 / 1 |
| subset | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
3 | |||
| getGidMap | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parseTables | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
3 | |||
| resolveComposites | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
10.16 | |||
| readLocaTable | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
| buildGlyf | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
5 | |||
| remapCompositeGlyph | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
5.12 | |||
| canUseShortLoca | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
4.59 | |||
| buildLoca | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| buildHmtx | |
46.15% |
6 / 13 |
|
0.00% |
0 / 1 |
6.50 | |||
| buildCmap | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
| buildCmapFormat4 | |
83.61% |
51 / 61 |
|
0.00% |
0 / 1 |
11.53 | |||
| buildCmapFormat12 | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
6 | |||
| buildMaxp | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| buildHhea | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| buildHead | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| getTableData | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| assembleFont | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
6 | |||
| calculateChecksum | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| readUint32FromString | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| 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 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 13 | class 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 | } |