Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.45% covered (success)
96.45%
136 / 141
77.78% covered (success)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CMapParser
96.45% covered (success)
96.45%
136 / 141
77.78% covered (success)
77.78%
7 / 9
69
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
 readCodespaceRanges
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 readBfChar
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 readBfRange
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
17.26
 readCidChar
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 readCidRange
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
13.13
 bytesToInt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 intToUnicodeString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dstToUnicodeString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Extract\Font;
16
17use Pop\Pdf\Extract\Exception;
18use Pop\Pdf\Extract\ObjectParser;
19use Pop\Pdf\Extract\Tokenizer;
20use Pop\Pdf\Extract\Value;
21
22/**
23 * Pdf extract CMap parser class
24 *
25 * Parses CMap-program byte streams (used by both /ToUnicode streams and
26 * embedded /Encoding CMap streams for Type0 fonts) - reuses Extract\Tokenizer
27 * for the underlying lexical grammar, the same way Content\Interpreter
28 * reuses it for content-stream operators.
29 *
30 * @category   Pop
31 * @package    Pop\Pdf
32 * @author     Nick Sagona, III <nick@popphp.org>
33 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
34 * @license    https://www.popphp.org/license     New BSD License
35 * @version    6.0.0
36 */
37class CMapParser
38{
39
40    /**
41     * Maximum span (hi - lo) allowed for a single bfrange/cidrange entry
42     */
43    protected const MAX_RANGE_SPAN = 65536;
44
45    /**
46     * Maximum total mappings a single parse() call may materialize
47     */
48    protected const MAX_TOTAL_MAPPINGS = 1000000;
49
50    /**
51     * Parse a CMap-program byte stream into codespace ranges and bf/cid mappings
52     *
53     * @param  string $data
54     * @return array
55     */
56    public static function parse(string $data): array
57    {
58        $tokenizer    = new Tokenizer($data);
59        $objectParser = new ObjectParser($tokenizer);
60
61        $codespaceRanges = [];
62        $bfMappings      = [];
63        $cidMappings     = [];
64
65        while (true) {
66            $savedPos = $tokenizer->getPosition();
67            $peek     = $tokenizer->next();
68
69            if ($peek['type'] === 'eof') {
70                break;
71            }
72
73            $tokenizer->setPosition($savedPos);
74
75            try {
76                $value = $objectParser->parseValue();
77            } catch (Exception $e) {
78                break;
79            }
80
81            if (!($value instanceof Value\Keyword)) {
82                continue;
83            }
84
85            switch ($value->keyword) {
86                case 'begincodespacerange':
87                    self::readCodespaceRanges($objectParser, $codespaceRanges);
88                    break;
89                case 'beginbfchar':
90                    self::readBfChar($objectParser, $bfMappings);
91                    break;
92                case 'beginbfrange':
93                    self::readBfRange($objectParser, $bfMappings);
94                    break;
95                case 'begincidchar':
96                    self::readCidChar($objectParser, $cidMappings);
97                    break;
98                case 'begincidrange':
99                    self::readCidRange($objectParser, $cidMappings);
100                    break;
101                default:
102                    break;
103            }
104        }
105
106        return [
107            'codespaceRanges' => $codespaceRanges,
108            'bfMappings'      => $bfMappings,
109            'cidMappings'     => $cidMappings,
110        ];
111    }
112
113    /**
114     * Read a begincodespacerange...endcodespacerange section
115     *
116     * @param  ObjectParser $parser
117     * @param  array        $ranges
118     * @return void
119     */
120    protected static function readCodespaceRanges(ObjectParser $parser, array &$ranges): void
121    {
122        try {
123            while (true) {
124                $value = $parser->parseValue();
125                if (($value instanceof Value\Keyword) && ($value->keyword === 'endcodespacerange')) {
126                    break;
127                }
128                if (!is_string($value)) {
129                    continue;
130                }
131                $lo = $value;
132                $hi = $parser->parseValue();
133                if (!is_string($hi)) {
134                    continue;
135                }
136                $ranges[] = ['lo' => $lo, 'hi' => $hi, 'length' => strlen($lo)];
137            }
138        } catch (Exception $e) {
139            // Truncated/malformed section (no matching end* keyword before
140            // EOF) - keep whatever was successfully parsed rather than
141            // aborting the whole CMap.
142            return;
143        }
144    }
145
146    /**
147     * Read a beginbfchar...endbfchar section
148     *
149     * @param  ObjectParser $parser
150     * @param  array        $mappings
151     * @return void
152     */
153    protected static function readBfChar(ObjectParser $parser, array &$mappings): void
154    {
155        try {
156            while (true) {
157                $value = $parser->parseValue();
158                if (($value instanceof Value\Keyword) && ($value->keyword === 'endbfchar')) {
159                    break;
160                }
161                if (!is_string($value)) {
162                    continue;
163                }
164                $code = self::bytesToInt($value);
165                $dst  = $parser->parseValue();
166                if (is_string($dst)) {
167                    $mappings[$code] = self::dstToUnicodeString($dst);
168                }
169            }
170        } catch (Exception $e) {
171            // Truncated/malformed section (no matching end* keyword before
172            // EOF) - keep whatever was successfully parsed rather than
173            // aborting the whole CMap.
174            return;
175        }
176    }
177
178    /**
179     * Read a beginbfrange...endbfrange section
180     *
181     * @param  ObjectParser $parser
182     * @param  array        $mappings
183     * @return void
184     */
185    protected static function readBfRange(ObjectParser $parser, array &$mappings): void
186    {
187        try {
188            while (true) {
189                if (count($mappings) >= self::MAX_TOTAL_MAPPINGS) {
190                    return;
191                }
192
193                $value = $parser->parseValue();
194                if (($value instanceof Value\Keyword) && ($value->keyword === 'endbfrange')) {
195                    break;
196                }
197                if (!is_string($value)) {
198                    continue;
199                }
200                $loBytes = $value;
201                $hi      = $parser->parseValue();
202                $dst     = $parser->parseValue();
203
204                $lo    = self::bytesToInt($loBytes);
205                $hiInt = is_string($hi) ? self::bytesToInt($hi) : $lo;
206
207                if (($hiInt < $lo) || (($hiInt - $lo) >= self::MAX_RANGE_SPAN)) {
208                    // Malformed or adversarial range (inverted, or larger
209                    // than any legitimate single-codespace range) - skip
210                    // rather than materializing a huge/unbounded array.
211                    continue;
212                }
213
214                if (is_array($dst)) {
215                    foreach ($dst as $i => $element) {
216                        if (count($mappings) >= self::MAX_TOTAL_MAPPINGS) {
217                            break;
218                        }
219                        if (is_string($element)) {
220                            $mappings[$lo + $i] = self::dstToUnicodeString($element);
221                        }
222                    }
223                } elseif (is_string($dst)) {
224                    $dstStart = self::bytesToInt($dst);
225                    $dstLen   = strlen($dst);
226                    for ($code = $lo; $code <= $hiInt; $code++) {
227                        if (count($mappings) >= self::MAX_TOTAL_MAPPINGS) {
228                            break;
229                        }
230                        $offset = $code - $lo;
231                        $mappings[$code] = self::intToUnicodeString($dstStart + $offset, $dstLen);
232                    }
233                }
234            }
235        } catch (Exception $e) {
236            // Truncated/malformed section (no matching end* keyword before
237            // EOF) - keep whatever was successfully parsed rather than
238            // aborting the whole CMap.
239            return;
240        }
241    }
242
243    /**
244     * Read a begincidchar...endcidchar section
245     *
246     * @param  ObjectParser $parser
247     * @param  array        $mappings
248     * @return void
249     */
250    protected static function readCidChar(ObjectParser $parser, array &$mappings): void
251    {
252        try {
253            while (true) {
254                $value = $parser->parseValue();
255                if (($value instanceof Value\Keyword) && ($value->keyword === 'endcidchar')) {
256                    break;
257                }
258                if (!is_string($value)) {
259                    continue;
260                }
261                $code = self::bytesToInt($value);
262                $cid  = $parser->parseValue();
263                if (is_numeric($cid)) {
264                    $mappings[$code] = (int) $cid;
265                }
266            }
267        } catch (Exception $e) {
268            // Truncated/malformed section (no matching end* keyword before
269            // EOF) - keep whatever was successfully parsed rather than
270            // aborting the whole CMap.
271            return;
272        }
273    }
274
275    /**
276     * Read a begincidrange...endcidrange section
277     *
278     * @param  ObjectParser $parser
279     * @param  array        $mappings
280     * @return void
281     */
282    protected static function readCidRange(ObjectParser $parser, array &$mappings): void
283    {
284        try {
285            while (true) {
286                if (count($mappings) >= self::MAX_TOTAL_MAPPINGS) {
287                    return;
288                }
289
290                $value = $parser->parseValue();
291                if (($value instanceof Value\Keyword) && ($value->keyword === 'endcidrange')) {
292                    break;
293                }
294                if (!is_string($value)) {
295                    continue;
296                }
297                $loBytes = $value;
298                $hi      = $parser->parseValue();
299                $cid     = $parser->parseValue();
300
301                $lo       = self::bytesToInt($loBytes);
302                $hiInt    = is_string($hi) ? self::bytesToInt($hi) : $lo;
303                $cidStart = is_numeric($cid) ? (int) $cid : 0;
304
305                if (($hiInt < $lo) || (($hiInt - $lo) >= self::MAX_RANGE_SPAN)) {
306                    continue;
307                }
308
309                for ($code = $lo; $code <= $hiInt; $code++) {
310                    if (count($mappings) >= self::MAX_TOTAL_MAPPINGS) {
311                        break;
312                    }
313                    $mappings[$code] = $cidStart + ($code - $lo);
314                }
315            }
316        } catch (Exception $e) {
317            // Truncated/malformed section (no matching end* keyword before
318            // EOF) - keep whatever was successfully parsed rather than
319            // aborting the whole CMap.
320            return;
321        }
322    }
323
324    /**
325     * Convert a byte string to a big-endian integer
326     *
327     * @param  string $bytes
328     * @return int
329     */
330    protected static function bytesToInt(string $bytes): int
331    {
332        $value = 0;
333        for ($i = 0; $i < strlen($bytes); $i++) {
334            $value = ($value << 8) | ord($bytes[$i]);
335        }
336        return $value;
337    }
338
339    /**
340     * Convert an integer to a fixed-length big-endian byte string, then decode it as UTF-16BE
341     *
342     * @param  int $value
343     * @param  int $byteLen
344     * @return string
345     */
346    protected static function intToUnicodeString(int $value, int $byteLen): string
347    {
348        $bytes = '';
349        for ($i = $byteLen - 1; $i >= 0; $i--) {
350            $bytes .= chr(($value >> ($i * 8)) & 0xFF);
351        }
352        return self::dstToUnicodeString($bytes);
353    }
354
355    /**
356     * Decode a bfchar/bfrange destination byte string as UTF-16BE, rejecting malformed/placeholder values
357     *
358     * @param  string $bytes
359     * @return string
360     */
361    protected static function dstToUnicodeString(string $bytes): string
362    {
363        if ((strlen($bytes) % 2) !== 0) {
364            // Odd-length UTF-16BE input is malformed - mb_convert_encoding
365            // would otherwise substitute a literal '?' that's indistinguishable
366            // from real decoded content.
367            return '';
368        }
369
370        if (rtrim($bytes, "\x00") === '') {
371            // A destination that decodes to nothing but U+0000 is a common
372            // real-world "unused code" placeholder, not actual text -
373            // emitting it would inject raw NUL bytes into extracted text.
374            return '';
375        }
376
377        return @mb_convert_encoding($bytes, 'UTF-8', 'UTF-16BE');
378    }
379
380}