Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.92% covered (success)
98.92%
92 / 93
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectParser
98.92% covered (success)
98.92%
92 / 93
87.50% covered (success)
87.50%
7 / 8
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseValueFromToken
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
12
 parseNumberOrReference
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 parseArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 parseDictOrStream
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 parseStreamData
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
19
 isEndstreamAt
100.00% covered (success)
100.00%
4 / 4
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;
16
17/**
18 * Pdf extract object parser 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 ObjectParser
28{
29
30    /**
31     * Maximum array/dict nesting depth
32     */
33    protected const MAX_DEPTH = 64;
34
35    /**
36     * Tokenizer feeding this parser
37     * @var Tokenizer
38     */
39    protected Tokenizer $tokenizer;
40
41    /**
42     * Constructor
43     *
44     * Instantiate an object parser.
45     *
46     * @param Tokenizer $tokenizer
47     */
48    public function __construct(Tokenizer $tokenizer)
49    {
50        $this->tokenizer = $tokenizer;
51    }
52
53    /**
54     * Parse the next value from the tokenizer
55     *
56     * @throws Exception
57     * @return mixed
58     */
59    public function parseValue(): mixed
60    {
61        return $this->parseValueFromToken($this->tokenizer->next(), 0);
62    }
63
64    /**
65     * Parse a value from an already-read token
66     *
67     * @param  array $token
68     * @param  int   $depth
69     * @throws Exception
70     * @return mixed
71     */
72    protected function parseValueFromToken(array $token, int $depth): mixed
73    {
74        if ($depth > self::MAX_DEPTH) {
75            throw new Exception('Error: Maximum nesting depth exceeded while parsing a PDF object.');
76        }
77
78        if ($token['type'] === 'number') {
79            return $this->parseNumberOrReference($token['value']);
80        } elseif ($token['type'] === 'name') {
81            return new Value\Name($token['value']);
82        } elseif ($token['type'] === 'string') {
83            return $token['value'];
84        } elseif ($token['type'] === 'array_start') {
85            return $this->parseArray($depth + 1);
86        } elseif ($token['type'] === 'dict_start') {
87            return $this->parseDictOrStream($depth + 1);
88        } elseif ($token['type'] === 'keyword') {
89            if ($token['value'] === 'true') {
90                return true;
91            } elseif ($token['value'] === 'false') {
92                return false;
93            } elseif ($token['value'] === 'null') {
94                return null;
95            }
96            return new Value\Keyword($token['value']);
97        } elseif ($token['type'] === 'eof') {
98            throw new Exception('Error: Unexpected end of data while parsing a PDF object.');
99        }
100
101        throw new Exception("Error: Unexpected token '{$token['type']}' while parsing a PDF object.");
102    }
103
104    /**
105     * Parse a number, disambiguating a plain number from an "N G R" indirect reference
106     *
107     * @param  int|float $number
108     * @return int|float|Value\Reference
109     */
110    protected function parseNumberOrReference(int|float $number): int|float|Value\Reference
111    {
112        if (is_float($number)) {
113            return $number;
114        }
115
116        $savedPos = $this->tokenizer->getPosition();
117        $genToken = $this->tokenizer->next();
118
119        if (($genToken['type'] === 'number') && is_int($genToken['value'])) {
120            $savedPos2 = $this->tokenizer->getPosition();
121            $rToken    = $this->tokenizer->next();
122
123            if (($rToken['type'] === 'keyword') && ($rToken['value'] === 'R')) {
124                return new Value\Reference($number, $genToken['value']);
125            }
126
127            $this->tokenizer->setPosition($savedPos2);
128        }
129
130        $this->tokenizer->setPosition($savedPos);
131
132        return $number;
133    }
134
135    /**
136     * Parse an array
137     *
138     * @param  int $depth
139     * @throws Exception
140     * @return array
141     */
142    protected function parseArray(int $depth): array
143    {
144        $items = [];
145
146        while (true) {
147            $token = $this->tokenizer->next();
148
149            if ($token['type'] === 'array_end') {
150                break;
151            }
152            if ($token['type'] === 'eof') {
153                throw new Exception('Error: Unexpected end of data while parsing a PDF array.');
154            }
155
156            $items[] = $this->parseValueFromToken($token, $depth);
157        }
158
159        return $items;
160    }
161
162    /**
163     * Parse a dictionary, or a stream if the dictionary is followed by 'stream'
164     *
165     * @param  int $depth
166     * @throws Exception
167     * @return mixed
168     */
169    protected function parseDictOrStream(int $depth): mixed
170    {
171        $dict = [];
172
173        while (true) {
174            $token = $this->tokenizer->next();
175
176            if ($token['type'] === 'dict_end') {
177                break;
178            }
179            if ($token['type'] !== 'name') {
180                throw new Exception('Error: Expected a name key while parsing a PDF dictionary.');
181            }
182
183            // Threads $depth through directly rather than calling the
184            // public parseValue() (which always starts at depth 0) - a
185            // deeply-nested array/dict as a dict VALUE must still count
186            // toward the same nesting cap, not reset it.
187            $dict[$token['value']] = $this->parseValueFromToken($this->tokenizer->next(), $depth);
188        }
189
190        $savedPos    = $this->tokenizer->getPosition();
191        $streamToken = $this->tokenizer->next();
192
193        if (($streamToken['type'] === 'keyword') && ($streamToken['value'] === 'stream')) {
194            return $this->parseStreamData($dict);
195        }
196
197        $this->tokenizer->setPosition($savedPos);
198
199        return $dict;
200    }
201
202    /**
203     * Parse the raw bytes of a stream, honoring /Length when trustworthy
204     *
205     * @param  array $dict
206     * @throws Exception
207     * @return Value\Stream
208     */
209    protected function parseStreamData(array $dict): Value\Stream
210    {
211        $data = $this->tokenizer->getData();
212        $pos  = $this->tokenizer->getPosition();
213
214        if ((($pos + 1) < strlen($data)) && ($data[$pos] === "\r") && ($data[$pos + 1] === "\n")) {
215            $pos += 2;
216        } elseif (($pos < strlen($data)) && ($data[$pos] === "\n")) {
217            $pos += 1;
218        }
219
220        $length = $dict['Length'] ?? null;
221        $streamStart = $pos;
222        $streamEnd   = null;
223
224        // Trust a direct-integer /Length only if it actually lands on the
225        // endstream keyword - a wrong /Length (a known real-world PDF
226        // corruption pattern) must not silently truncate/mangle the stream.
227        if (is_int($length) && ($length >= 0) && (($pos + $length) <= strlen($data)) &&
228            $this->isEndstreamAt($data, $pos + $length)) {
229            $streamEnd = $pos + $length;
230        }
231
232        if ($streamEnd === null) {
233            $endPos = strpos($data, 'endstream', $pos);
234
235            if ($endPos === false) {
236                throw new Exception('Error: Could not locate endstream marker for a PDF stream object.');
237            }
238
239            $streamEnd = $endPos;
240
241            if (($streamEnd > $streamStart) && ($data[$streamEnd - 1] === "\n")) {
242                $streamEnd--;
243                if (($streamEnd > $streamStart) && ($data[$streamEnd - 1] === "\r")) {
244                    $streamEnd--;
245                }
246            }
247        }
248
249        $raw = substr($data, $streamStart, $streamEnd - $streamStart);
250
251        $this->tokenizer->setPosition($streamEnd);
252
253        $endToken = $this->tokenizer->next();
254        if (($endToken['type'] !== 'keyword') || ($endToken['value'] !== 'endstream')) {
255            $endPos = strpos($data, 'endstream', $this->tokenizer->getPosition());
256            if ($endPos !== false) {
257                $this->tokenizer->setPosition($endPos + strlen('endstream'));
258            }
259        }
260
261        return new Value\Stream($dict, $raw);
262    }
263
264    /**
265     * Determine if the 'endstream' keyword appears at (or after whitespace from) a position
266     *
267     * @param  string $data
268     * @param  int    $pos
269     * @return bool
270     */
271    protected function isEndstreamAt(string $data, int $pos): bool
272    {
273        $length = strlen($data);
274
275        while (($pos < $length) && Tokenizer::isWhitespace($data[$pos])) {
276            $pos++;
277        }
278
279        return substr($data, $pos, 9) === 'endstream';
280    }
281
282}