Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
Tokenizer
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
14 / 14
106
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWhitespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
6
 isDelimiter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
10
 skipWhitespaceAndComments
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 next
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
15
 readName
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 readLiteralString
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
26
 readAngleOpen
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 readAngleClose
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 readNumber
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
12
 readKeyword
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
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;
16
17/**
18 * Pdf extract tokenizer class
19 *
20 * @category   Pop
21 * @package    Pop\Pdf
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    6.0.0
26 */
27class Tokenizer
28{
29
30    /**
31     * Raw data being tokenized
32     * @var string
33     */
34    protected string $data;
35
36    /**
37     * Length of the raw data
38     * @var int
39     */
40    protected int $length;
41
42    /**
43     * Current byte offset into the data
44     * @var int
45     */
46    protected int $pos;
47
48    /**
49     * Constructor
50     *
51     * Instantiate a tokenizer.
52     *
53     * @param string $data
54     * @param int    $pos
55     */
56    public function __construct(string $data, int $pos = 0)
57    {
58        $this->data   = $data;
59        $this->length = strlen($data);
60        $this->pos    = $pos;
61    }
62
63    /**
64     * Get the current byte offset
65     *
66     * @return int
67     */
68    public function getPosition(): int
69    {
70        return $this->pos;
71    }
72
73    /**
74     * Set the current byte offset
75     *
76     * @param  int $pos
77     * @return void
78     */
79    public function setPosition(int $pos): void
80    {
81        $this->pos = $pos;
82    }
83
84    /**
85     * Get the raw data being tokenized
86     *
87     * @return string
88     */
89    public function getData(): string
90    {
91        return $this->data;
92    }
93
94    /**
95     * Determine if a byte is PDF whitespace
96     *
97     * @param  string $c
98     * @return bool
99     */
100    public static function isWhitespace(string $c): bool
101    {
102        return ($c === ' ') || ($c === "\t") || ($c === "\r") || ($c === "\n") || ($c === "\f") || ($c === "\0");
103    }
104
105    /**
106     * Determine if a byte is a PDF delimiter
107     *
108     * @param  string $c
109     * @return bool
110     */
111    public static function isDelimiter(string $c): bool
112    {
113        return ($c === '(') || ($c === ')') || ($c === '<') || ($c === '>') ||
114            ($c === '[') || ($c === ']') || ($c === '{') || ($c === '}') ||
115            ($c === '/') || ($c === '%');
116    }
117
118    /**
119     * Skip whitespace and comments
120     *
121     * @return void
122     */
123    protected function skipWhitespaceAndComments(): void
124    {
125        while ($this->pos < $this->length) {
126            $c = $this->data[$this->pos];
127            if (self::isWhitespace($c)) {
128                $this->pos++;
129            } elseif ($c === '%') {
130                while (($this->pos < $this->length) && ($this->data[$this->pos] !== "\n") && ($this->data[$this->pos] !== "\r")) {
131                    $this->pos++;
132                }
133            } else {
134                break;
135            }
136        }
137    }
138
139    /**
140     * Read the next token
141     *
142     * @return array
143     */
144    public function next(): array
145    {
146        // Loop rather than recurse for tokens that may need to skip over
147        // themselves and continue (e.g. a stray '>' byte) - a malicious
148        // input with many such bytes in a row must not grow the call stack.
149        while (true) {
150            $this->skipWhitespaceAndComments();
151
152            if ($this->pos >= $this->length) {
153                return ['type' => 'eof', 'value' => null];
154            }
155
156            $c = $this->data[$this->pos];
157
158            if ($c === '/') {
159                return $this->readName();
160            } elseif ($c === '(') {
161                return $this->readLiteralString();
162            } elseif ($c === '<') {
163                return $this->readAngleOpen();
164            } elseif ($c === '>') {
165                $token = $this->readAngleClose();
166                if ($token === null) {
167                    // Stray '>' - already skipped by readAngleClose(); loop
168                    // back around to tokenize whatever follows.
169                    continue;
170                }
171                return $token;
172            } elseif ($c === '[') {
173                $this->pos++;
174                return ['type' => 'array_start', 'value' => '['];
175            } elseif ($c === ']') {
176                $this->pos++;
177                return ['type' => 'array_end', 'value' => ']'];
178            } elseif (($c === '+') || ($c === '-') || ($c === '.') || (($c >= '0') && ($c <= '9'))) {
179                return $this->readNumber();
180            } else {
181                return $this->readKeyword();
182            }
183        }
184    }
185
186    /**
187     * Read a name token
188     *
189     * @return array
190     */
191    protected function readName(): array
192    {
193        $this->pos++; // skip '/'
194        $name = '';
195
196        while (($this->pos < $this->length) &&
197            (!self::isWhitespace($this->data[$this->pos])) &&
198            (!self::isDelimiter($this->data[$this->pos]))) {
199            if (($this->data[$this->pos] === '#') && (($this->pos + 2) < $this->length) &&
200                ctype_xdigit($this->data[$this->pos + 1]) && ctype_xdigit($this->data[$this->pos + 2])) {
201                $name .= chr((int) hexdec(substr($this->data, $this->pos + 1, 2)));
202                $this->pos += 3;
203            } else {
204                $name .= $this->data[$this->pos];
205                $this->pos++;
206            }
207        }
208
209        return ['type' => 'name', 'value' => $name];
210    }
211
212    /**
213     * Read a literal string token
214     *
215     * @return array
216     */
217    protected function readLiteralString(): array
218    {
219        $this->pos++; // skip '('
220        $depth = 1;
221        $value = '';
222
223        while (($this->pos < $this->length) && ($depth > 0)) {
224            $c = $this->data[$this->pos];
225
226            if ($c === '\\') {
227                $this->pos++;
228                if ($this->pos >= $this->length) {
229                    break;
230                }
231                $esc = $this->data[$this->pos];
232
233                if ($esc === 'n') {
234                    $value .= "\n"; $this->pos++;
235                } elseif ($esc === 'r') {
236                    $value .= "\r"; $this->pos++;
237                } elseif ($esc === 't') {
238                    $value .= "\t"; $this->pos++;
239                } elseif ($esc === 'b') {
240                    $value .= "\x08"; $this->pos++;
241                } elseif ($esc === 'f') {
242                    $value .= "\x0C"; $this->pos++;
243                } elseif (($esc === '(') || ($esc === ')') || ($esc === '\\')) {
244                    $value .= $esc; $this->pos++;
245                } elseif ($esc === "\n") {
246                    $this->pos++;
247                } elseif ($esc === "\r") {
248                    $this->pos++;
249                    if (($this->pos < $this->length) && ($this->data[$this->pos] === "\n")) {
250                        $this->pos++;
251                    }
252                } elseif (($esc >= '0') && ($esc <= '7')) {
253                    $octal = '';
254                    $count = 0;
255                    while (($count < 3) && ($this->pos < $this->length) &&
256                        ($this->data[$this->pos] >= '0') && ($this->data[$this->pos] <= '7')) {
257                        $octal .= $this->data[$this->pos];
258                        $this->pos++;
259                        $count++;
260                    }
261                    $value .= chr(((int) octdec($octal)) & 0xFF);
262                } else {
263                    $value .= $esc;
264                    $this->pos++;
265                }
266            } elseif ($c === '(') {
267                $depth++;
268                $value .= $c;
269                $this->pos++;
270            } elseif ($c === ')') {
271                $depth--;
272                $this->pos++;
273                if ($depth > 0) {
274                    $value .= $c;
275                }
276            } else {
277                $value .= $c;
278                $this->pos++;
279            }
280        }
281
282        return ['type' => 'string', 'value' => $value];
283    }
284
285    /**
286     * Read a token starting with '<' (hex string or dict-open)
287     *
288     * @return array
289     */
290    protected function readAngleOpen(): array
291    {
292        if ((($this->pos + 1) < $this->length) && ($this->data[$this->pos + 1] === '<')) {
293            $this->pos += 2;
294            return ['type' => 'dict_start', 'value' => '<<'];
295        }
296
297        $this->pos++; // skip '<'
298        $hex = '';
299
300        while (($this->pos < $this->length) && ($this->data[$this->pos] !== '>')) {
301            $c = $this->data[$this->pos];
302            if (ctype_xdigit($c)) {
303                $hex .= $c;
304            }
305            $this->pos++;
306        }
307
308        if (($this->pos < $this->length) && ($this->data[$this->pos] === '>')) {
309            $this->pos++;
310        }
311
312        if ((strlen($hex) % 2) !== 0) {
313            $hex .= '0';
314        }
315
316        return ['type' => 'string', 'value' => ($hex === '') ? '' : (string) hex2bin($hex)];
317    }
318
319    /**
320     * Read a token starting with '>' (dict-close, or a stray byte to skip)
321     *
322     * @return ?array
323     */
324    protected function readAngleClose(): ?array
325    {
326        if ((($this->pos + 1) < $this->length) && ($this->data[$this->pos + 1] === '>')) {
327            $this->pos += 2;
328            return ['type' => 'dict_end', 'value' => '>>'];
329        }
330
331        // Stray '>' outside of a hex string / dict-close - skip it. Return
332        // null so next() loops back around instead of recursing here.
333        $this->pos++;
334
335        return null;
336    }
337
338    /**
339     * Read a number token
340     *
341     * @return array
342     */
343    protected function readNumber(): array
344    {
345        $start = $this->pos;
346
347        if (($this->data[$this->pos] === '+') || ($this->data[$this->pos] === '-')) {
348            $this->pos++;
349        }
350
351        while (($this->pos < $this->length) &&
352            ((($this->data[$this->pos] >= '0') && ($this->data[$this->pos] <= '9')) || ($this->data[$this->pos] === '.'))) {
353            $this->pos++;
354        }
355
356        $raw = substr($this->data, $start, $this->pos - $start);
357
358        if (($raw === '') || ($raw === '+') || ($raw === '-') || ($raw === '.')) {
359            return ['type' => 'keyword', 'value' => $raw];
360        }
361
362        $value = (str_contains($raw, '.')) ? (float) $raw : (int) $raw;
363
364        return ['type' => 'number', 'value' => $value];
365    }
366
367    /**
368     * Read a keyword token
369     *
370     * @return array
371     */
372    protected function readKeyword(): array
373    {
374        $start = $this->pos;
375
376        while (($this->pos < $this->length) &&
377            (!self::isWhitespace($this->data[$this->pos])) &&
378            (!self::isDelimiter($this->data[$this->pos]))) {
379            $this->pos++;
380        }
381
382        if ($this->pos === $start) {
383            $this->pos++;
384            return ['type' => 'keyword', 'value' => $this->data[$start]];
385        }
386
387        return ['type' => 'keyword', 'value' => substr($this->data, $start, $this->pos - $start)];
388    }
389
390}