Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.45% covered (success)
92.45%
147 / 159
70.83% covered (warning)
70.83%
17 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
BookmarkEditor
92.45% covered (success)
92.45%
147 / 159
70.83% covered (warning)
70.83%
17 / 24
66.82
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
 getBookmarks
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 hasBookmarks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setBookmarks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addBookmark
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 removeBookmarks
100.00% covered (success)
100.00%
4 / 4
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
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 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
 collectPageReferences
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 collectPageRefs
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
 readOutlineChildren
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 extractTitle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveDestPageNumber
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 resolveEffectiveBookmarks
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 buildOutlineTree
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 createOutlineItems
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 countAllEntries
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cloneDictionary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 wrapDictionary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\Document\NumberTree;
8use Phpdftk\Pdf\Core\Document\Outline;
9use Phpdftk\Pdf\Core\Document\OutlineItem;
10use Phpdftk\Pdf\Core\File\IncrementalWriter;
11use Phpdftk\Filesystem\LocalFilesystem;
12use Phpdftk\Pdf\Core\PdfArray;
13use Phpdftk\Pdf\Core\PdfDictionary;
14use Phpdftk\Pdf\Core\PdfName;
15use Phpdftk\Pdf\Core\PdfNumber;
16use Phpdftk\Pdf\Core\PdfObject;
17use Phpdftk\Pdf\Core\PdfReference;
18use Phpdftk\Pdf\Core\PdfString;
19use Phpdftk\Pdf\Reader\PdfReader;
20use Phpdftk\Pdf\Toolkit\Bookmark\BookmarkEntry;
21
22/**
23 * Add, replace, read, or remove PDF bookmarks (outlines).
24 *
25 * Usage:
26 *   BookmarkEditor::open('report.pdf')
27 *       ->setBookmarks(
28 *           new BookmarkEntry('Chapter 1', 1),
29 *           new BookmarkEntry('Chapter 2', 5, [
30 *               new BookmarkEntry('Section 2.1', 5),
31 *               new BookmarkEntry('Section 2.2', 8),
32 *           ]),
33 *       )
34 *       ->save('bookmarked.pdf');
35 *
36 * @api
37 */
38final class BookmarkEditor
39{
40    private string $originalBytes;
41
42    /** @var list<string> */
43    private array $lastVersionWarnings = [];
44
45    /** @var list<BookmarkEntry>|null Pending bookmark set (null = no change) */
46    private ?array $pendingBookmarks = null;
47
48    /** @var list<BookmarkEntry> Bookmarks to append */
49    private array $appendBookmarks = [];
50
51    private bool $removeAll = false;
52
53    private function __construct(
54        private readonly PdfReader $reader,
55        string $originalBytes,
56    ) {
57        $this->originalBytes = $originalBytes;
58    }
59
60    public static function open(string $path, string $password = ''): self
61    {
62        $bytes = LocalFilesystem::readFile($path);
63        return new self(PdfReader::fromString($bytes, $password), $bytes);
64    }
65
66    public static function openString(string $pdfBytes, string $password = ''): self
67    {
68        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
69    }
70
71    // -----------------------------------------------------------------------
72    // Read
73    // -----------------------------------------------------------------------
74
75    /**
76     * Read existing bookmarks from the PDF.
77     *
78     * @return list<BookmarkEntry>
79     */
80    public function getBookmarks(): array
81    {
82        $catalog = $this->reader->getCatalog();
83        $outlinesRef = $catalog->get('Outlines');
84        if (!$outlinesRef instanceof PdfReference) {
85            return [];
86        }
87
88        $outlinesDict = $this->reader->resolveReference($outlinesRef);
89        if (!$outlinesDict instanceof PdfDictionary) {
90            return [];
91        }
92
93        $pageRefs = $this->collectPageReferences();
94        return $this->readOutlineChildren($outlinesDict, $pageRefs);
95    }
96
97    public function hasBookmarks(): bool
98    {
99        return $this->getBookmarks() !== [];
100    }
101
102    // -----------------------------------------------------------------------
103    // Write (fluent)
104    // -----------------------------------------------------------------------
105
106    /**
107     * Replace all bookmarks with the given entries.
108     */
109    public function setBookmarks(BookmarkEntry ...$entries): self
110    {
111        $this->pendingBookmarks = array_values($entries);
112        $this->removeAll = false;
113        $this->appendBookmarks = [];
114        return $this;
115    }
116
117    /**
118     * Add a single bookmark (appended to existing bookmarks).
119     */
120    public function addBookmark(string $title, int $pageNumber): self
121    {
122        $this->appendBookmarks[] = new BookmarkEntry($title, $pageNumber);
123        return $this;
124    }
125
126    /**
127     * Remove all bookmarks from the document.
128     */
129    public function removeBookmarks(): self
130    {
131        $this->removeAll = true;
132        $this->pendingBookmarks = null;
133        $this->appendBookmarks = [];
134        return $this;
135    }
136
137    // -----------------------------------------------------------------------
138    // Output
139    // -----------------------------------------------------------------------
140
141    public function save(string $path): void
142    {
143        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
144    }
145
146    public function toBytes(): string
147    {
148        // Determine the effective bookmark list
149        $entries = $this->resolveEffectiveBookmarks();
150        if ($entries === null && !$this->removeAll) {
151            return $this->originalBytes;
152        }
153
154        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
155        $pageRefs = $this->collectPageReferences();
156
157        // Build the catalog modification
158        $catalog = $this->reader->getCatalog();
159        $catalogDict = $this->cloneDictionary($catalog);
160
161        if ($this->removeAll && ($entries === null || $entries === [])) {
162            // Remove /Outlines from catalog
163            unset($catalogDict->entries['Outlines']);
164        } else {
165            // Build outline tree
166            $outlineRoot = new Outline();
167            $outlineRootRef = $writer->addNewObject($outlineRoot);
168
169            /** @var list<BookmarkEntry> $entries */
170            $this->buildOutlineTree($writer, $outlineRoot, $outlineRootRef, $entries ?? [], $pageRefs);
171
172            $catalogDict->set('Outlines', $outlineRootRef);
173        }
174
175        // Wrap catalog dict in a PdfObject and register as modified
176        $catalogObj = $this->wrapDictionary($catalogDict);
177        $rootRef = $this->reader->getTrailer()->get('Root');
178        if ($rootRef instanceof PdfReference) {
179            $catalogObj->objectNumber = $rootRef->objectNumber;
180            $catalogObj->generationNumber = 0;
181        }
182        $writer->addModifiedObject($catalogObj);
183
184        $result = $writer->generate();
185        $this->lastVersionWarnings = $writer->getVersionWarnings();
186        return $result;
187    }
188
189    // -----------------------------------------------------------------------
190    // Escape hatches
191    // -----------------------------------------------------------------------
192
193    /** @return list<string> */
194    public function getVersionWarnings(): array
195    {
196        return $this->lastVersionWarnings;
197    }
198
199    public function getReader(): PdfReader
200    {
201        return $this->reader;
202    }
203
204    public function getPageCount(): int
205    {
206        return $this->reader->getPageCount();
207    }
208
209    // -----------------------------------------------------------------------
210    // Internal — reading
211    // -----------------------------------------------------------------------
212
213    /**
214     * Collect PdfReference objects for each page in order.
215     *
216     * @return list<PdfReference> 0-indexed; pageRefs[0] = page 1
217     */
218    private function collectPageReferences(): array
219    {
220        $catalog = $this->reader->getCatalog();
221        $pagesRef = $catalog->get('Pages');
222        if (!$pagesRef instanceof PdfReference) {
223            return [];
224        }
225        $pagesDict = $this->reader->resolveReference($pagesRef);
226        if (!$pagesDict instanceof PdfDictionary) {
227            return [];
228        }
229        $refs = [];
230        $this->collectPageRefs($pagesDict, $refs);
231        return $refs;
232    }
233
234    /**
235     * @param list<PdfReference> $refs
236     */
237    private function collectPageRefs(PdfDictionary $node, array &$refs): void
238    {
239        $kids = $node->get('Kids');
240        if (!$kids instanceof PdfArray) {
241            return;
242        }
243        foreach ($kids->items as $kidRef) {
244            if (!$kidRef instanceof PdfReference) {
245                continue;
246            }
247            $kid = $this->reader->resolveReference($kidRef);
248            if (!$kid instanceof PdfDictionary) {
249                continue;
250            }
251            $type = $kid->get('Type');
252            if ($type instanceof PdfName && $type->value === 'Pages') {
253                $this->collectPageRefs($kid, $refs);
254            } else {
255                $refs[] = $kidRef;
256            }
257        }
258    }
259
260    /**
261     * Read outline items from an outline dict following /First chain.
262     *
263     * @param list<PdfReference> $pageRefs
264     * @return list<BookmarkEntry>
265     */
266    private function readOutlineChildren(PdfDictionary $parentDict, array $pageRefs): array
267    {
268        $entries = [];
269        $firstRef = $parentDict->get('First');
270        if (!$firstRef instanceof PdfReference) {
271            return [];
272        }
273
274        $currentRef = $firstRef;
275        $visited = [];
276        while ($currentRef instanceof PdfReference) {
277            // Guard against circular references
278            if (isset($visited[$currentRef->objectNumber])) {
279                break;
280            }
281            $visited[$currentRef->objectNumber] = true;
282
283            $itemDict = $this->reader->resolveReference($currentRef);
284            if (!$itemDict instanceof PdfDictionary) {
285                break;
286            }
287
288            $title = $this->extractTitle($itemDict);
289            $pageNumber = $this->resolveDestPageNumber($itemDict, $pageRefs);
290            $children = $this->readOutlineChildren($itemDict, $pageRefs);
291
292            $entries[] = new BookmarkEntry($title, $pageNumber, $children);
293
294            $currentRef = $itemDict->get('Next');
295        }
296
297        return $entries;
298    }
299
300    private function extractTitle(PdfDictionary $dict): string
301    {
302        $title = $dict->get('Title');
303        if ($title instanceof PdfString) {
304            return $title->value;
305        }
306        return '';
307    }
308
309    /**
310     * Resolve a /Dest entry to a 1-based page number.
311     *
312     * @param list<PdfReference> $pageRefs
313     */
314    private function resolveDestPageNumber(PdfDictionary $itemDict, array $pageRefs): int
315    {
316        $dest = $itemDict->get('Dest');
317        if ($dest instanceof PdfArray && count($dest->items) >= 1) {
318            $pageRef = $dest->items[0];
319            if ($pageRef instanceof PdfReference) {
320                foreach ($pageRefs as $index => $ref) {
321                    if ($ref->objectNumber === $pageRef->objectNumber) {
322                        return $index + 1;
323                    }
324                }
325            }
326        }
327        // Default to page 1 if destination cannot be resolved
328        return 1;
329    }
330
331    // -----------------------------------------------------------------------
332    // Internal — writing
333    // -----------------------------------------------------------------------
334
335    /**
336     * Determine what bookmarks to write, or null if nothing changed.
337     *
338     * @return list<BookmarkEntry>|null
339     */
340    private function resolveEffectiveBookmarks(): ?array
341    {
342        if ($this->pendingBookmarks !== null) {
343            return $this->pendingBookmarks;
344        }
345        if ($this->removeAll) {
346            return null;
347        }
348        if ($this->appendBookmarks !== []) {
349            $existing = $this->getBookmarks();
350            return array_merge($existing, $this->appendBookmarks);
351        }
352        return null;
353    }
354
355    /**
356     * Build the outline tree from BookmarkEntry objects.
357     *
358     * @param list<BookmarkEntry> $entries
359     * @param list<PdfReference> $pageRefs
360     */
361    private function buildOutlineTree(
362        IncrementalWriter $writer,
363        Outline $outlineRoot,
364        PdfReference $outlineRootRef,
365        array $entries,
366        array $pageRefs,
367    ): void {
368        if (empty($entries)) {
369            return;
370        }
371
372        $totalCount = $this->countAllEntries($entries);
373        $outlineRoot->count = $totalCount;
374
375        $items = $this->createOutlineItems($writer, $entries, $outlineRootRef, $pageRefs);
376
377        if (!empty($items)) {
378            $outlineRoot->first = $items[0]['ref'];
379            $outlineRoot->last = $items[count($items) - 1]['ref'];
380        }
381    }
382
383    /**
384     * Create OutlineItem objects for a list of entries at one level.
385     *
386     * @param list<BookmarkEntry> $entries
387     * @param list<PdfReference> $pageRefs
388     * @return list<array{ref: PdfReference, item: OutlineItem}>
389     */
390    private function createOutlineItems(
391        IncrementalWriter $writer,
392        array $entries,
393        PdfReference $parentRef,
394        array $pageRefs,
395    ): array {
396        $items = [];
397
398        foreach ($entries as $entry) {
399            $item = new OutlineItem($entry->title);
400            $item->parent = $parentRef;
401
402            // Set destination
403            $pageIndex = max(0, min($entry->pageNumber - 1, count($pageRefs) - 1));
404            if (isset($pageRefs[$pageIndex])) {
405                $item->dest = new PdfArray([$pageRefs[$pageIndex], new PdfName('Fit')]);
406            }
407
408            $ref = $writer->addNewObject($item);
409            $items[] = ['ref' => $ref, 'item' => $item];
410        }
411
412        // Wire prev/next doubly-linked list
413        for ($i = 0; $i < count($items); $i++) {
414            if ($i > 0) {
415                $items[$i]['item']->prev = $items[$i - 1]['ref'];
416            }
417            if ($i < count($items) - 1) {
418                $items[$i]['item']->next = $items[$i + 1]['ref'];
419            }
420        }
421
422        // Recursively build children
423        foreach ($entries as $idx => $entry) {
424            if (!empty($entry->children)) {
425                $childItems = $this->createOutlineItems(
426                    $writer,
427                    $entry->children,
428                    $items[$idx]['ref'],
429                    $pageRefs,
430                );
431                if (!empty($childItems)) {
432                    $items[$idx]['item']->first = $childItems[0]['ref'];
433                    $items[$idx]['item']->last = $childItems[count($childItems) - 1]['ref'];
434                    $items[$idx]['item']->count = $this->countAllEntries($entry->children);
435                }
436            }
437        }
438
439        return $items;
440    }
441
442    /**
443     * Count total visible entries recursively.
444     *
445     * @param list<BookmarkEntry> $entries
446     */
447    private function countAllEntries(array $entries): int
448    {
449        $count = count($entries);
450        foreach ($entries as $entry) {
451            $count += $this->countAllEntries($entry->children);
452        }
453        return $count;
454    }
455
456    // -----------------------------------------------------------------------
457    // Internal — helpers
458    // -----------------------------------------------------------------------
459
460    private function cloneDictionary(PdfDictionary $dict): PdfDictionary
461    {
462        $clone = new PdfDictionary();
463        foreach ($dict->entries as $key => $value) {
464            $clone->set($key, $value);
465        }
466        return $clone;
467    }
468
469    private function wrapDictionary(PdfDictionary $dict): PdfObject
470    {
471        return new class ($dict) extends PdfObject {
472            public function __construct(private readonly PdfDictionary $dict) {}
473            public function toPdf(): string
474            {
475                return $this->dict->toPdf();
476            }
477        };
478    }
479}