Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.64% covered (warning)
82.64%
200 / 242
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
CffSubsetter
82.64% covered (warning)
82.64%
200 / 242
41.67% covered (danger)
41.67%
5 / 12
138.21
0.00% covered (danger)
0.00%
0 / 1
 subset
78.57% covered (warning)
78.57%
55 / 70
0.00% covered (danger)
0.00%
0 / 1
5.25
 getGidMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildIndex
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
8.51
 encodeOffset
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 buildTopDict
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 encodeDictEntry
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 encodeDictInteger
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
10.36
 encode5ByteInt
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 encodeDictReal
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
10.93
 buildCharset
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 patchPrivateDictLocalSubr
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 parseDictDataDirect
78.26% covered (warning)
78.26%
54 / 69
0.00% covered (danger)
0.00%
0 / 1
51.06
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\FontParser;
6
7/**
8 * Produces a minimal CFF table containing only requested glyphs.
9 *
10 * Takes raw CFF table bytes and a set of glyph IDs, and emits a new
11 * CFF with only those glyph charstrings and charset entries.
12 *
13 * All subroutines (Global + Local) are preserved intact to avoid
14 * charstring bytecode analysis. Uses 5-byte integer encoding for
15 * offset operands in the Top DICT to guarantee stable sizing.
16 */
17final class CffSubsetter
18{
19    /**
20     * Old GID → new GID map, populated by the most recent `subset()` call.
21     * Callers use this to translate pre-subset GIDs (e.g. from a parsed
22     * font's `fullUnicodeToGid`) into the renumbered GIDs that live in
23     * the emitted subset.
24     *
25     * @var array<int, int>
26     */
27    private array $gidMap = [];
28
29    /**
30     * @param string $cffBytes Raw CFF table bytes
31     * @param int[]  $glyphIds GIDs to keep (GID 0 is always included)
32     * @return string Subset CFF bytes
33     */
34    public function subset(string $cffBytes, array $glyphIds): string
35    {
36        $parser = new CffParser();
37        $cffData = $parser->parse($cffBytes);
38
39        // Always include GID 0, deduplicate and sort
40        $glyphIds = array_unique(array_merge([0], $glyphIds));
41        sort($glyphIds);
42
43        // Filter to valid GIDs
44        $nOrigGlyphs = count($cffData->charStrings);
45        $glyphIds = array_filter($glyphIds, fn(int $gid): bool => $gid < $nOrigGlyphs);
46        $glyphIds = array_values($glyphIds);
47
48        // Build subset charstrings
49        $subsetCharStrings = [];
50        foreach ($glyphIds as $oldGid) {
51            $subsetCharStrings[] = $cffData->charStrings[$oldGid];
52        }
53
54        // Build subset charset (format 0: simple SID list) and record
55        // the old → new GID map so callers can remap any pre-subset
56        // GIDs they hold.
57        $subsetCharset = [];
58        $this->gidMap = [];
59        foreach ($glyphIds as $newGid => $oldGid) {
60            $subsetCharset[$newGid] = $cffData->charset[$oldGid] ?? $oldGid;
61            $this->gidMap[$oldGid] = $newGid;
62        }
63
64        // Build the output CFF
65        // Structure: Header + Name INDEX + Top DICT INDEX + String INDEX +
66        //            Global Subr INDEX + Charset + CharStrings INDEX +
67        //            Private DICT + Local Subr INDEX
68
69        // Header (4 bytes)
70        $header = chr($cffData->major) . chr($cffData->minor) . chr(4) . chr(1);
71
72        // Name INDEX (preserved as-is)
73        $nameIndex = $cffData->nameIndexData;
74
75        // String INDEX (preserved as-is)
76        $stringIndex = $cffData->stringIndexData;
77
78        // Global Subr INDEX (preserved as-is)
79        $globalSubrIndex = $cffData->globalSubrIndexData;
80
81        // Build Charset (format 0)
82        $charsetData = $this->buildCharset($subsetCharset);
83
84        // Build CharStrings INDEX
85        $charStringsIndex = $this->buildIndex($subsetCharStrings);
86
87        // Private DICT + Local Subr INDEX (preserved as-is)
88        $privateDict = $cffData->privateDictData;
89        $localSubrIndex = $cffData->localSubrIndexData;
90
91        // Patch the Private DICT's local subr offset BEFORE measuring sizes,
92        // since patching can change the Private DICT length.
93        if ($localSubrIndex !== '') {
94            $privateDict = $this->patchPrivateDictLocalSubr($privateDict, strlen($privateDict));
95        }
96
97        // Layout: Header + Name INDEX + Top DICT INDEX + String INDEX +
98        //         Global Subr INDEX + Charset + CharStrings INDEX +
99        //         Private DICT + Local Subr INDEX
100        //
101        // We use 5-byte integers for all offset operands so the Top DICT
102        // has a deterministic size.
103
104        // Private size in Top DICT op 18 is ONLY the Private DICT size
105        // (local subrs are located via op 19 inside Private DICT)
106        $privateSize = strlen($privateDict);
107
108        $topDictContent = $this->buildTopDict(
109            $cffData->topDictOperators,
110            charsetOffset: 0,         // placeholder
111            charStringsOffset: 0,     // placeholder
112            privateSize: $privateSize,
113            privateOffset: 0,         // placeholder
114            nGlyphs: count($subsetCharStrings),
115        );
116
117        $topDictIndex = $this->buildIndex([$topDictContent]);
118
119        // Calculate actual offsets
120        $afterTopDict = strlen($header) + strlen($nameIndex) + strlen($topDictIndex);
121        $afterStringIndex = $afterTopDict + strlen($stringIndex);
122        $afterGlobalSubr = $afterStringIndex + strlen($globalSubrIndex);
123        $charsetOffset = $afterGlobalSubr;
124        $charStringsOffset = $charsetOffset + strlen($charsetData);
125        $privateOffset = $charStringsOffset + strlen($charStringsIndex);
126
127        // Rebuild Top DICT with real offsets
128        $topDictContent = $this->buildTopDict(
129            $cffData->topDictOperators,
130            charsetOffset: $charsetOffset,
131            charStringsOffset: $charStringsOffset,
132            privateSize: $privateSize,
133            privateOffset: $privateOffset,
134            nGlyphs: count($subsetCharStrings),
135        );
136        $topDictIndex = $this->buildIndex([$topDictContent]);
137
138        // Recalculate offsets — Top DICT INDEX size should be stable since
139        // we use 5-byte integers, but verify and recalculate if needed
140        $newAfterTopDict = strlen($header) + strlen($nameIndex) + strlen($topDictIndex);
141        if ($newAfterTopDict !== $afterTopDict) {
142            $afterTopDict = $newAfterTopDict;
143            $afterStringIndex = $afterTopDict + strlen($stringIndex);
144            $afterGlobalSubr = $afterStringIndex + strlen($globalSubrIndex);
145            $charsetOffset = $afterGlobalSubr;
146            $charStringsOffset = $charsetOffset + strlen($charsetData);
147            $privateOffset = $charStringsOffset + strlen($charStringsIndex);
148
149            $topDictContent = $this->buildTopDict(
150                $cffData->topDictOperators,
151                charsetOffset: $charsetOffset,
152                charStringsOffset: $charStringsOffset,
153                privateSize: $privateSize,
154                privateOffset: $privateOffset,
155                nGlyphs: count($subsetCharStrings),
156            );
157            $topDictIndex = $this->buildIndex([$topDictContent]);
158        }
159
160        return $header . $nameIndex . $topDictIndex . $stringIndex
161            . $globalSubrIndex . $charsetData . $charStringsIndex
162            . $privateDict . $localSubrIndex;
163    }
164
165    /**
166     * The old → new GID map from the most recent `subset()` call. Returns
167     * an empty array if `subset()` has not been called yet.
168     *
169     * @return array<int, int>
170     */
171    public function getGidMap(): array
172    {
173        return $this->gidMap;
174    }
175
176    /**
177     * Build a CFF INDEX from an array of byte entries.
178     *
179     * @param string[] $entries
180     */
181    private function buildIndex(array $entries): string
182    {
183        $count = count($entries);
184        if ($count === 0) {
185            return pack('n', 0); // count=0, no further data
186        }
187
188        // Calculate offsets (1-based)
189        $offsets = [1]; // first entry starts at offset 1
190        foreach ($entries as $entry) {
191            $offsets[] = end($offsets) + strlen($entry);
192        }
193
194        // Determine offSize
195        $maxOffset = end($offsets);
196        if ($maxOffset <= 0xFF) {
197            $offSize = 1;
198        } elseif ($maxOffset <= 0xFFFF) {
199            $offSize = 2;
200        } elseif ($maxOffset <= 0xFFFFFF) {
201            $offSize = 3;
202        } else {
203            $offSize = 4;
204        }
205
206        // Header: count(2) + offSize(1)
207        $result = pack('n', $count) . chr($offSize);
208
209        // Offsets
210        foreach ($offsets as $off) {
211            $result .= $this->encodeOffset($off, $offSize);
212        }
213
214        // Data
215        foreach ($entries as $entry) {
216            $result .= $entry;
217        }
218
219        return $result;
220    }
221
222    private function encodeOffset(int $offset, int $offSize): string
223    {
224        return match ($offSize) {
225            1 => chr($offset),
226            2 => pack('n', $offset),
227            3 => chr(($offset >> 16) & 0xFF) . chr(($offset >> 8) & 0xFF) . chr($offset & 0xFF),
228            default => pack('N', $offset),
229        };
230    }
231
232    /**
233     * Build Top DICT with patched offset operands.
234     *
235     * Preserves all original operators except those that reference
236     * offsets into the CFF data (charset, CharStrings, Private).
237     * Uses 5-byte integer encoding for offset values.
238     *
239     * @param array<int|string, int|float|array<int, int|float>> $originalOps
240     */
241    private function buildTopDict(
242        array $originalOps,
243        int $charsetOffset,
244        int $charStringsOffset,
245        int $privateSize,
246        int $privateOffset,
247        int $nGlyphs,
248    ): string {
249        $result = '';
250
251        // Operators to skip (we'll emit them with patched values)
252        $patchedOps = [15, 16, 17, 18, '12.36', '12.37'];
253
254        foreach ($originalOps as $op => $operands) {
255            if (in_array($op, $patchedOps, true)) {
256                continue;
257            }
258            $result .= $this->encodeDictEntry($op, $operands);
259        }
260
261        // Emit patched offset operators with 5-byte integers
262        // Charset (15)
263        $result .= $this->encode5ByteInt($charsetOffset) . chr(15);
264
265        // CharStrings (17)
266        $result .= $this->encode5ByteInt($charStringsOffset) . chr(17);
267
268        // Private (18) — [size, offset]
269        $result .= $this->encode5ByteInt($privateSize) . $this->encode5ByteInt($privateOffset) . chr(18);
270
271        // Skip Encoding (16), FDArray (12.36), FDSelect (12.37) — not needed for CIDFontType0C subsets
272
273        return $result;
274    }
275
276    /**
277     * Encode a DICT entry (operands + operator).
278     *
279     * @param int|string $op
280     * @param int|float|array<int, int|float> $operands
281     */
282    private function encodeDictEntry(int|string $op, int|float|array $operands): string
283    {
284        $result = '';
285        $operandList = is_array($operands) ? $operands : [$operands];
286
287        foreach ($operandList as $val) {
288            if (is_float($val)) {
289                $result .= $this->encodeDictReal($val);
290            } else {
291                $result .= $this->encodeDictInteger((int) $val);
292            }
293        }
294
295        // Encode operator
296        if (is_string($op) && str_starts_with($op, '12.')) {
297            $b1 = (int) substr($op, 3);
298            $result .= chr(12) . chr($b1);
299        } else {
300            $result .= chr((int) $op);
301        }
302
303        return $result;
304    }
305
306    /**
307     * Encode a DICT integer using the most compact representation.
308     */
309    private function encodeDictInteger(int $value): string
310    {
311        if ($value >= -107 && $value <= 107) {
312            return chr($value + 139);
313        }
314        if ($value >= 108 && $value <= 1131) {
315            $value -= 108;
316            return chr(247 + ($value >> 8)) . chr($value & 0xFF);
317        }
318        if ($value >= -1131 && $value <= -108) {
319            $value = -$value - 108;
320            return chr(251 + ($value >> 8)) . chr($value & 0xFF);
321        }
322        if ($value >= -32768 && $value <= 32767) {
323            if ($value < 0) {
324                $value += 0x10000;
325            }
326            return chr(28) . chr(($value >> 8) & 0xFF) . chr($value & 0xFF);
327        }
328        // 5-byte integer
329        return $this->encode5ByteInt($value);
330    }
331
332    /**
333     * Encode a 5-byte DICT integer (operator 29 + 4 bytes big-endian).
334     */
335    private function encode5ByteInt(int $value): string
336    {
337        if ($value < 0) {
338            $value = (int) ($value + 0x100000000);
339        }
340        return chr(29)
341            . chr(($value >> 24) & 0xFF)
342            . chr(($value >> 16) & 0xFF)
343            . chr(($value >> 8) & 0xFF)
344            . chr($value & 0xFF);
345    }
346
347    /**
348     * Encode a DICT real number.
349     */
350    private function encodeDictReal(float $value): string
351    {
352        $str = rtrim(rtrim(sprintf('%.10f', $value), '0'), '.');
353        if ($str === '') {
354            $str = '0';
355        }
356
357        $nibbles = [];
358        for ($i = 0; $i < strlen($str); $i++) {
359            $ch = $str[$i];
360            $nibbles[] = match ($ch) {
361                '0','1','2','3','4','5','6','7','8','9' => (int) $ch,
362                '.' => 0x0A,
363                '-' => 0x0E,
364                'E','e' => 0x0B,
365                default => 0x0D, // reserved
366            };
367        }
368        $nibbles[] = 0x0F; // end
369
370        // Pad to even number of nibbles
371        if (count($nibbles) % 2 !== 0) {
372            $nibbles[] = 0x0F;
373        }
374
375        $result = chr(30);
376        for ($i = 0; $i < count($nibbles); $i += 2) {
377            $result .= chr(($nibbles[$i] << 4) | $nibbles[$i + 1]);
378        }
379        return $result;
380    }
381
382    /**
383     * Build Charset in format 0 (simple SID list).
384     *
385     * @param array<int, int> $charset GID => SID
386     */
387    private function buildCharset(array $charset): string
388    {
389        $nGlyphs = count($charset);
390        if ($nGlyphs <= 1) {
391            // Only .notdef — use predefined charset 0
392            return '';
393        }
394
395        $data = chr(0); // format 0
396        // Skip GID 0 (.notdef) — always SID 0, not stored
397        for ($gid = 1; $gid < $nGlyphs; $gid++) {
398            $sid = $charset[$gid] ?? 0;
399            $data .= pack('n', $sid);
400        }
401        return $data;
402    }
403
404    /**
405     * Patch the Private DICT to update the Local Subr offset (operator 19).
406     *
407     * The relative offset must equal the final Private DICT size, since
408     * local subrs are laid out immediately after it. We strip op 19,
409     * measure, then append it with a 5-byte encoded value.
410     */
411    private function patchPrivateDictLocalSubr(string $privateDict, int $_unused): string
412    {
413        $ops = $this->parseDictDataDirect($privateDict);
414
415        // Rebuild WITHOUT operator 19
416        $result = '';
417        foreach ($ops as $op => $operands) {
418            if ($op === 19) {
419                continue; // skip — we'll add it at the end
420            }
421            $result .= $this->encodeDictEntry($op, $operands);
422        }
423
424        // Op 19 entry = 5-byte int + 1-byte operator = 6 bytes
425        // Relative offset = size of everything before local subrs = len(result) + 6
426        $relativeOffset = strlen($result) + 6;
427        $result .= $this->encode5ByteInt($relativeOffset) . chr(19);
428
429        return $result;
430    }
431
432    /**
433     * Parse DICT data directly (duplicated from CffParser for self-containment).
434     *
435     * @return array<int|string, int|float|array<int, int|float>>
436     */
437    private function parseDictDataDirect(string $data): array
438    {
439        $operators = [];
440        $operandStack = [];
441        $pos = 0;
442        $len = strlen($data);
443
444        while ($pos < $len) {
445            $b0 = ord($data[$pos]);
446
447            if ($b0 >= 32 && $b0 <= 246) {
448                $operandStack[] = $b0 - 139;
449                $pos++;
450            } elseif ($b0 >= 247 && $b0 <= 250) {
451                $b1 = ord($data[$pos + 1]);
452                $operandStack[] = ($b0 - 247) * 256 + $b1 + 108;
453                $pos += 2;
454            } elseif ($b0 >= 251 && $b0 <= 254) {
455                $b1 = ord($data[$pos + 1]);
456                $operandStack[] = -($b0 - 251) * 256 - $b1 - 108;
457                $pos += 2;
458            } elseif ($b0 === 28) {
459                $val = (ord($data[$pos + 1]) << 8) | ord($data[$pos + 2]);
460                if ($val >= 0x8000) {
461                    $val -= 0x10000;
462                }
463                $operandStack[] = $val;
464                $pos += 3;
465            } elseif ($b0 === 29) {
466                $val = (ord($data[$pos + 1]) << 24) | (ord($data[$pos + 2]) << 16)
467                    | (ord($data[$pos + 3]) << 8) | ord($data[$pos + 4]);
468                if ($val >= 0x80000000) {
469                    $val = (int) ($val - 0x100000000);
470                }
471                $operandStack[] = $val;
472                $pos += 5;
473            } elseif ($b0 === 30) {
474                $realStr = '';
475                $pos++;
476                $done = false;
477                while (!$done && $pos < $len) {
478                    $byte = ord($data[$pos]);
479                    $pos++;
480                    for ($nib = 0; $nib < 2; $nib++) {
481                        $nibble = ($nib === 0) ? ($byte >> 4) : ($byte & 0x0F);
482                        switch ($nibble) {
483                            case 0: case 1: case 2: case 3: case 4:
484                            case 5: case 6: case 7: case 8: case 9:
485                                $realStr .= (string) $nibble;
486                                break;
487                            case 0x0A: $realStr .= '.';
488                                break;
489                            case 0x0B: $realStr .= 'E';
490                                break;
491                            case 0x0C: $realStr .= 'E-';
492                                break;
493                            case 0x0E: $realStr .= '-';
494                                break;
495                            case 0x0F: $done = true;
496                                break;
497                        }
498                        if ($done) {
499                            break;
500                        }
501                    }
502                }
503                $operandStack[] = (float) $realStr;
504            } elseif ($b0 === 12) {
505                $pos++;
506                $b1 = ord($data[$pos]);
507                $pos++;
508                $key = '12.' . $b1;
509                $operators[$key] = count($operandStack) === 1 ? $operandStack[0] : $operandStack;
510                $operandStack = [];
511            } elseif ($b0 <= 21) {
512                $pos++;
513                $operators[$b0] = count($operandStack) === 1 ? $operandStack[0] : $operandStack;
514                $operandStack = [];
515            } else {
516                $pos++;
517            }
518        }
519
520        return $operators;
521    }
522}