Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
170 / 172
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentStreamParser
98.84% covered (success)
98.84%
170 / 172
83.33% covered (warning)
83.33%
10 / 12
99
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
20
 isWhitespace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
6
 isNumberStart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
 isDelimiter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
8
 readLiteralString
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 readHexString
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 readName
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 readNumber
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 readKeyword
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 readArray
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
10
 readInlineDict
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 readInlineImage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
17
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Reader\Parser;
6
7/**
8 * Parses decoded content stream data into a sequence of operations.
9 *
10 * Each operation is a list of operands followed by an operator keyword.
11 * The parser handles all PDF content stream token types: numbers, names,
12 * literal strings, hex strings, arrays, dictionaries, and inline images.
13 */
14final class ContentStreamParser
15{
16    /**
17     * Parse content stream data into operations.
18     *
19     * @return list<ContentStreamOp>
20     */
21    public function parse(string $data): array
22    {
23        $ops = [];
24        $operands = [];
25        $len = strlen($data);
26        $pos = 0;
27
28        while ($pos < $len) {
29            // Skip whitespace
30            while ($pos < $len && $this->isWhitespace($data[$pos])) {
31                $pos++;
32            }
33            if ($pos >= $len) {
34                break;
35            }
36
37            $ch = $data[$pos];
38
39            // Comment â€” skip to end of line
40            if ($ch === '%') {
41                while ($pos < $len && $data[$pos] !== "\n" && $data[$pos] !== "\r") {
42                    $pos++;
43                }
44                continue;
45            }
46
47            // Literal string (...)
48            if ($ch === '(') {
49                $str = $this->readLiteralString($data, $pos);
50                $operands[] = $str;
51                continue;
52            }
53
54            // Hex string <...>
55            if ($ch === '<') {
56                // Could be hex string or dict start <<
57                if ($pos + 1 < $len && $data[$pos + 1] === '<') {
58                    // Inline dict â€” read until >>
59                    $dictStr = $this->readInlineDict($data, $pos);
60                    $operands[] = $dictStr;
61                    continue;
62                }
63                $hex = $this->readHexString($data, $pos);
64                $operands[] = $hex;
65                continue;
66            }
67
68            // Array [...]
69            if ($ch === '[') {
70                $arr = $this->readArray($data, $pos);
71                $operands[] = $arr;
72                continue;
73            }
74
75            // Name /...
76            if ($ch === '/') {
77                $name = $this->readName($data, $pos);
78                $operands[] = $name;
79                continue;
80            }
81
82            // Number or keyword
83            if ($this->isNumberStart($ch)) {
84                $num = $this->readNumber($data, $pos);
85                $operands[] = $num;
86                continue;
87            }
88
89            // Keyword (operator or boolean/null)
90            $keyword = $this->readKeyword($data, $pos);
91
92            // Handle inline image: BI ... ID <data> EI
93            if ($keyword === 'BI') {
94                $ops[] = $this->readInlineImage($data, $pos, $operands);
95                $operands = [];
96                continue;
97            }
98
99            // true/false/null are operands, not operators
100            if ($keyword === 'true' || $keyword === 'false' || $keyword === 'null') {
101                $operands[] = $keyword;
102                continue;
103            }
104
105            // Everything else is an operator
106            $ops[] = new ContentStreamOp($operands, $keyword);
107            $operands = [];
108        }
109
110        return $ops;
111    }
112
113    private function isWhitespace(string $ch): bool
114    {
115        return $ch === ' ' || $ch === "\n" || $ch === "\r"
116            || $ch === "\t" || $ch === "\x00" || $ch === "\x0C";
117    }
118
119    private function isNumberStart(string $ch): bool
120    {
121        return ($ch >= '0' && $ch <= '9') || $ch === '+' || $ch === '-' || $ch === '.';
122    }
123
124    private function isDelimiter(string $ch): bool
125    {
126        return $ch === '(' || $ch === ')' || $ch === '<' || $ch === '>'
127            || $ch === '[' || $ch === ']' || $ch === '/' || $ch === '%';
128    }
129
130    private function readLiteralString(string $data, int &$pos): string
131    {
132        $result = '(';
133        $pos++; // skip opening (
134        $depth = 1;
135        $len = strlen($data);
136
137        while ($pos < $len && $depth > 0) {
138            $ch = $data[$pos];
139            if ($ch === '(') {
140                $depth++;
141                $result .= '(';
142            } elseif ($ch === ')') {
143                $depth--;
144                if ($depth > 0) {
145                    $result .= ')';
146                }
147            } elseif ($ch === '\\') {
148                $result .= '\\';
149                $pos++;
150                if ($pos < $len) {
151                    $result .= $data[$pos];
152                }
153            } else {
154                $result .= $ch;
155            }
156            $pos++;
157        }
158
159        return $result . ')';
160    }
161
162    private function readHexString(string $data, int &$pos): string
163    {
164        $result = '<';
165        $pos++; // skip <
166        $len = strlen($data);
167
168        while ($pos < $len && $data[$pos] !== '>') {
169            $result .= $data[$pos];
170            $pos++;
171        }
172        if ($pos < $len) {
173            $pos++; // skip >
174        }
175        return $result . '>';
176    }
177
178    private function readName(string $data, int &$pos): string
179    {
180        $result = '/';
181        $pos++; // skip /
182        $len = strlen($data);
183
184        while ($pos < $len) {
185            $ch = $data[$pos];
186            if ($this->isWhitespace($ch) || $this->isDelimiter($ch)) {
187                break;
188            }
189            $result .= $ch;
190            $pos++;
191        }
192        return $result;
193    }
194
195    private function readNumber(string $data, int &$pos): string
196    {
197        $result = '';
198        $len = strlen($data);
199
200        while ($pos < $len) {
201            $ch = $data[$pos];
202            if (($ch >= '0' && $ch <= '9') || $ch === '.' || $ch === '+' || $ch === '-') {
203                $result .= $ch;
204                $pos++;
205            } else {
206                break;
207            }
208        }
209        return $result;
210    }
211
212    private function readKeyword(string $data, int &$pos): string
213    {
214        $result = '';
215        $len = strlen($data);
216
217        while ($pos < $len) {
218            $ch = $data[$pos];
219            if ($this->isWhitespace($ch) || $this->isDelimiter($ch)) {
220                break;
221            }
222            $result .= $ch;
223            $pos++;
224        }
225        return $result;
226    }
227
228    private function readArray(string $data, int &$pos): string
229    {
230        $result = '[';
231        $pos++; // skip [
232        $depth = 1;
233        $len = strlen($data);
234
235        while ($pos < $len && $depth > 0) {
236            $ch = $data[$pos];
237            if ($ch === '[') {
238                $depth++;
239            } elseif ($ch === ']') {
240                $depth--;
241                if ($depth === 0) {
242                    $pos++;
243                    break;
244                }
245            }
246            // Handle nested strings
247            if ($ch === '(') {
248                $result .= $this->readLiteralString($data, $pos);
249                continue;
250            }
251            if ($ch === '<' && $pos + 1 < $len && $data[$pos + 1] !== '<') {
252                $result .= $this->readHexString($data, $pos);
253                continue;
254            }
255            $result .= $ch;
256            $pos++;
257        }
258
259        return $result . ']';
260    }
261
262    private function readInlineDict(string $data, int &$pos): string
263    {
264        $result = '<<';
265        $pos += 2; // skip <<
266        $len = strlen($data);
267
268        while ($pos < $len) {
269            if ($data[$pos] === '>' && $pos + 1 < $len && $data[$pos + 1] === '>') {
270                $pos += 2;
271                return $result . '>>';
272            }
273            if ($data[$pos] === '(') {
274                $result .= $this->readLiteralString($data, $pos);
275                continue;
276            }
277            $result .= $data[$pos];
278            $pos++;
279        }
280        return $result . '>>';
281    }
282
283    /**
284     * Read an inline image: operands already consumed.
285     * We're positioned after "BI". Read key-value pairs until "ID",
286     * then read raw image data until "EI".
287     */
288    private function readInlineImage(string $data, int &$pos, array $operands): ContentStreamOp
289    {
290        $len = strlen($data);
291
292        // Skip whitespace after BI
293        while ($pos < $len && $this->isWhitespace($data[$pos])) {
294            $pos++;
295        }
296
297        // Read key-value pairs until ID keyword
298        $params = '';
299        while ($pos < $len) {
300            // Check for ID keyword (must be followed by single whitespace byte)
301            if ($data[$pos] === 'I' && $pos + 1 < $len && $data[$pos + 1] === 'D') {
302                $pos += 2; // skip "ID"
303                if ($pos < $len && ($data[$pos] === ' ' || $data[$pos] === "\n")) {
304                    $pos++; // skip single whitespace after ID
305                }
306                break;
307            }
308            $params .= $data[$pos];
309            $pos++;
310        }
311
312        // Read image data until EI
313        $imageData = '';
314        while ($pos < $len) {
315            // Look for \nEI or \rEI or space+EI
316            if ($pos + 2 <= $len
317                && ($data[$pos] === "\n" || $data[$pos] === "\r" || $data[$pos] === ' ')
318                && $data[$pos + 1] === 'E' && $data[$pos + 2] === 'I'
319            ) {
320                $pos += 3; // skip whitespace+EI
321                break;
322            }
323            $imageData .= $data[$pos];
324            $pos++;
325        }
326
327        return new ContentStreamOp(
328            [trim($params), $imageData],
329            'BI',
330        );
331    }
332}