Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
113 / 113
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
InfoObject
100.00% covered (success)
100.00%
113 / 113
100.00% covered (success)
100.00%
6 / 6
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parse
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
8
 setMetadata
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMetadata
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 encryptWith
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 __toString
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
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
17use Pop\Pdf\Document\Metadata;
18use Pop\Pdf\Document\Page\Text;
19
20/**
21 * Pdf info object class
22 *
23 * @category   Pop
24 * @package    Pop\Pdf
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    6.2.0
29 */
30class InfoObject extends AbstractObject
31{
32
33    /**
34     * PDF info object index
35     * @var ?int
36     */
37    protected ?int $index = 3;
38
39    /**
40     * PDF metadata for the info object
41     * @var ?Metadata
42     */
43    protected ?Metadata $metadata = null;
44
45    /**
46     * Encrypted, PDF-literal-string-escaped overrides for this object's
47     * fields, keyed by the same placeholder name used in $this->data (e.g.
48     * 'title', 'creation_date'). Populated by encryptWith() and consumed by
49     * __toString(); empty when the document has no security configured, in
50     * which case __toString() falls back to the raw, unescaped metadata
51     * values exactly as it always has.
52     * @var array<string, string>
53     */
54    protected array $encrypted = [];
55
56    /**
57     * Constructor
58     *
59     * Instantiate a PDF info object.
60     *
61     * @param  int       $index
62     * @param  ?Metadata $metadata
63     */
64    public function __construct(int $index = 3, ?Metadata $metadata = null)
65    {
66        $this->setIndex($index);
67        $this->setData("[{info_index}] 0 obj\n<</Creator([{creator}])/CreationDate([{creation_date}])/ModDate" .
68            "([{mod_date}])/Author([{author}])/Title([{title}])/Subject([{subject}])/Producer([{producer}])>>\nendobj\n");
69
70        if ($metadata !== null) {
71            $this->setMetadata($metadata);
72        }
73    }
74
75    /**
76     * Parse a info object from a string
77     *
78     * @param  string $stream
79     * @return InfoObject
80     */
81    public static function parse(string $stream): InfoObject
82    {
83        $info = new self();
84        $info->setIndex((int)substr($stream, 0, strpos($stream, ' ')));
85        $stream = str_replace($info->getIndex() . ' 0 obj', '[{info_index}] 0 obj', $stream);
86
87        // Determine the Creator
88        if (str_contains($stream, '/Creator')) {
89            $creator = substr($stream, strpos($stream, '/Creator'));
90            $creator = substr($creator, strpos($creator, '('));
91            $creator = substr($creator, 0, strpos($creator, ')'));
92            $creator =  str_replace('(', '', $creator);
93            $stream =  str_replace('Creator(' . $creator . ')', 'Creator([{creator}])', $stream);
94            $info->getMetadata()->setCreator($creator);
95        } else {
96            $stream =  str_replace('>>', '/Creator([{creator}])>>', $stream);
97        }
98
99        // Determine the CreationDate
100        if (str_contains($stream, '/CreationDate')) {
101            $creationDate = substr($stream, strpos($stream, '/CreationDate'));
102            $creationDate = substr($creationDate, strpos($creationDate, '('));
103            $creationDate = substr($creationDate, 0, strpos($creationDate, ')'));
104            $creationDate =  str_replace('(', '', $creationDate);
105            $stream =  str_replace('CreationDate(' . $creationDate . ')', 'CreationDate([{creation_date}])', $stream);
106            $info->getMetadata()->setCreationDate($creationDate);
107        } else {
108            $stream =  str_replace('>>', '/CreationDate([{creation_date}])>>', $stream);
109        }
110
111        // Determine the ModDate
112        if (str_contains($stream, '/ModDate')) {
113            $modDate = substr($stream, strpos($stream, '/ModDate'));
114            $modDate = substr($modDate, strpos($modDate, '('));
115            $modDate = substr($modDate, 0, strpos($modDate, ')'));
116            $modDate =  str_replace('(', '', $modDate);
117            $stream =  str_replace('ModDate(' . $modDate . ')', 'ModDate([{mod_date}])', $stream);
118            $info->getMetadata()->setModDate($modDate);
119        } else {
120            $stream =  str_replace('>>', '/ModDate([{mod_date}])>>', $stream);
121        }
122
123        // Determine the Author
124        if (str_contains($stream, '/Author')) {
125            $author = substr($stream, strpos($stream, '/Author'));
126            $author = substr($author, strpos($author, '('));
127            $author = substr($author, 0, strpos($author, ')'));
128            $author =  str_replace('(', '', $author);
129            $stream =  str_replace('Author(' . $author . ')', 'Author([{author}])', $stream);
130            $info->getMetadata()->setAuthor($author);
131        } else {
132            $stream =  str_replace('>>', '/Author([{author}])>>', $stream);
133        }
134
135        // Determine the Title
136        if (str_contains($stream, '/Title')) {
137            $title = substr($stream, strpos($stream, '/Title'));
138            $title = substr($title, strpos($title, '('));
139            $title = substr($title, 0, strpos($title, ')'));
140            $title =  str_replace('(', '', $title);
141            $stream =  str_replace('Title(' . $title . ')', 'Title([{title}])', $stream);
142            $info->getMetadata()->setTitle($title);
143        } else {
144            $stream =  str_replace('>>', '/Title([{title}])>>', $stream);
145        }
146
147        // Determine the Subject
148        if (str_contains($stream, '/Subject')) {
149            $subject = substr($stream, strpos($stream, '/Subject'));
150            $subject = substr($subject, strpos($subject, '('));
151            $subject = substr($subject, 0, strpos($subject, ')'));
152            $subject =  str_replace('(', '', $subject);
153            $stream =  str_replace('Subject(' . $subject . ')', 'Subject([{subject}])', $stream);
154            $info->getMetadata()->setSubject($subject);
155        } else {
156            $stream =  str_replace('>>', '/Subject([{subject}])>>', $stream);
157        }
158
159        // Determine the Producer
160        if (str_contains($stream, '/Producer')) {
161            $producer = substr($stream, strpos($stream, '/Producer'));
162            $producer = substr($producer, strpos($producer, '('));
163            $producer = substr($producer, 0, strpos($producer, ')'));
164            $producer =  str_replace('(', '', $producer);
165            $stream =  str_replace('Producer(' . $producer . ')', 'Producer([{producer}])', $stream);
166            $info->getMetadata()->setProducer($producer);
167        } else {
168            $stream =  str_replace('>>', '/Producer([{producer}])>>', $stream);
169        }
170
171        $info->setData($stream);
172        return $info;
173    }
174
175    /**
176     * Set the info object metadata
177     *
178     * @param  Metadata $metadata
179     * @return InfoObject
180     */
181    public function setMetadata(Metadata $metadata): InfoObject
182    {
183        $this->metadata = $metadata;
184        return $this;
185    }
186
187    /**
188     * Get the info object metadata
189     *
190     * @return ?Metadata
191     */
192    public function getMetadata(): ?Metadata
193    {
194        if ($this->metadata === null) {
195            $this->metadata = new Metadata();
196        }
197        return $this->metadata;
198    }
199
200    /**
201     * Run this object's literal string fields (title, author, subject,
202     * creator, producer, and the CreationDate/ModDate timestamps) through an
203     * encryption callback.
204     *
205     * Invoked from Compiler's encryption pass, alongside the StreamObject
206     * loop, since this object builds its literal PDF strings directly rather
207     * than going through StreamObject and so isn't covered by that pass.
208     * $encryptor receives each field's raw string value and must return its
209     * encrypted bytes; those bytes are then escaped for PDF literal-string
210     * syntax the same way an unencrypted value's reserved bytes would be.
211     *
212     * The CreationDate/ModDate defaults are resolved here (rather than left
213     * for __toString() to fill in lazily) so the value that gets encrypted
214     * is exactly the value __toString() later emits - otherwise a still-null
215     * date would be encrypted as an empty string here and then overwritten
216     * with a plaintext default in __toString(), leaving a date a decrypting
217     * reader would garble.
218     *
219     * @param  callable $encryptor
220     * @return InfoObject
221     */
222    public function encryptWith(callable $encryptor): InfoObject
223    {
224        $metadata = $this->getMetadata();
225
226        if ($metadata->getCreationDate() === null) {
227            $metadata->setCreationDate(date('D, M j, Y h:i A'));
228        }
229        if ($metadata->getModDate() === null) {
230            $metadata->setModDate(date('D, M j, Y h:i A'));
231        }
232
233        $fields = [
234            'title'         => $metadata->getTitle(),
235            'subject'       => $metadata->getSubject(),
236            'author'        => $metadata->getAuthor(),
237            'creator'       => $metadata->getCreator(),
238            'producer'      => $metadata->getProducer(),
239            'mod_date'      => $metadata->getModDate(),
240            'creation_date' => $metadata->getCreationDate(),
241        ];
242
243        foreach ($fields as $key => $value) {
244            if ($value !== null) {
245                // AES-encrypted bytes are arbitrary binary and, unlike a
246                // human-authored title/author string, routinely contain raw
247                // 0x0D/0x0A bytes among other reserved bytes. A compliant
248                // literal-string reader normalizes any *unescaped* raw CR
249                // (and CR/LF pairs) to a bare LF per ISO 32000-1 7.3.4.2,
250                // silently altering that byte - which corrupts not just that
251                // byte but the whole 16-byte AES-CBC block it's part of once
252                // decrypted. Text::escape() (already used everywhere else in
253                // this codebase that emits literal PDF strings) backslash-
254                // escapes CR/LF/tab/backspace/form-feed in addition to
255                // backslash/parens, which is what prevents that
256                // normalization from ever touching the byte in the first
257                // place - a plain backslash/parens-only escape (sufficient
258                // for ordinary text) is not enough here.
259                $this->encrypted[$key] = Text::escape($encryptor((string)$value));
260            }
261        }
262
263        return $this;
264    }
265
266    /**
267     * Method to print the PDF info object.
268     *
269     * @return string
270     */
271    public function __toString(): string
272    {
273        if ($this->metadata === null) {
274            $this->metadata = new \Pop\Pdf\Document\Metadata();
275        }
276
277        // Set the CreationDate and the ModDate if they are null.
278        if ($this->metadata->getCreationDate() === null) {
279            $this->metadata->setCreationDate(date('D, M j, Y h:i A'));
280        }
281        if ($this->metadata->getModDate() === null) {
282            $this->metadata->setModDate(date('D, M j, Y h:i A'));
283        }
284
285        // Encrypted (and already literal-string-escaped) overrides take
286        // precedence, field by field, over the raw metadata value - set by
287        // encryptWith() when the document has security configured, and
288        // empty otherwise.
289        //
290        // A raw metadata value has to be escaped here for exactly the same
291        // reason encryptWith() escapes its ciphertext: these values are
292        // substituted straight into (...) literal-string syntax, so an
293        // unbalanced parenthesis or a stray backslash anywhere in one of them
294        // ends the string early and corrupts the surrounding object. That is
295        // not hypothetical - Build\Parser copies a source PDF's /Info strings
296        // into Metadata verbatim, and for a source encrypted with a
297        // non-/Identity /StrF (including this library's own /StrF /StdCF
298        // output) those "strings" are raw AES ciphertext: arbitrary binary,
299        // routinely containing both. Escaping is applied to the value only -
300        // never to the already-escaped encryptWith() override, which would
301        // double-escape it.
302        $title        = $this->encrypted['title']          ?? Text::escape((string)$this->metadata->getTitle());
303        $subject      = $this->encrypted['subject']        ?? Text::escape((string)$this->metadata->getSubject());
304        $author       = $this->encrypted['author']         ?? Text::escape((string)$this->metadata->getAuthor());
305        $creator      = $this->encrypted['creator']        ?? Text::escape((string)$this->metadata->getCreator());
306        $producer     = $this->encrypted['producer']       ?? Text::escape((string)$this->metadata->getProducer());
307        $modDate      = $this->encrypted['mod_date']       ?? Text::escape((string)$this->metadata->getModDate());
308        $creationDate = $this->encrypted['creation_date']  ?? Text::escape((string)$this->metadata->getCreationDate());
309
310        return str_replace(
311            [
312                '[{info_index}]', '[{title}]', '[{subject}]', '[{author}]',
313                '[{creator}]', '[{producer}]', '[{mod_date}]', '[{creation_date}]'
314            ],
315            [
316                (string)$this->index, $title, $subject, $author,
317                $creator, $producer, $modDate, $creationDate
318            ],
319            $this->data
320        );
321    }
322
323}