Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.46% covered (success)
96.46%
109 / 113
90.32% covered (success)
90.32%
28 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
MetadataEditor
96.46% covered (success)
96.46%
109 / 113
90.32% covered (success)
90.32%
28 / 31
46
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
 open
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 openString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProducer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreationDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTrapped
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getAll
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 setTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAuthor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSubject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setKeywords
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCreator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setProducer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCreationDate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setModDate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTrapped
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCustom
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
9
 getVersionWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStringField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getDateField
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 findStartxrefOffset
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\File\IncrementalWriter;
8use Phpdftk\Filesystem\LocalFilesystem;
9use Phpdftk\Pdf\Core\PdfArray;
10use Phpdftk\Pdf\Core\PdfDate;
11use Phpdftk\Pdf\Core\PdfDictionary;
12use Phpdftk\Pdf\Core\PdfName;
13use Phpdftk\Pdf\Core\PdfNumber;
14use Phpdftk\Pdf\Core\PdfObject;
15use Phpdftk\Pdf\Core\PdfReference;
16use Phpdftk\Pdf\Core\PdfString;
17use Phpdftk\Pdf\Core\Serializable;
18use Phpdftk\Pdf\Reader\PdfReader;
19
20/**
21 * Read and modify PDF document metadata (Info dictionary).
22 *
23 * Usage:
24 *   $info = MetadataEditor::openString($bytes)->getAll();
25 *
26 *   MetadataEditor::open('doc.pdf')
27 *       ->setTitle('My Document')
28 *       ->setAuthor('Jane Doe')
29 *       ->save('updated.pdf');
30 *
31 * @api
32 */
33final class MetadataEditor
34{
35    private string $originalBytes;
36
37    /** @var list<string> */
38    private array $lastVersionWarnings = [];
39
40    /** @var array<string, PdfString|PdfName|null> Pending field changes */
41    private array $changes = [];
42
43    private function __construct(
44        private readonly PdfReader $reader,
45        string $originalBytes,
46    ) {
47        $this->originalBytes = $originalBytes;
48    }
49
50    public static function open(string $path, string $password = ''): self
51    {
52        $bytes = LocalFilesystem::readFile($path);
53        return new self(PdfReader::fromString($bytes, $password), $bytes);
54    }
55
56    public static function openString(string $pdfBytes, string $password = ''): self
57    {
58        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
59    }
60
61    // -----------------------------------------------------------------------
62    // Read
63    // -----------------------------------------------------------------------
64
65    public function getTitle(): ?string
66    {
67        return $this->getStringField('Title');
68    }
69
70    public function getAuthor(): ?string
71    {
72        return $this->getStringField('Author');
73    }
74
75    public function getSubject(): ?string
76    {
77        return $this->getStringField('Subject');
78    }
79
80    public function getKeywords(): ?string
81    {
82        return $this->getStringField('Keywords');
83    }
84
85    public function getCreator(): ?string
86    {
87        return $this->getStringField('Creator');
88    }
89
90    public function getProducer(): ?string
91    {
92        return $this->getStringField('Producer');
93    }
94
95    public function getCreationDate(): ?\DateTimeImmutable
96    {
97        return $this->getDateField('CreationDate');
98    }
99
100    public function getModDate(): ?\DateTimeImmutable
101    {
102        return $this->getDateField('ModDate');
103    }
104
105    public function getTrapped(): ?string
106    {
107        $info = $this->reader->getInfo();
108        if ($info === null) {
109            return null;
110        }
111        $val = $info->get('Trapped');
112        return $val instanceof PdfName ? $val->value : null;
113    }
114
115    public function getAll(): MetadataInfo
116    {
117        return new MetadataInfo(
118            title: $this->getTitle(),
119            author: $this->getAuthor(),
120            subject: $this->getSubject(),
121            keywords: $this->getKeywords(),
122            creator: $this->getCreator(),
123            producer: $this->getProducer(),
124            creationDate: $this->getCreationDate(),
125            modDate: $this->getModDate(),
126            trapped: $this->getTrapped(),
127        );
128    }
129
130    // -----------------------------------------------------------------------
131    // Write (fluent)
132    // -----------------------------------------------------------------------
133
134    public function setTitle(string $value): self
135    {
136        $this->changes['Title'] = new PdfString($value);
137        return $this;
138    }
139
140    public function setAuthor(string $value): self
141    {
142        $this->changes['Author'] = new PdfString($value);
143        return $this;
144    }
145
146    public function setSubject(string $value): self
147    {
148        $this->changes['Subject'] = new PdfString($value);
149        return $this;
150    }
151
152    public function setKeywords(string $value): self
153    {
154        $this->changes['Keywords'] = new PdfString($value);
155        return $this;
156    }
157
158    public function setCreator(string $value): self
159    {
160        $this->changes['Creator'] = new PdfString($value);
161        return $this;
162    }
163
164    public function setProducer(string $value): self
165    {
166        $this->changes['Producer'] = new PdfString($value);
167        return $this;
168    }
169
170    public function setCreationDate(\DateTimeInterface $date): self
171    {
172        $this->changes['CreationDate'] = PdfDate::fromDateTime($date);
173        return $this;
174    }
175
176    public function setModDate(\DateTimeInterface $date): self
177    {
178        $this->changes['ModDate'] = PdfDate::fromDateTime($date);
179        return $this;
180    }
181
182    public function setTrapped(string $value): self
183    {
184        $this->changes['Trapped'] = new PdfName($value);
185        return $this;
186    }
187
188    public function setCustom(string $key, string $value): self
189    {
190        $this->changes[$key] = new PdfString($value);
191        return $this;
192    }
193
194    // -----------------------------------------------------------------------
195    // Output
196    // -----------------------------------------------------------------------
197
198    public function save(string $path): void
199    {
200        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
201    }
202
203    public function toBytes(): string
204    {
205        if (empty($this->changes)) {
206            return $this->originalBytes;
207        }
208
209        $trailer = $this->reader->getTrailer();
210        $existingInfoRef = $trailer->get('Info');
211
212        // Build merged dictionary: existing fields + changes
213        $dict = new PdfDictionary();
214        $existingInfo = $this->reader->getInfo();
215        if ($existingInfo !== null) {
216            foreach (array_keys($existingInfo->entries) as $key) {
217                $dict->set($key, $existingInfo->entries[$key]);
218            }
219        }
220        foreach ($this->changes as $key => $value) {
221            $dict->set($key, $value);
222        }
223
224        // Create a PdfObject wrapper for the Info dictionary
225        $info = new class ($dict) extends PdfObject {
226            public function __construct(private readonly PdfDictionary $dict) {}
227            public function toPdf(): string
228            {
229                return $this->dict->toPdf();
230            }
231        };
232
233        if ($existingInfoRef instanceof PdfReference) {
234            // Modify existing Info object
235            $info->objectNumber = $existingInfoRef->objectNumber;
236            $info->generationNumber = 0;
237            $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
238            $writer->addModifiedObject($info);
239        } else {
240            // No existing Info — construct IncrementalWriter manually with new /Info ref
241            $xrefOffset = $this->findStartxrefOffset();
242            $sizeVal = $trailer->get('Size');
243            $size = $sizeVal instanceof PdfNumber ? (int) $sizeVal->toPdf() : 0;
244            $root = $trailer->get('Root');
245            if (!$root instanceof PdfReference) {
246                throw new \RuntimeException('Trailer missing /Root');
247            }
248            $id = $trailer->get('ID');
249            $idArray = $id instanceof PdfArray ? $id : null;
250
251            // Pre-assign object number
252            $info->objectNumber = $size;
253            $info->generationNumber = 0;
254            $infoRef = new PdfReference($size);
255
256            $writer = new IncrementalWriter(
257                $this->originalBytes,
258                $size,
259                $xrefOffset,
260                $root,
261                $infoRef,
262                $idArray,
263            );
264            $writer->addModifiedObject($info);
265        }
266
267        $result = $writer->generate();
268        $this->lastVersionWarnings = $writer->getVersionWarnings();
269        return $result;
270    }
271
272    // -----------------------------------------------------------------------
273    // Escape hatches
274    // -----------------------------------------------------------------------
275
276    /** @return list<string> */
277    public function getVersionWarnings(): array
278    {
279        return $this->lastVersionWarnings;
280    }
281
282    public function getReader(): PdfReader
283    {
284        return $this->reader;
285    }
286
287    public function getPageCount(): int
288    {
289        return $this->reader->getPageCount();
290    }
291
292    // -----------------------------------------------------------------------
293    // Internal
294    // -----------------------------------------------------------------------
295
296    private function getStringField(string $key): ?string
297    {
298        $info = $this->reader->getInfo();
299        if ($info === null) {
300            return null;
301        }
302        $val = $info->get($key);
303        return $val instanceof PdfString ? $val->value : null;
304    }
305
306    private function getDateField(string $key): ?\DateTimeImmutable
307    {
308        $raw = $this->getStringField($key);
309        if ($raw === null) {
310            return null;
311        }
312        return PdfDate::parse($raw);
313    }
314
315    private function findStartxrefOffset(): int
316    {
317        $tailLen = min(1024, strlen($this->originalBytes));
318        $tail = substr($this->originalBytes, -$tailLen);
319        $pos = strrpos($tail, 'startxref');
320        if ($pos === false) {
321            throw new \RuntimeException('Cannot find startxref in PDF');
322        }
323        $after = substr($tail, $pos + strlen('startxref'));
324        if (!preg_match('/\s+(\d+)/', $after, $m)) {
325            throw new \RuntimeException('Cannot parse startxref offset');
326        }
327        return (int) $m[1];
328    }
329}