Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.79% covered (success)
95.79%
91 / 95
94.74% covered (success)
94.74%
18 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
StreamObject
95.79% covered (success)
95.79%
91 / 95
94.74% covered (success)
94.74%
18 / 19
54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
82.61% covered (success)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
9.43
 setDefinition
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
 setStream
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 appendStream
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefinition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 decode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isEncoded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPalette
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isPalette
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLeadingEolLength
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLeadingEolLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isXObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getByteLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateByteLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
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\Build\PdfObject;
16
17/**
18 * Pdf stream object 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.2.0
26 */
27class StreamObject extends AbstractObject
28{
29
30    /**
31     * PDF stream object index
32     * @var ?int
33     */
34    protected ?int $index = 5;
35
36    /**
37     * PDF stream object definition
38     * @var ?string
39     */
40    protected ?string $definition = null;
41
42    /**
43     * PDF stream object stream
44     * @var ?string
45     */
46    protected ?string $stream = null;
47
48    /**
49     * Encoding filter
50     * @var ?string
51     */
52    protected ?string $encoding = null;
53
54    /**
55     * Palette object flag
56     * @var bool
57     */
58    protected bool $isPalette = false;
59
60    /**
61     * XObject object flag
62     * @var bool
63     */
64    protected bool $isXObject = false;
65
66    /**
67     * Number of leading bytes in $stream that are the mandatory end-of-line
68     * marker (ISO 32000-1 7.3.8.1) after the "stream" keyword, rather than
69     * real payload - non-zero only for objects built via parse() (or an
70     * equivalent importer, e.g. ObjectGraphReader::translateGeneric()) that
71     * retain a declared /Length verbatim (literal or indirect) and so keep
72     * that EOL as part of $stream instead of having it added dynamically at
73     * render time. Consulted by Compiler's encryption pass to strip exactly
74     * this many leading bytes before encrypting, independent of whatever
75     * /Length happens to say (a literal integer, an indirect reference, or
76     * nothing at all).
77     * @var int
78     */
79    protected int $leadingEolLength = 0;
80
81    /**
82     * Constructor
83     *
84     * Instantiate a PDF stream object.
85     *
86     * @param  int $index
87     */
88    public function __construct(int $index = 5)
89    {
90        $this->setIndex($index);
91        $this->setData("[{object_index}] 0 obj\n[{definition}]\n[{stream}]\nendobj\n\n");
92    }
93
94    /**
95     * Parse a stream object from a string
96     *
97     * @param  string $stream
98     * @return StreamObject
99     */
100    public static function parse(string $stream): StreamObject
101    {
102        $object = new self();
103        $object->setIndex((int)substr($stream, 0, strpos($stream, ' ')));
104        $stream = str_replace($object->getIndex() . ' 0 obj', '[{object_index}] 0 obj', $stream);
105
106        // Determine the objects definition and stream, if applicable.
107        $s = substr($stream, (strpos($stream, ' obj') + 4));
108        $s = substr($s, 0, strpos($s, 'endobj'));
109        if (str_contains($s, 'stream')) {
110            $def = substr($s, 0, strpos($s, 'stream'));
111            $str = substr($s, (strpos($s, 'stream') + 6));
112            $str = substr($str, 0, strpos($str, 'endstream'));
113
114            // The stream keyword is always followed by a mandatory
115            // end-of-line marker (ISO 32000-1 7.3.8.1) that is not part of
116            // the real payload. Captured once up front - independent of
117            // whether /Length below turns out to be a literal integer, an
118            // indirect reference, or absent entirely - so callers (e.g.
119            // Compiler's encryption pass, via getLeadingEolLength()) can
120            // strip exactly this many leading bytes to recover the real
121            // payload regardless of which branch below executes.
122            $leadingEolLength = str_starts_with($str, "\r\n")
123                ? 2 : ((str_starts_with($str, "\n") || str_starts_with($str, "\r")) ? 1 : 0);
124
125            // __toString() always re-adds the EOL before 'endstream' itself, so
126            // an EOL captured here from the original string would otherwise be
127            // duplicated, making the declared and actual stream lengths disagree.
128            //
129            // A declared, literal /Length is authoritative on exactly how many
130            // data bytes follow the leading EOL - a trailing-byte heuristic
131            // can't be, since it can't distinguish the template's own
132            // separator from a stream whose real last byte(s) happen to be
133            // \r/\n themselves (e.g. "\r" + the template's "\n" looks
134            // identical to one atomic "\r\n" separator, and stripping both
135            // would silently drop a real trailing \r of data).
136            if (preg_match('/\/Length\s+(\d+)\b(?!\s+\d+\s+R\b)/', $def, $lengthMatch)) {
137                $str = substr($str, 0, $leadingEolLength + (int)$lengthMatch[1]);
138            } else if (str_ends_with($str, "\r\n")) {
139                $str = substr($str, 0, -2);
140            } else if (str_ends_with($str, "\n") || str_ends_with($str, "\r")) {
141                $str = substr($str, 0, -1);
142            }
143
144            $object->setDefinition($def);
145            $object->appendStream($str);
146            $object->setLeadingEolLength($leadingEolLength);
147        } else {
148            $object->setDefinition($s);
149        }
150
151        $object->setData("[{object_index}] 0 obj\n[{definition}]\n[{stream}]\nendobj\n\n");
152        return $object;
153    }
154
155    /**
156     * Set the stream object definition
157     *
158     * @param  string $definition
159     * @return StreamObject
160     */
161    public function setDefinition(string $definition): StreamObject
162    {
163        $this->definition = (string)$definition;
164
165        if (str_contains($this->definition, '/ASCIIHexDecode')) {
166            $this->encoding = 'ASCIIHexDecode';
167        } else if (str_contains($this->definition, '/ASCII85Decode')) {
168            $this->encoding = 'ASCII85Decode';
169        } else if (str_contains($this->definition, '/LZWDecode')) {
170            $this->encoding = 'LZWDecode';
171        } else if (str_contains($this->definition, '/FlateDecode')) {
172            $this->encoding = 'FlateDecode';
173        } else if (str_contains($this->definition, '/RunLengthDecode')) {
174            $this->encoding = 'RunLengthDecode';
175        } else if (str_contains($this->definition, '/CCITTFaxDecode')) {
176            $this->encoding = 'CCITTFaxDecode';
177        } else if (str_contains($this->definition, '/JBIG2Decode')) {
178            $this->encoding = 'JBIG2Decode';
179        } else if (str_contains($this->definition, '/DCTDecode')) {
180            $this->encoding = 'DCTDecode';
181        } else if (str_contains($this->definition, '/JPXDecode')) {
182            $this->encoding = 'JPXDecode';
183        } else if (str_contains($this->definition, '/Crypt')) {
184            $this->encoding = 'Crypt';
185        }
186
187        if (stripos($this->definition, '/xobject') !== false) {
188            $this->isXObject = true;
189        }
190
191        return $this;
192    }
193
194    /**
195     * Set the stream object stream
196     *
197     * @param  string $stream
198     * @return StreamObject
199     */
200    public function setStream(string $stream): StreamObject
201    {
202        $this->stream = $stream;
203        return $this;
204    }
205
206    /**
207     * Append to the stream the PDF stream object
208     *
209     * @param  string $stream
210     * @return StreamObject
211     */
212    public function appendStream(string $stream): StreamObject
213    {
214        $this->stream .= $stream;
215        return $this;
216    }
217
218    /**
219     * Get the stream object definition
220     *
221     * @return ?string
222     */
223    public function getDefinition(): ?string
224    {
225        return $this->definition;
226    }
227
228    /**
229     * Get the PDF stream object stream
230     *
231     * @return ?string
232     */
233    public function getStream(): ?string
234    {
235        return $this->stream;
236    }
237
238    /**
239     * Method to encode the PDF stream object with FlateDecode (gzcompress)
240     *
241     * @return void
242     */
243    public function encode(): void
244    {
245        if (($this->stream != '') && (function_exists('gzcompress')) &&
246            (!str_contains((string)$this->definition, ' /Image')) && (!str_contains((string)$this->definition, '/FlateDecode'))) {
247            $this->stream   = "\n" . gzcompress($this->stream, 9) . "\n";
248            $this->encoding = 'FlateDecode';
249
250            // The leading "\n" just added is the mandatory post-"stream"
251            // end-of-line marker (ISO 32000-1 7.3.8.1), not payload - zlib's
252            // own data starts at the byte after it. Recording that here (the
253            // same way parse() does for imported objects) is what lets
254            // Compiler's encryption pass strip it before encrypting: without
255            // it, the "\n" would be encrypted as if it were real deflate
256            // data, and Compiler would then splice a SECOND, uncounted "\n"
257            // in after the "stream" keyword - so a reader would decrypt and
258            // hand FlateDecode a payload with a stray leading 0x0A, failing
259            // with "incorrect header check". The TRAILING "\n" needs no such
260            // treatment: inflate stops at the end of the deflate stream and
261            // ignores trailing bytes, exactly as it already does on the
262            // unencrypted path.
263            $this->leadingEolLength = 1;
264        }
265    }
266
267    /**
268     * Method to decode the PDF stream contents with FlateDecode (gzuncompress)
269     *
270     * @return bool|string
271     */
272    public function decode(): bool|string
273    {
274        $decoded = false;
275        if (($this->stream != '') && function_exists('gzuncompress')) {
276            $decoded = @gzuncompress(trim($this->stream));
277        }
278        return $decoded;
279    }
280
281    /**
282     * Determine whether or not the PDF stream object is encoded
283     *
284     * @return bool
285     */
286    public function isEncoded(): bool
287    {
288        return ($this->encoding !== null);
289    }
290
291    /**
292     * Get the encoding filter
293     *
294     * @return ?string
295     */
296    public function getEncoding(): ?string
297    {
298        return $this->encoding;
299    }
300
301    /**
302     * Set whether the PDF stream object is a palette object
303     *
304     * @param  bool $isPalette
305     * @return StreamObject
306     */
307    public function setPalette(bool $isPalette): StreamObject
308    {
309        $this->isPalette = $isPalette;
310        return $this;
311    }
312
313    /**
314     * Get whether the PDF stream object is a palette object
315     *
316     * @return bool
317     */
318    public function isPalette(): bool
319    {
320        return $this->isPalette;
321    }
322
323    /**
324     * Set the number of leading bytes in the stream that are the mandatory
325     * post-"stream"-keyword end-of-line marker rather than real payload
326     *
327     * @param  int $length
328     * @return StreamObject
329     */
330    public function setLeadingEolLength(int $length): StreamObject
331    {
332        $this->leadingEolLength = $length;
333        return $this;
334    }
335
336    /**
337     * Get the number of leading bytes in the stream that are the mandatory
338     * post-"stream"-keyword end-of-line marker rather than real payload
339     *
340     * @return int
341     */
342    public function getLeadingEolLength(): int
343    {
344        return $this->leadingEolLength;
345    }
346
347    /**
348     * Get whether the PDF stream object is an XObject
349     *
350     * @return bool
351     */
352    public function isXObject(): bool
353    {
354        return $this->isXObject;
355    }
356
357    /**
358     * Get the PDF stream object byte length
359     *
360     * @return int
361     */
362    public function getByteLength(): int
363    {
364        return $this->calculateByteLength((string)$this);
365    }
366
367    /**
368     * Calculate the byte length of a string
369     *
370     * @param  ?string $string
371     * @return int
372     */
373    protected function calculateByteLength(?string $string): int
374    {
375        return strlen((string)$string);
376    }
377
378    /**
379     * Method to print the PDF stream object.
380     *
381     * @return string
382     */
383    public function __toString(): string
384    {
385        // Set the stream, adding linefeed
386        $stream = ($this->stream !== null) ? "stream" . $this->stream . "\nendstream\n" : '';
387
388        // Set up the Length definition. The match/replace is scoped to the
389        // exact "/Length N" (or indirect "/Length N G R") span via
390        // preg_replace's own single-match substitution, rather than a
391        // blanket string search-and-replace on the extracted digits - a
392        // blanket replace would corrupt any other dict value that happens
393        // to contain the same digit substring (e.g. "/Length 38" colliding
394        // with "/Width 384"), and would leave a dangling, meaningless
395        // indirect reference behind for a source using indirect /Length.
396        if ((preg_match('/\/Length\s+\d+(?:\s+\d+\s+R)?/', (string) $this->definition)) &&
397            (!str_contains((string)$this->definition, '/Length1')) &&
398            (!str_contains((string)$this->definition, '/Image'))) {
399            $this->definition = preg_replace(
400                '/\/Length\s+\d+(?:\s+\d+\s+R)?/', '/Length [{byte_length}]', $this->definition, 1
401            );
402        } else if (!str_contains((string)$this->definition, '/Length')) {
403            $this->definition .= "<</Length [{byte_length}]>>\n";
404        }
405
406        // Calculate the byte length of the stream and swap out the placeholders.
407        $byteLength = (($this->encoding == 'FlateDecode') && (function_exists('gzcompress')) &&
408            (!str_contains((string)$this->definition, ' /Image')) && (!str_contains((string)$this->definition, '/FlateDecode'))) ?
409            $this->calculateByteLength($this->stream) . " /Filter /FlateDecode" : $this->calculateByteLength($this->stream);
410
411        $data = str_replace(
412            ['[{object_index}]', '[{stream}]', '[{definition}]', '[{byte_length}]'],
413            [(string)$this->index, $stream, (string)$this->definition, (string)$byteLength],
414            $this->data
415        );
416
417        // Clear Length definition if it is zero.
418        if (str_contains((string)$data, '<</Length 0>>')) {
419            $data = str_replace('<</Length 0>>', '', $data);
420        }
421
422        return $data;
423    }
424
425}