Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.84% covered (success)
90.84%
119 / 131
75.00% covered (warning)
75.00%
18 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageTransformer
90.84% covered (success)
90.84%
119 / 131
75.00% covered (warning)
75.00%
18 / 24
59.50
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
 rotate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 scale
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 scaleTo
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 setCropBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMediaBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTrimBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBleedBox
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
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
12
 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
 resolvePageEntries
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 collectPageEntries
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
 applyRotate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 applyScale
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 applyScaleTo
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 applySetBox
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 scaleBox
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 numVal
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 cloneDict
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
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\PdfDictionary;
11use Phpdftk\Pdf\Core\PdfName;
12use Phpdftk\Pdf\Core\PdfNumber;
13use Phpdftk\Pdf\Core\PdfObject;
14use Phpdftk\Pdf\Core\PdfReference;
15use Phpdftk\Pdf\Reader\PdfReader;
16
17/**
18 * Transform page geometry — rotate, scale, and set page boxes.
19 *
20 * Uses incremental updates so the original PDF content is preserved
21 * intact and only modified page dictionaries are appended.
22 *
23 * Usage:
24 *   PageTransformer::open('input.pdf')
25 *       ->rotate(90)
26 *       ->setCropBox(0, 0, 300, 400, PageSelector::pages(1))
27 *       ->save('output.pdf');
28 *
29 * @api
30 */
31final class PageTransformer
32{
33    private string $originalBytes;
34
35    /** @var list<array{op: string, args: array<string, mixed>, pages: ?PageSelector}> */
36    private array $operations = [];
37
38    /** @var list<string> */
39    private array $lastVersionWarnings = [];
40
41    private function __construct(
42        private readonly PdfReader $reader,
43        string $originalBytes,
44    ) {
45        $this->originalBytes = $originalBytes;
46    }
47
48    public static function open(string $path, string $password = ''): self
49    {
50        $bytes = LocalFilesystem::readFile($path);
51        return new self(PdfReader::fromString($bytes, $password), $bytes);
52    }
53
54    public static function openString(string $pdfBytes, string $password = ''): self
55    {
56        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
57    }
58
59    // -----------------------------------------------------------------------
60    // Transform operations (fluent)
61    // -----------------------------------------------------------------------
62
63    /**
64     * Rotate pages by the given angle.
65     *
66     * @param int $degrees Must be 0, 90, 180, or 270
67     */
68    public function rotate(int $degrees, ?PageSelector $pages = null): self
69    {
70        if ($degrees % 90 !== 0) {
71            throw new \InvalidArgumentException('Rotation must be a multiple of 90 degrees');
72        }
73        // Normalize to 0..359
74        $degrees = (($degrees % 360) + 360) % 360;
75        $this->operations[] = ['op' => 'rotate', 'args' => ['degrees' => $degrees], 'pages' => $pages];
76        return $this;
77    }
78
79    /**
80     * Scale pages by a uniform factor.
81     *
82     * Multiplies all page box dimensions (MediaBox, CropBox, etc.) by the factor.
83     */
84    public function scale(float $factor, ?PageSelector $pages = null): self
85    {
86        if ($factor <= 0) {
87            throw new \InvalidArgumentException('Scale factor must be positive');
88        }
89        $this->operations[] = ['op' => 'scale', 'args' => ['factor' => $factor], 'pages' => $pages];
90        return $this;
91    }
92
93    /**
94     * Scale pages to fit the given dimensions.
95     *
96     * Computes the uniform scale factor from the MediaBox and applies it.
97     */
98    public function scaleTo(float $width, float $height, ?PageSelector $pages = null): self
99    {
100        if ($width <= 0 || $height <= 0) {
101            throw new \InvalidArgumentException('Target dimensions must be positive');
102        }
103        $this->operations[] = ['op' => 'scaleTo', 'args' => ['width' => $width, 'height' => $height], 'pages' => $pages];
104        return $this;
105    }
106
107    /**
108     * Set the CropBox on selected pages.
109     */
110    public function setCropBox(float $x, float $y, float $w, float $h, ?PageSelector $pages = null): self
111    {
112        $this->operations[] = ['op' => 'setBox', 'args' => ['box' => 'CropBox', 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h], 'pages' => $pages];
113        return $this;
114    }
115
116    /**
117     * Set the MediaBox on selected pages.
118     */
119    public function setMediaBox(float $x, float $y, float $w, float $h, ?PageSelector $pages = null): self
120    {
121        $this->operations[] = ['op' => 'setBox', 'args' => ['box' => 'MediaBox', 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h], 'pages' => $pages];
122        return $this;
123    }
124
125    /**
126     * Set the TrimBox on selected pages.
127     */
128    public function setTrimBox(float $x, float $y, float $w, float $h, ?PageSelector $pages = null): self
129    {
130        $this->operations[] = ['op' => 'setBox', 'args' => ['box' => 'TrimBox', 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h], 'pages' => $pages];
131        return $this;
132    }
133
134    /**
135     * Set the BleedBox on selected pages.
136     */
137    public function setBleedBox(float $x, float $y, float $w, float $h, ?PageSelector $pages = null): self
138    {
139        $this->operations[] = ['op' => 'setBox', 'args' => ['box' => 'BleedBox', 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h], 'pages' => $pages];
140        return $this;
141    }
142
143    // -----------------------------------------------------------------------
144    // Output
145    // -----------------------------------------------------------------------
146
147    public function save(string $path): void
148    {
149        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
150    }
151
152    public function toBytes(): string
153    {
154        if (empty($this->operations)) {
155            return $this->originalBytes;
156        }
157
158        // Resolve page tree to get (objectNumber, PdfDictionary) pairs
159        $pageEntries = $this->resolvePageEntries();
160        $totalPages = count($pageEntries);
161
162        // Track which pages have been modified (by 0-based index)
163        /** @var array<int, PdfDictionary> */
164        $modifiedPages = [];
165
166        foreach ($this->operations as $operation) {
167            $selector = $operation['pages'] ?? PageSelector::all();
168            $indices = $selector->resolve($totalPages);
169
170            foreach ($indices as $pageIndex) {
171                // Clone the dict on first modification
172                if (!isset($modifiedPages[$pageIndex])) {
173                    $modifiedPages[$pageIndex] = $this->cloneDict($pageEntries[$pageIndex]['dict']);
174                }
175                $dict = $modifiedPages[$pageIndex];
176
177                match ($operation['op']) {
178                    'rotate' => $this->applyRotate($dict, $operation['args']['degrees']),
179                    'scale' => $this->applyScale($dict, $operation['args']['factor']),
180                    'scaleTo' => $this->applyScaleTo($dict, $operation['args']['width'], $operation['args']['height']),
181                    'setBox' => $this->applySetBox(
182                        $dict,
183                        $operation['args']['box'],
184                        $operation['args']['x'],
185                        $operation['args']['y'],
186                        $operation['args']['w'],
187                        $operation['args']['h'],
188                    ),
189                    default => throw new \LogicException("Unknown operation: {$operation['op']}"),
190                };
191            }
192        }
193
194        if (empty($modifiedPages)) {
195            return $this->originalBytes;
196        }
197
198        // Create incremental writer and add modified page objects
199        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
200
201        foreach ($modifiedPages as $pageIndex => $dict) {
202            $objNum = $pageEntries[$pageIndex]['objectNumber'];
203            $obj = new class ($dict) extends PdfObject {
204                public function __construct(private readonly PdfDictionary $dict) {}
205                public function toPdf(): string
206                {
207                    return $this->dict->toPdf();
208                }
209            };
210            $obj->objectNumber = $objNum;
211            $obj->generationNumber = 0;
212            $writer->addModifiedObject($obj);
213        }
214
215        $result = $writer->generate();
216        $this->lastVersionWarnings = $writer->getVersionWarnings();
217        return $result;
218    }
219
220    // -----------------------------------------------------------------------
221    // Escape hatches
222    // -----------------------------------------------------------------------
223
224    /** @return list<string> */
225    public function getVersionWarnings(): array
226    {
227        return $this->lastVersionWarnings;
228    }
229
230    public function getReader(): PdfReader
231    {
232        return $this->reader;
233    }
234
235    public function getPageCount(): int
236    {
237        return $this->reader->getPageCount();
238    }
239
240    // -----------------------------------------------------------------------
241    // Internal — page tree traversal
242    // -----------------------------------------------------------------------
243
244    /**
245     * Traverse the page tree and return each leaf page with its object number.
246     *
247     * @return list<array{objectNumber: int, dict: PdfDictionary}>
248     */
249    private function resolvePageEntries(): array
250    {
251        $catalog = $this->reader->getCatalog();
252        $pagesRef = $catalog->get('Pages');
253        if (!$pagesRef instanceof PdfReference) {
254            return [];
255        }
256        $pagesDict = $this->reader->resolveReference($pagesRef);
257        if (!$pagesDict instanceof PdfDictionary) {
258            return [];
259        }
260
261        $result = [];
262        $this->collectPageEntries($pagesDict, $result);
263        return $result;
264    }
265
266    /**
267     * Recursively collect page entries from a Pages tree node.
268     *
269     * @param list<array{objectNumber: int, dict: PdfDictionary}> $result
270     */
271    private function collectPageEntries(PdfDictionary $node, array &$result): void
272    {
273        $kids = $node->get('Kids');
274        if (!$kids instanceof PdfArray) {
275            return;
276        }
277        foreach ($kids->items as $kidRef) {
278            if (!$kidRef instanceof PdfReference) {
279                continue;
280            }
281            $kid = $this->reader->resolveReference($kidRef);
282            if (!$kid instanceof PdfDictionary) {
283                continue;
284            }
285            $type = $kid->get('Type');
286            if ($type instanceof PdfName && $type->value === 'Pages') {
287                $this->collectPageEntries($kid, $result);
288            } else {
289                $result[] = [
290                    'objectNumber' => $kidRef->objectNumber,
291                    'dict' => $kid,
292                ];
293            }
294        }
295    }
296
297    // -----------------------------------------------------------------------
298    // Internal — operations
299    // -----------------------------------------------------------------------
300
301    private function applyRotate(PdfDictionary $dict, int $degrees): void
302    {
303        $existing = $dict->get('Rotate');
304        $current = $existing instanceof PdfNumber ? ((int) $existing->toPdf()) : 0;
305        $new = (($current + $degrees) % 360 + 360) % 360;
306        $dict->set('Rotate', new PdfNumber($new));
307    }
308
309    private function applyScale(PdfDictionary $dict, float $factor): void
310    {
311        foreach (['MediaBox', 'CropBox', 'TrimBox', 'BleedBox', 'ArtBox'] as $boxName) {
312            $box = $dict->get($boxName);
313            if ($box instanceof PdfArray && count($box->items) === 4) {
314                $dict->set($boxName, $this->scaleBox($box, $factor));
315            }
316        }
317    }
318
319    private function applyScaleTo(PdfDictionary $dict, float $targetWidth, float $targetHeight): void
320    {
321        $mediaBox = $dict->get('MediaBox');
322        if (!$mediaBox instanceof PdfArray || count($mediaBox->items) !== 4) {
323            return;
324        }
325
326        $currentWidth = $this->numVal($mediaBox->items[2]) - $this->numVal($mediaBox->items[0]);
327        $currentHeight = $this->numVal($mediaBox->items[3]) - $this->numVal($mediaBox->items[1]);
328
329        if ($currentWidth <= 0 || $currentHeight <= 0) {
330            return;
331        }
332
333        $factor = min($targetWidth / $currentWidth, $targetHeight / $currentHeight);
334        $this->applyScale($dict, $factor);
335    }
336
337    private function applySetBox(PdfDictionary $dict, string $boxName, float $x, float $y, float $w, float $h): void
338    {
339        $dict->set($boxName, new PdfArray([
340            new PdfNumber($x),
341            new PdfNumber($y),
342            new PdfNumber($x + $w),
343            new PdfNumber($y + $h),
344        ]));
345    }
346
347    // -----------------------------------------------------------------------
348    // Internal — helpers
349    // -----------------------------------------------------------------------
350
351    private function scaleBox(PdfArray $box, float $factor): PdfArray
352    {
353        return new PdfArray([
354            new PdfNumber($this->numVal($box->items[0]) * $factor),
355            new PdfNumber($this->numVal($box->items[1]) * $factor),
356            new PdfNumber($this->numVal($box->items[2]) * $factor),
357            new PdfNumber($this->numVal($box->items[3]) * $factor),
358        ]);
359    }
360
361    private function numVal(mixed $item): float
362    {
363        if ($item instanceof PdfNumber) {
364            return (float) $item->value;
365        }
366        return (float) (string) $item;
367    }
368
369    private function cloneDict(PdfDictionary $dict): PdfDictionary
370    {
371        $clone = new PdfDictionary();
372        foreach ($dict->entries as $key => $value) {
373            $clone->set($key, $value);
374        }
375        return $clone;
376    }
377}