Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.43% covered (warning)
73.43%
105 / 143
86.67% covered (warning)
86.67%
13 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfEncrypt
73.43% covered (warning)
73.43%
105 / 143
86.67% covered (warning)
86.67%
13 / 15
83.00
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
 encrypt
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 decrypt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 changePasswords
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setPermissions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isEncrypted
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
87.72% covered (warning)
87.72%
50 / 57
0.00% covered (danger)
0.00%
0 / 1
12.27
 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
 copyStream
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 copyPage
43.64% covered (danger)
43.64%
24 / 55
0.00% covered (danger)
0.00%
0 / 1
76.02
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\Document\Catalog;
8use Phpdftk\Pdf\Core\Document\Page;
9use Phpdftk\Pdf\Core\Document\PageTree;
10use Phpdftk\Filesystem\LocalFilesystem;
11use Phpdftk\Pdf\Core\File\PdfFileWriter;
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\PdfStream;
19use Phpdftk\Pdf\Core\PdfString;
20use Phpdftk\Pdf\Core\Security\PdfEncryptor;
21use Phpdftk\Pdf\Reader\PdfReader;
22use Phpdftk\Pdf\Toolkit\Encryption\EncryptionMethod;
23use Phpdftk\Pdf\Toolkit\Encryption\Permission;
24
25/**
26 * Apply, change, or remove encryption on existing PDFs.
27 *
28 * Usage:
29 *   PdfEncrypt::open('doc.pdf')
30 *       ->encrypt('user', 'owner', EncryptionMethod::Aes256)
31 *       ->save('encrypted.pdf');
32 *
33 *   PdfEncrypt::open('encrypted.pdf', 'password')
34 *       ->decrypt()
35 *       ->save('decrypted.pdf');
36 *
37 * @api
38 */
39final class PdfEncrypt
40{
41    private string $originalBytes;
42
43    /** @var list<string> */
44    private array $lastVersionWarnings = [];
45
46    private ?EncryptionMethod $newMethod = null;
47    private string $newUserPassword = '';
48    private string $newOwnerPassword = '';
49    private int $newPermissions = Permission::ALL;
50    private bool $shouldDecrypt = false;
51
52    private function __construct(
53        private readonly PdfReader $reader,
54        string $originalBytes,
55    ) {
56        $this->originalBytes = $originalBytes;
57    }
58
59    public static function open(string $path, string $password = ''): self
60    {
61        $bytes = LocalFilesystem::readFile($path);
62        return new self(PdfReader::fromString($bytes, $password), $bytes);
63    }
64
65    public static function openString(string $pdfBytes, string $password = ''): self
66    {
67        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
68    }
69
70    // -----------------------------------------------------------------------
71    // Operations
72    // -----------------------------------------------------------------------
73
74    public function encrypt(
75        string $userPassword,
76        string $ownerPassword,
77        EncryptionMethod $method = EncryptionMethod::Aes256,
78        int $permissions = Permission::ALL,
79    ): self {
80        $this->newMethod = $method;
81        $this->newUserPassword = $userPassword;
82        $this->newOwnerPassword = $ownerPassword;
83        $this->newPermissions = $permissions;
84        $this->shouldDecrypt = false;
85        return $this;
86    }
87
88    public function decrypt(): self
89    {
90        $this->shouldDecrypt = true;
91        $this->newMethod = null;
92        return $this;
93    }
94
95    public function changePasswords(string $newUserPassword, string $newOwnerPassword): self
96    {
97        $this->newUserPassword = $newUserPassword;
98        $this->newOwnerPassword = $newOwnerPassword;
99        // Keep existing method if not explicitly set
100        if ($this->newMethod === null && !$this->shouldDecrypt) {
101            $this->newMethod = EncryptionMethod::Aes256;
102        }
103        return $this;
104    }
105
106    public function setPermissions(int $permissions): self
107    {
108        $this->newPermissions = $permissions;
109        return $this;
110    }
111
112    // -----------------------------------------------------------------------
113    // Query
114    // -----------------------------------------------------------------------
115
116    public function isEncrypted(): bool
117    {
118        $trailer = $this->reader->getTrailer();
119        return $trailer->get('Encrypt') !== null;
120    }
121
122    // -----------------------------------------------------------------------
123    // Output
124    // -----------------------------------------------------------------------
125
126    public function save(string $path): void
127    {
128        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
129    }
130
131    public function toBytes(): string
132    {
133        if ($this->newMethod === null && !$this->shouldDecrypt) {
134            return $this->originalBytes;
135        }
136
137        // Full rewrite: read all pages from reader, rebuild PDF
138        $fw = new PdfFileWriter();
139
140        // Generate file ID
141        $fileId = md5(microtime() . random_bytes(16), true);
142
143        // Set up encryption if not decrypting
144        if ($this->newMethod !== null && !$this->shouldDecrypt) {
145            $encryptor = match ($this->newMethod) {
146                EncryptionMethod::Rc440 => PdfEncryptor::rc440(
147                    $this->newUserPassword,
148                    $this->newOwnerPassword,
149                    $fileId,
150                    $this->newPermissions,
151                ),
152                EncryptionMethod::Rc4128 => PdfEncryptor::rc4128(
153                    $this->newUserPassword,
154                    $this->newOwnerPassword,
155                    $fileId,
156                    $this->newPermissions,
157                ),
158                EncryptionMethod::Aes128 => PdfEncryptor::aes128(
159                    $this->newUserPassword,
160                    $this->newOwnerPassword,
161                    $fileId,
162                    $this->newPermissions,
163                ),
164                EncryptionMethod::Aes256 => PdfEncryptor::aes256(
165                    $this->newUserPassword,
166                    $this->newOwnerPassword,
167                    $fileId,
168                    $this->newPermissions,
169                ),
170                default => throw new \RuntimeException('Public-key encryption not supported for re-encryption'),
171            };
172            $fw->setEncryption($encryptor);
173        }
174
175        // Rebuild document structure
176        $catalog = new Catalog();
177        $fw->setCatalog($catalog);
178
179        $pageTree = new PageTree();
180        $fw->register($pageTree);
181        $catalog->pages = new PdfReference($pageTree->objectNumber);
182
183        // Copy Info if present
184        $infoDict = $this->reader->getInfo();
185        if ($infoDict !== null) {
186            $info = new class ($infoDict) extends PdfObject {
187                public function __construct(private readonly PdfDictionary $dict) {}
188                public function toPdf(): string
189                {
190                    return $this->dict->toPdf();
191                }
192            };
193            $fw->register($info);
194            $fw->setInfo($info);
195        }
196
197        // Copy pages
198        $pages = $this->reader->getPages();
199        $pageRefs = [];
200        foreach ($pages as $pageDict) {
201            // Copy page content and resources
202            $page = $this->copyPage($pageDict, $fw);
203            $fw->register($page);
204            $pageRefs[] = new PdfReference($page->objectNumber);
205            $page->parent = new PdfReference($pageTree->objectNumber);
206        }
207
208        $pageTree->kids = $pageRefs;
209        $pageTree->count = count($pageRefs);
210
211        $result = $fw->generate();
212        $this->lastVersionWarnings = $fw->getVersionWarnings();
213        return $result;
214    }
215
216    // -----------------------------------------------------------------------
217    // Escape hatches
218    // -----------------------------------------------------------------------
219
220    /** @return list<string> */
221    public function getVersionWarnings(): array
222    {
223        return $this->lastVersionWarnings;
224    }
225
226    public function getReader(): PdfReader
227    {
228        return $this->reader;
229    }
230
231    public function getPageCount(): int
232    {
233        return $this->reader->getPageCount();
234    }
235
236    // -----------------------------------------------------------------------
237    // Internal
238    // -----------------------------------------------------------------------
239
240    /**
241     * Copy a resolved stream, stripping /Filter and /DecodeParms since the
242     * reader has already decompressed the data. PdfFileWriter will re-compress
243     * if its compressStreams option is enabled.
244     */
245    private function copyStream(PdfStream $source): PdfStream
246    {
247        $dict = clone $source->dictionary;
248        unset($dict->entries['Filter'], $dict->entries['DecodeParms'], $dict->entries['Length']);
249
250        return new class ($dict, $source->data) extends PdfStream {
251            public function __construct(PdfDictionary $dict, string $data)
252            {
253                parent::__construct($dict, $data);
254            }
255        };
256    }
257
258    private function copyPage(PdfDictionary $sourceDict, PdfFileWriter $fw): Page
259    {
260        $page = new Page();
261
262        // Copy MediaBox
263        $mediaBox = $sourceDict->get('MediaBox');
264        if ($mediaBox instanceof PdfArray) {
265            $page->mediaBox = $mediaBox;
266        }
267
268        // Copy Rotate
269        $rotate = $sourceDict->get('Rotate');
270        if ($rotate instanceof PdfNumber) {
271            $page->rotate = (int) $rotate->toPdf();
272        }
273
274        // Copy content streams
275        $contents = $sourceDict->get('Contents');
276        if ($contents instanceof PdfReference) {
277            $stream = $this->reader->resolveReference($contents);
278            if ($stream instanceof PdfStream) {
279                $newCs = $this->copyStream($stream);
280                $fw->register($newCs);
281                $page->contents = [new PdfReference($newCs->objectNumber)];
282            }
283        } elseif ($contents instanceof PdfArray) {
284            $contentRefs = [];
285            foreach ($contents->items as $ref) {
286                if ($ref instanceof PdfReference) {
287                    $stream = $this->reader->resolveReference($ref);
288                    if ($stream instanceof PdfStream) {
289                        $newCs = $this->copyStream($stream);
290                        $fw->register($newCs);
291                        $contentRefs[] = new PdfReference($newCs->objectNumber);
292                    }
293                }
294            }
295            $page->contents = $contentRefs;
296        }
297
298        // Copy resources using PageCopier's resource builder
299        $resources = $sourceDict->get('Resources');
300        $resDict = null;
301        if ($resources instanceof PdfDictionary) {
302            $resDict = $resources;
303        } elseif ($resources instanceof PdfReference) {
304            $resolved = $this->reader->resolveReference($resources);
305            if ($resolved instanceof PdfDictionary) {
306                $resDict = $resolved;
307            }
308        }
309        if ($resDict !== null) {
310            $copier = new \Phpdftk\Pdf\Toolkit\Internal\PageCopier($this->reader, $fw);
311            // Use copyPages on a temp page to leverage the resource copy logic
312            // Instead, build Resources directly
313            $res = new \Phpdftk\Pdf\Core\Content\Resources();
314            $fontDict = $resDict->get('Font');
315            if ($fontDict instanceof PdfDictionary) {
316                foreach (array_keys($fontDict->entries) as $name) {
317                    $ref = $fontDict->entries[$name];
318                    if ($ref instanceof PdfReference) {
319                        $resolved = $this->reader->resolveReference($ref);
320                        if ($resolved instanceof PdfObject) {
321                            $clone = clone $resolved;
322                            $clone->objectNumber = 0;
323                            $fw->register($clone);
324                            $res->font[$name] = new PdfReference($clone->objectNumber);
325                        } elseif ($resolved instanceof PdfDictionary) {
326                            $wrapper = new class ($resolved) extends PdfObject {
327                                public function __construct(private readonly PdfDictionary $d) {}
328                                public function toPdf(): string
329                                {
330                                    return $this->d->toPdf();
331                                }
332                            };
333                            $fw->register($wrapper);
334                            $res->font[$name] = new PdfReference($wrapper->objectNumber);
335                        }
336                    }
337                }
338            }
339            $page->resources = $res;
340        }
341
342        return $page;
343    }
344
345}