Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.01% covered (success)
91.01%
81 / 89
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageLabeler
91.01% covered (success)
91.01%
81 / 89
93.33% covered (success)
93.33%
14 / 15
31.70
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
 setLabels
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setRomanNumerals
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setAlphabetic
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setArabic
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
10.15
 removeLabels
100.00% covered (success)
100.00%
3 / 3
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%
30 / 30
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
 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\PageLabel;
9use Phpdftk\Pdf\Core\File\IncrementalWriter;
10use Phpdftk\Filesystem\LocalFilesystem;
11use Phpdftk\Pdf\Core\PdfArray;
12use Phpdftk\Pdf\Core\PdfDictionary;
13use Phpdftk\Pdf\Core\PdfName;
14use Phpdftk\Pdf\Core\PdfNumber;
15use Phpdftk\Pdf\Core\PdfObject;
16use Phpdftk\Pdf\Core\PdfReference;
17use Phpdftk\Pdf\Core\PdfString;
18use Phpdftk\Pdf\Reader\PdfReader;
19use Phpdftk\Pdf\Toolkit\Label\LabelStyle;
20
21/**
22 * Set page numbering labels on a PDF.
23 *
24 * Usage:
25 *   PageLabeler::open('report.pdf')
26 *       ->setRomanNumerals(1, 4)        // pages 1-4: i, ii, iii, iv
27 *       ->setArabic(5, null, 1)         // pages 5+: 1, 2, 3, ...
28 *       ->save('labeled.pdf');
29 *
30 * @api
31 */
32final class PageLabeler
33{
34    private string $originalBytes;
35
36    /** @var list<string> */
37    private array $lastVersionWarnings = [];
38
39    /**
40     * Pending label ranges: 0-based page index => [style, prefix, startNumber]
41     *
42     * @var array<int, array{style: LabelStyle, prefix: string, startNumber: int}>
43     */
44    private array $labelSpecs = [];
45
46    private bool $removeAll = false;
47
48    private function __construct(
49        private readonly PdfReader $reader,
50        string $originalBytes,
51    ) {
52        $this->originalBytes = $originalBytes;
53    }
54
55    public static function open(string $path, string $password = ''): self
56    {
57        $bytes = LocalFilesystem::readFile($path);
58        return new self(PdfReader::fromString($bytes, $password), $bytes);
59    }
60
61    public static function openString(string $pdfBytes, string $password = ''): self
62    {
63        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
64    }
65
66    // -----------------------------------------------------------------------
67    // Label configuration (fluent, 1-based page numbers)
68    // -----------------------------------------------------------------------
69
70    /**
71     * Set a label range starting at the given page.
72     *
73     * @param int $startPage 1-based page number where this label range begins
74     */
75    public function setLabels(int $startPage, LabelStyle $style, string $prefix = '', int $startNumber = 1): self
76    {
77        $this->removeAll = false;
78        $this->labelSpecs[$startPage - 1] = [
79            'style' => $style,
80            'prefix' => $prefix,
81            'startNumber' => $startNumber,
82        ];
83        return $this;
84    }
85
86    /**
87     * Set roman numeral labels for a page range.
88     *
89     * @param int $fromPage 1-based start page
90     * @param int $toPage 1-based end page
91     */
92    public function setRomanNumerals(int $fromPage, int $toPage, bool $uppercase = false): self
93    {
94        $style = $uppercase ? LabelStyle::RomanUpper : LabelStyle::RomanLower;
95        $this->setLabels($fromPage, $style);
96
97        // If there's a page after toPage, set arabic numbering to "reset"
98        // unless the caller explicitly sets something else there
99        $nextPage = $toPage + 1;
100        $pageCount = $this->reader->getPageCount();
101        if ($nextPage <= $pageCount && !isset($this->labelSpecs[$nextPage - 1])) {
102            $this->labelSpecs[$nextPage - 1] = [
103                'style' => LabelStyle::Arabic,
104                'prefix' => '',
105                'startNumber' => $nextPage,
106            ];
107        }
108
109        return $this;
110    }
111
112    /**
113     * Set alphabetic labels for a page range.
114     *
115     * @param int $fromPage 1-based start page
116     * @param int $toPage 1-based end page
117     */
118    public function setAlphabetic(int $fromPage, int $toPage, bool $uppercase = false): self
119    {
120        $style = $uppercase ? LabelStyle::AlphaUpper : LabelStyle::AlphaLower;
121        $this->setLabels($fromPage, $style);
122
123        $nextPage = $toPage + 1;
124        $pageCount = $this->reader->getPageCount();
125        if ($nextPage <= $pageCount && !isset($this->labelSpecs[$nextPage - 1])) {
126            $this->labelSpecs[$nextPage - 1] = [
127                'style' => LabelStyle::Arabic,
128                'prefix' => '',
129                'startNumber' => $nextPage,
130            ];
131        }
132
133        return $this;
134    }
135
136    /**
137     * Set arabic numeral labels starting at a page.
138     *
139     * @param int $fromPage 1-based start page
140     * @param int|null $toPage 1-based end page, or null for all remaining
141     */
142    public function setArabic(int $fromPage, ?int $toPage = null, int $startNumber = 1): self
143    {
144        $this->setLabels($fromPage, LabelStyle::Arabic, '', $startNumber);
145
146        if ($toPage !== null) {
147            $nextPage = $toPage + 1;
148            $pageCount = $this->reader->getPageCount();
149            if ($nextPage <= $pageCount && !isset($this->labelSpecs[$nextPage - 1])) {
150                $this->labelSpecs[$nextPage - 1] = [
151                    'style' => LabelStyle::Arabic,
152                    'prefix' => '',
153                    'startNumber' => $nextPage,
154                ];
155            }
156        }
157
158        return $this;
159    }
160
161    /**
162     * Remove all page labels from the document.
163     */
164    public function removeLabels(): self
165    {
166        $this->removeAll = true;
167        $this->labelSpecs = [];
168        return $this;
169    }
170
171    // -----------------------------------------------------------------------
172    // Output
173    // -----------------------------------------------------------------------
174
175    public function save(string $path): void
176    {
177        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
178    }
179
180    public function toBytes(): string
181    {
182        if (empty($this->labelSpecs) && !$this->removeAll) {
183            return $this->originalBytes;
184        }
185
186        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
187        $catalog = $this->reader->getCatalog();
188        $catalogDict = $this->cloneDictionary($catalog);
189
190        if ($this->removeAll) {
191            unset($catalogDict->entries['PageLabels']);
192        } else {
193            // Build the number tree
194            $numberTree = new NumberTree();
195            $numsItems = [];
196
197            // Sort by page index
198            ksort($this->labelSpecs);
199
200            foreach ($this->labelSpecs as $pageIndex => $spec) {
201                $label = new PageLabel();
202                $label->s = new PdfName($spec['style']->value);
203                if ($spec['prefix'] !== '') {
204                    $label->p = new PdfString($spec['prefix']);
205                }
206                $label->st = $spec['startNumber'];
207
208                $numsItems[] = new PdfNumber($pageIndex);
209                $numsItems[] = $label;
210            }
211
212            $numberTree->nums = new PdfArray($numsItems);
213            $numberTreeRef = $writer->addNewObject($numberTree);
214            $catalogDict->set('PageLabels', $numberTreeRef);
215        }
216
217        // Wrap and register modified catalog
218        $catalogObj = $this->wrapDictionary($catalogDict);
219        $rootRef = $this->reader->getTrailer()->get('Root');
220        if ($rootRef instanceof PdfReference) {
221            $catalogObj->objectNumber = $rootRef->objectNumber;
222            $catalogObj->generationNumber = 0;
223        }
224        $writer->addModifiedObject($catalogObj);
225
226        $result = $writer->generate();
227        $this->lastVersionWarnings = $writer->getVersionWarnings();
228        return $result;
229    }
230
231    // -----------------------------------------------------------------------
232    // Escape hatches
233    // -----------------------------------------------------------------------
234
235    /** @return list<string> */
236    public function getVersionWarnings(): array
237    {
238        return $this->lastVersionWarnings;
239    }
240
241    public function getReader(): PdfReader
242    {
243        return $this->reader;
244    }
245
246    public function getPageCount(): int
247    {
248        return $this->reader->getPageCount();
249    }
250
251    // -----------------------------------------------------------------------
252    // Internal
253    // -----------------------------------------------------------------------
254
255    private function cloneDictionary(PdfDictionary $dict): PdfDictionary
256    {
257        $clone = new PdfDictionary();
258        foreach ($dict->entries as $key => $value) {
259            $clone->set($key, $value);
260        }
261        return $clone;
262    }
263
264    private function wrapDictionary(PdfDictionary $dict): PdfObject
265    {
266        return new class ($dict) extends PdfObject {
267            public function __construct(private readonly PdfDictionary $dict) {}
268            public function toPdf(): string
269            {
270                return $this->dict->toPdf();
271            }
272        };
273    }
274}