Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.91% covered (success)
98.91%
271 / 274
92.59% covered (success)
92.59%
25 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Document
98.91% covered (success)
98.91%
271 / 274
92.59% covered (success)
92.59%
25 / 27
142
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDecodeBudget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isEncrypted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasEncryptedStrings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTrailer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getObjectNumbers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRoot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getObject
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 resolve
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getOrResolveFontInfo
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 parseAt
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getFromObjectStream
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 load
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 recoverEncryptionTrailerKeys
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 initEncryption
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
16
 resolveEncryptDictRaw
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
11
 cryptFilterMethod
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
11
 decryptStream
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 loadViaXref
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 mergeXrefSection
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isClassicXref
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadViaRepair
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 expandObjectStreamsFromRepair
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
11.08
 findCatalogReference
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 isUsable
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 looksLikeObjectAt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Extract;
16
17use Pop\Pdf\Build\Security\Exception as SecurityException;
18use Pop\Pdf\Build\Security\ObjectCipher;
19use Pop\Pdf\Build\Security\StandardSecurityHandler;
20use Pop\Pdf\Extract\Filter\Budget;
21
22/**
23 * Pdf extract document class
24 *
25 * @category   Pop
26 * @package    Pop\Pdf
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    6.2.0
31 */
32class Document
33{
34
35    /**
36     * Maximum bytes retained by the font info cache
37     */
38    protected const MAX_FONT_INFO_CACHE_BYTES = 67108864; // 64MB
39
40    /**
41     * Maximum total bytes this document may decode across every stream over its lifetime.
42     *
43     * The Budget only throws once a charge pushes the running total past this ceiling - by
44     * then, the decode that tipped it over (up to Flate's own 64MB per-call cap) has already
45     * completed and its output is retained in memory alongside every prior charged chunk, so
46     * real peak usage runs measurably higher than this number, on top of the PHP process's own
47     * baseline footprint. This must stay comfortably below common PHP memory_limit floors (128M
48     * on conservative/shared hosting) or the process hits a hard, uncatchable OOM fatal before
49     * the Budget ever gets to throw its catchable Exception - which is exactly what this
50     * constant exists to prevent.
51     */
52    protected const MAX_TOTAL_DECODED_BYTES = 67108864; // 64MB
53
54    /**
55     * Raw PDF data
56     * @var string
57     */
58    protected string $data;
59
60    /**
61     * Object number to xref location map
62     * @var array
63     */
64    protected array $offsets = [];
65
66    /**
67     * Trailer dictionary
68     * @var array
69     */
70    protected array $trailer = [];
71
72    /**
73     * Resolved object cache
74     * @var array
75     */
76    protected array $cache = [];
77
78    /**
79     * Expanded object stream cache, keyed by stream object number
80     * @var array
81     */
82    protected array $objectStreamCache = [];
83
84    /**
85     * Positional view of each expanded object stream (index within the stream
86     * to object value), keyed by stream object number. Built once alongside
87     * objectStreamCache so index lookups don't re-run array_values() on every call.
88     * @var array
89     */
90    protected array $objectStreamIndexCache = [];
91
92    /**
93     * Object numbers currently being resolved, used to detect circular references
94     * @var array
95     */
96    protected array $resolving = [];
97
98    /**
99     * Object stream numbers currently being expanded, used to detect circular references
100     * @var array
101     */
102    protected array $expandingStreams = [];
103
104    /**
105     * Resolved FontInfo cache, keyed by a caller-supplied cache key
106     * @var array
107     */
108    protected array $fontInfoCache = [];
109
110    /**
111     * Running total of bytes retained in the font info cache
112     * @var int
113     */
114    protected int $fontInfoCacheBytes = 0;
115
116    /**
117     * Most-recently-used font info cache key, used once the byte budget is exhausted
118     * @var ?string
119     */
120    protected ?string $fontInfoMruKey = null;
121
122    /**
123     * Most-recently-used font info cache value, used once the byte budget is exhausted
124     * @var mixed
125     */
126    protected mixed $fontInfoMruValue = null;
127
128    /**
129     * Total decoded-byte budget shared by every stream this document decodes
130     * @var Budget
131     */
132    protected Budget $decodeBudget;
133
134    /**
135     * Password used to open this document, if it is encrypted
136     * @var ?string
137     */
138    protected ?string $password = null;
139
140    /**
141     * File Encryption Key recovered from the /Encrypt dictionary, if this
142     * document is encrypted and the password checked out
143     * @var ?string
144     */
145    protected ?string $fileKey = null;
146
147    /**
148     * Stream content encryption algorithm ('AES128' or 'AES256'), or null when
149     * this document either isn't encrypted or declares /StmF /Identity (i.e.
150     * its streams aren't encrypted even though the document is)
151     * @var ?string
152     */
153    protected ?string $encryptionAlgorithm = null;
154
155    /**
156     * Whether this document's STRINGS are encrypted (i.e. it declares a
157     * non-/Identity /StrF). Nothing in Extract\* decrypts strings, so when
158     * this is true every string value this document hands back - /Info
159     * metadata above all - is raw ciphertext rather than readable text.
160     * @var bool
161     */
162    protected bool $encryptedStrings = false;
163
164    /**
165     * Constructor
166     *
167     * Instantiate a document from raw PDF data.
168     *
169     * @param string  $data
170     * @param ?string $password Required if the PDF is encrypted; either the
171     *                          user or the owner password will open it
172     */
173    public function __construct(string $data, ?string $password = null)
174    {
175        $this->decodeBudget = new Budget(self::MAX_TOTAL_DECODED_BYTES);
176        $this->data         = $data;
177        $this->password     = $password;
178        $this->load();
179    }
180
181    /**
182     * Get this document's shared decode budget
183     *
184     * @return Budget
185     */
186    public function getDecodeBudget(): Budget
187    {
188        return $this->decodeBudget;
189    }
190
191    /**
192     * Create a document from a PDF file
193     *
194     * @param  string  $file
195     * @param  ?string $password Required if the PDF is encrypted
196     * @throws Exception
197     * @return Document
198     */
199    public static function fromFile(string $file, ?string $password = null): Document
200    {
201        if (!file_exists($file)) {
202            throw new Exception('Error: That PDF file does not exist.');
203        }
204
205        // Suppressed: a read failure here (e.g. permission denied) is
206        // already converted into a typed Exception below, so the native
207        // PHP warning would just be noise on an already-handled failure.
208        $data = @file_get_contents($file);
209
210        if ($data === false) {
211            throw new Exception('Error: Could not read that PDF file.');
212        }
213
214        return new self($data, $password);
215    }
216
217    /**
218     * Determine if this document was encrypted (and therefore opened with a password)
219     *
220     * @return bool
221     */
222    public function isEncrypted(): bool
223    {
224        return ($this->fileKey !== null);
225    }
226
227    /**
228     * Determine if this document's STRING values are encrypted.
229     *
230     * Extract\* has no string-decryption layer at all (see the read-path
231     * plan's disclosed non-goal), so when this is true every string this
232     * document returns is raw ciphertext. A caller that would otherwise
233     * present those bytes as text - Build\Parser copying /Info into
234     * Document\Metadata, most notably - should skip them instead.
235     *
236     * @return bool
237     */
238    public function hasEncryptedStrings(): bool
239    {
240        return $this->encryptedStrings;
241    }
242
243    /**
244     * Get the trailer dictionary
245     *
246     * @return array
247     */
248    public function getTrailer(): array
249    {
250        return $this->trailer;
251    }
252
253    /**
254     * Get every object number this document's xref exposes
255     *
256     * @return array
257     */
258    public function getObjectNumbers(): array
259    {
260        return array_keys($this->offsets);
261    }
262
263    /**
264     * Get the resolved document catalog (Root)
265     *
266     * @throws Exception
267     * @return array
268     */
269    public function getRoot(): array
270    {
271        $root = $this->resolve($this->trailer['Root'] ?? null);
272
273        if (!is_array($root)) {
274            throw new Exception('Error: Could not resolve the PDF document catalog (Root).');
275        }
276
277        return $root;
278    }
279
280    /**
281     * Get an object by object number, from the cache or by parsing/expanding it
282     *
283     * @param  int $objNum
284     * @return mixed
285     */
286    public function getObject(int $objNum): mixed
287    {
288        if (array_key_exists($objNum, $this->cache)) {
289            return $this->cache[$objNum];
290        }
291
292        if (!isset($this->offsets[$objNum])) {
293            return null;
294        }
295
296        $location = $this->offsets[$objNum];
297
298        if (isset($location['inStream'])) {
299            // Objects packed inside an object stream are NEVER separately
300            // encrypted (ISO 32000-1 7.5.7): the container /ObjStm's own
301            // stream was already decrypted as a whole when it passed through
302            // this same method, so its contents are plaintext by the time
303            // they get here. Decrypting again would corrupt them.
304            $value = $this->getFromObjectStream($location['inStream'], $location['index']);
305        } else {
306            $generation = 0;
307            $value      = $this->parseAt($location['offset'], $generation);
308
309            if ($value instanceof Value\Stream) {
310                $value = $this->decryptStream($objNum, $generation, $value);
311            }
312        }
313
314        $this->cache[$objNum] = $value;
315
316        return $value;
317    }
318
319    /**
320     * Resolve a value, following indirect references until a direct value is reached
321     *
322     * @param  mixed $value
323     * @throws Exception
324     * @return mixed
325     */
326    public function resolve(mixed $value): mixed
327    {
328        if ($value instanceof Value\Reference) {
329            $objNum = $value->objNum;
330
331            if (isset($this->resolving[$objNum])) {
332                throw new Exception("Error: Circular reference detected while resolving object {$objNum}.");
333            }
334
335            $this->resolving[$objNum] = true;
336
337            try {
338                return $this->resolve($this->getObject($objNum));
339            } finally {
340                unset($this->resolving[$objNum]);
341            }
342        }
343
344        return $value;
345    }
346
347    /**
348     * Get a cached FontInfo result for a key, or compute and (budget permitting) cache it
349     *
350     * @param  string   $key
351     * @param  callable $factory
352     * @return mixed
353     */
354    public function getOrResolveFontInfo(string $key, callable $factory): mixed
355    {
356        if (array_key_exists($key, $this->fontInfoCache)) {
357            return $this->fontInfoCache[$key];
358        }
359
360        if ($this->fontInfoMruKey === $key) {
361            return $this->fontInfoMruValue;
362        }
363
364        $result = $factory();
365
366        // Bound how much decoded font data (e.g. decompressed embedded
367        // TrueType programs) this cache may retain for the document's whole
368        // lifetime - a PDF with more/larger distinct fonts than the budget
369        // still works correctly, it just stops benefiting from caching once
370        // exhausted, rather than retaining every font's data forever (a
371        // 206KB PDF with 20 fonts each decompressing to 10MB was confirmed
372        // to otherwise inflate peak memory ~8x during Phase D's final
373        // review).
374        $size = strlen(serialize($result));
375
376        if (($this->fontInfoCacheBytes + $size) <= self::MAX_FONT_INFO_CACHE_BYTES) {
377            $this->fontInfoCache[$key] = $result;
378            $this->fontInfoCacheBytes += $size;
379        } else {
380            // Even once the budget is exhausted, always keep the single
381            // MOST RECENTLY resolved result cached - consecutive runs
382            // overwhelmingly share the SAME font (Interpreter only
383            // re-resolves on Tf), so this collapses what would otherwise be
384            // a per-run recompute back down to a per-font-activation one,
385            // without giving up the overall memory ceiling. Without this, a
386            // single font whose resolved size alone exceeds the budget
387            // (e.g. one 70MB embedded TrueType program) would be
388            // re-decompressed on every single run referencing it - a worse
389            // CPU DoS than the memory regression this cache was added to
390            // fix (confirmed during Phase D's final re-review).
391            $this->fontInfoMruKey   = $key;
392            $this->fontInfoMruValue = $result;
393        }
394
395        return $result;
396    }
397
398    /**
399     * Parse an object directly at a byte offset
400     *
401     * @param  int  $offset
402     * @param  ?int $generation Set to the object's generation number, which
403     *                          revision 4 (AES-128) decryption needs
404     * @throws Exception
405     * @return mixed
406     */
407    protected function parseAt(int $offset, ?int &$generation = null): mixed
408    {
409        $tokenizer = new Tokenizer($this->data, $offset);
410        $tokenizer->next(); // object number
411        $genToken = $tokenizer->next();
412        $objToken = $tokenizer->next();
413
414        $generation = (($genToken['type'] === 'number') && is_int($genToken['value'])) ? $genToken['value'] : 0;
415
416        if (($objToken['type'] !== 'keyword') || ($objToken['value'] !== 'obj')) {
417            throw new Exception('Error: Expected obj keyword while resolving a PDF object.');
418        }
419
420        $parser = new ObjectParser($tokenizer);
421
422        return $parser->parseValue();
423    }
424
425    /**
426     * Get an object at an index within an object stream, expanding and caching the stream if needed
427     *
428     * @param  int $streamObjNum
429     * @param  int $index
430     * @throws Exception
431     * @return mixed
432     */
433    protected function getFromObjectStream(int $streamObjNum, int $index): mixed
434    {
435        if (!isset($this->objectStreamCache[$streamObjNum])) {
436            if (isset($this->expandingStreams[$streamObjNum])) {
437                throw new Exception(
438                    "Error: Circular object stream reference detected while expanding object {$streamObjNum}."
439                );
440            }
441
442            $this->expandingStreams[$streamObjNum] = true;
443
444            try {
445                $streamObj = $this->getObject($streamObjNum);
446
447                if (!($streamObj instanceof Value\Stream)) {
448                    throw new Exception("Error: Object {$streamObjNum} is not a valid object stream.");
449                }
450
451                $this->objectStreamCache[$streamObjNum]      = ObjectStream::expand($streamObj, $this->decodeBudget);
452                $this->objectStreamIndexCache[$streamObjNum] = array_values($this->objectStreamCache[$streamObjNum]);
453            } finally {
454                unset($this->expandingStreams[$streamObjNum]);
455            }
456        }
457
458        return $this->objectStreamIndexCache[$streamObjNum][$index] ?? null;
459    }
460
461    /**
462     * Load offsets/trailer via xref, falling back to brute-force repair if unusable
463     *
464     * @throws Exception
465     * @return void
466     */
467    protected function load(): void
468    {
469        try {
470            [$offsets, $trailer] = $this->loadViaXref();
471        } catch (\Throwable $e) {
472            // Any lower-layer failure - not just this namespace's own
473            // Extract\Exception, but raw PHP errors from malformed data
474            // (e.g. a TypeError from a corrupt xref stream's /W array) -
475            // must trigger the repair fallback rather than leak out.
476            $offsets = [];
477            $trailer = [];
478        }
479
480        $repairOffsets = null;
481
482        if (!$this->isUsable($offsets, $trailer)) {
483            [$offsets, $trailer] = $this->loadViaRepair();
484            $repairOffsets       = $offsets;
485
486            // A repair scan can only recover a trailer from a literal
487            // "trailer" keyword, which a cross-reference-STREAM file doesn't
488            // have - its /Encrypt and /ID live in the xref stream's own
489            // dictionary instead. Without them an encrypted document would
490            // load "successfully" and hand back raw ciphertext, silently,
491            // rather than either decrypting it or saying it couldn't.
492            if (!isset($trailer['Encrypt'])) {
493                $trailer = $this->recoverEncryptionTrailerKeys($offsets) + $trailer;
494            }
495
496            // Backstop for every repair shape the recovery above cannot cover -
497            // a CLASSIC-xref document damaged badly enough to lose both its
498            // literal "trailer" keyword and its "startxref" has no surviving
499            // /Encrypt anywhere the repair scan looks, even though the raw
500            // bytes plainly still carry one. Proceeding would report the
501            // document as unencrypted and hand back undecrypted ciphertext -
502            // in practice, silently empty page content even when the caller
503            // supplied the CORRECT password, indistinguishable from a
504            // legitimately text-free PDF. Saying so is strictly better than
505            // that, so this refuses rather than guesses.
506            if (!isset($trailer['Encrypt']) && str_contains($this->data, '/Encrypt')) {
507                throw new Exception(
508                    'Error: This PDF appears to be encrypted (its raw data contains an /Encrypt entry), but its ' .
509                    'cross-reference data is damaged badly enough that the encryption dictionary could not be ' .
510                    'located, so its contents cannot be decrypted.'
511                );
512            }
513        }
514
515        // Encryption has to be set up before anything reads an object's stream
516        // body - including the repair path's object-stream pre-expansion below,
517        // whose /ObjStm containers are themselves encrypted.
518        $this->initEncryption($offsets, $trailer);
519
520        $this->offsets = $offsets;
521        $this->trailer = $trailer;
522
523        if ($repairOffsets !== null) {
524            $this->cache = $this->expandObjectStreamsFromRepair($repairOffsets) + $this->cache;
525        }
526    }
527
528    /**
529     * Recover /Encrypt (and the /ID revision 4 needs alongside it) from a
530     * cross-reference stream's dictionary, for a repaired document whose
531     * trailer the repair scan couldn't find
532     *
533     * Deliberately narrow: it returns nothing at all unless some xref stream
534     * actually declares /Encrypt, so it cannot change how any unencrypted
535     * document is repaired. The LAST such stream in the file wins, matching
536     * how an incremental update's most recent section supersedes earlier ones.
537     *
538     * @param  array $offsets
539     * @return array
540     */
541    protected function recoverEncryptionTrailerKeys(array $offsets): array
542    {
543        $recovered = [];
544        $bestAt    = -1;
545
546        foreach ($offsets as $location) {
547            if (!isset($location['offset']) || ($location['offset'] <= $bestAt)) {
548                continue;
549            }
550
551            try {
552                $value = $this->parseAt($location['offset']);
553            } catch (\Throwable $e) {
554                continue;
555            }
556
557            if (!($value instanceof Value\Stream)) {
558                continue;
559            }
560
561            $type = $value->dict['Type'] ?? null;
562
563            if (!($type instanceof Value\Name) || ($type->name !== 'XRef') || !isset($value->dict['Encrypt'])) {
564                continue;
565            }
566
567            $recovered = ['Encrypt' => $value->dict['Encrypt']];
568            $bestAt    = $location['offset'];
569
570            if (isset($value->dict['ID'])) {
571                $recovered['ID'] = $value->dict['ID'];
572            }
573        }
574
575        return $recovered;
576    }
577
578    /**
579     * Verify the supplied password against the /Encrypt dictionary and recover
580     * the File Encryption Key, if this document is encrypted
581     *
582     * @param  array $offsets
583     * @param  array $trailer
584     * @throws Exception
585     * @return void
586     */
587    protected function initEncryption(array $offsets, array $trailer): void
588    {
589        if (!isset($trailer['Encrypt'])) {
590            return;
591        }
592
593        if ($this->password === null) {
594            throw new Exception('Error: This PDF is encrypted; a password is required to open it.');
595        }
596
597        $encryptDict = $this->resolveEncryptDictRaw($offsets, $trailer['Encrypt']);
598        $revision    = (int) ($encryptDict['R'] ?? 0);
599        $method      = (string) ($encryptDict['CFM'] ?? '');
600
601        // /Identity means the document is encrypted but its STREAMS are not,
602        // which is legal and still requires the password to be verified - it
603        // just leaves nothing for decryptStream() to do.
604        if (($revision === 6) && (($method === 'AESV3') || ($method === 'Identity'))) {
605            $streamAlgorithm = ($method === 'AESV3') ? 'AES256' : null;
606        } elseif (($revision === 4) && (($method === 'AESV2') || ($method === 'Identity'))) {
607            $streamAlgorithm = ($method === 'AESV2') ? 'AES128' : null;
608        } else {
609            throw new Exception(
610                'Error: This PDF uses an unsupported encryption configuration (revision ' . $revision .
611                ", stream method '" . (($method === '') ? 'unknown' : $method) .
612                "'); only AES-128 (/AESV2, revision 4) and AES-256 (/AESV3, revision 6) are supported."
613            );
614        }
615
616        // Guarded with is_array() rather than just ?? - a malformed file whose
617        // /ID is an indirect reference (an object, not an array) would
618        // otherwise raise a fatal "cannot use object as array" Error here
619        // instead of a catchable Exception.
620        $id     = is_array($trailer['ID'] ?? null) ? ($trailer['ID'][0] ?? null) : null;
621        $fileId = is_string($id) ? $id : '';
622
623        // Anything other than an explicit /Identity leaves this document's
624        // strings as ciphertext, since nothing in Extract\* decrypts strings.
625        $this->encryptedStrings = ((string)($encryptDict['StrCFM'] ?? '') !== 'Identity');
626
627        try {
628            $this->fileKey = ($revision === 6)
629                ? StandardSecurityHandler::openRevision6($encryptDict, $this->password)
630                : StandardSecurityHandler::openRevision4($encryptDict, $fileId, $this->password);
631        } catch (SecurityException $e) {
632            // Rethrown in this namespace's own type so every caller of
633            // Extract\Document only ever has to catch Extract\Exception, but
634            // with the underlying message preserved - "the password is wrong"
635            // and "the /Encrypt dictionary is malformed" are different
636            // problems and flattening them together points at the wrong one.
637            throw new Exception($e->getMessage(), $e->getCode(), $e);
638        }
639
640        $this->encryptionAlgorithm = $streamAlgorithm;
641    }
642
643    /**
644     * Resolve the /Encrypt dictionary down to the raw scalar values
645     * StandardSecurityHandler expects
646     *
647     * The Tokenizer already decodes both <hex> and (literal) PDF strings into
648     * raw bytes (see Tokenizer::readAngleOpen()/readLiteralString()), so /O,
649     * /U, /OE and /UE arrive here in exactly the shape the security handler
650     * wants - no extra hex decoding is needed. The dictionary is parsed
651     * directly at its byte offset rather than through getObject()/resolve()
652     * so it never touches the object cache and can never itself be treated as
653     * something to decrypt.
654     *
655     * @param  array $offsets
656     * @param  mixed $encryptRef
657     * @throws Exception
658     * @return array
659     */
660    protected function resolveEncryptDictRaw(array $offsets, mixed $encryptRef): array
661    {
662        $dict = null;
663
664        if (is_array($encryptRef)) {
665            // The spec requires /Encrypt to be indirect, but a direct
666            // dictionary is trivially readable, so accept it too.
667            $dict = $encryptRef;
668        } elseif ($encryptRef instanceof Value\Reference) {
669            $location = $offsets[$encryptRef->objNum] ?? null;
670
671            // An /Encrypt dictionary is never inside an object stream - it has
672            // to be readable before anything can be decrypted at all.
673            if (!isset($location['offset'])) {
674                throw new Exception("Error: Could not locate this PDF's encryption dictionary.");
675            }
676
677            $dict = $this->parseAt($location['offset']);
678        }
679
680        if (!is_array($dict)) {
681            throw new Exception("Error: This PDF's encryption dictionary is missing or malformed.");
682        }
683
684        $raw = [];
685
686        foreach (['O', 'U', 'OE', 'UE'] as $key) {
687            if (isset($dict[$key]) && is_string($dict[$key])) {
688                $raw[$key] = $dict[$key];
689            }
690        }
691
692        foreach (['R', 'V', 'P', 'Length'] as $key) {
693            if (isset($dict[$key]) && is_int($dict[$key])) {
694                $raw[$key] = $dict[$key];
695            }
696        }
697
698        // ISO 32000-1 Table 21: /EncryptMetadata defaults to true when absent,
699        // and only an explicit `false` turns it off. It is load-bearing for
700        // revision 4 key derivation (Algorithm 2 step (f)), not just metadata
701        // handling, so it has to be read here and threaded through.
702        $raw['EncryptMetadata'] = !(($dict['EncryptMetadata'] ?? true) === false);
703
704        $raw['CFM']    = $this->cryptFilterMethod($dict, 'StmF');
705        $raw['StrCFM'] = $this->cryptFilterMethod($dict, 'StrF');
706
707        return $raw;
708    }
709
710    /**
711     * Determine the crypt filter method (/CFM) that applies to one category of
712     * this document's content, per the named filter entry (/StmF for streams,
713     * /StrF for strings) and the /CF crypt filter map
714     *
715     * @param  array  $dict
716     * @param  string $filterKey 'StmF' or 'StrF'
717     * @return string 'AESV2', 'AESV3', 'Identity', 'V2' (RC4), or '' if undeterminable
718     */
719    protected function cryptFilterMethod(array $dict, string $filterKey): string
720    {
721        $v = (isset($dict['V']) && is_int($dict['V'])) ? $dict['V'] : 0;
722
723        // Crypt filters only exist from /V 4 onward - /V 1 and /V 2 are always
724        // RC4 over everything, with no /CF map to consult.
725        if ($v < 4) {
726            return ($v === 0) ? '' : 'V2';
727        }
728
729        $filter = $dict[$filterKey] ?? null;
730        $name   = ($filter instanceof Value\Name) ? $filter->name : 'Identity'; // ISO 32000-1 Table 20 default
731
732        if ($name === 'Identity') {
733            return 'Identity';
734        }
735
736        $cf = $dict['CF'] ?? null;
737
738        if (!is_array($cf) || !isset($cf[$name]) || !is_array($cf[$name])) {
739            return '';
740        }
741
742        $cfm = $cf[$name]['CFM'] ?? null;
743
744        return ($cfm instanceof Value\Name) ? $cfm->name : '';
745    }
746
747    /**
748     * Decrypt a top-level object's stream body, if this document is encrypted
749     *
750     * @param  int          $objNum
751     * @param  int          $generation
752     * @param  Value\Stream $value
753     * @throws Exception
754     * @return Value\Stream
755     */
756    protected function decryptStream(int $objNum, int $generation, Value\Stream $value): Value\Stream
757    {
758        if (($this->fileKey === null) || ($this->encryptionAlgorithm === null)) {
759            return $value;
760        }
761
762        // A cross-reference stream is never encrypted (ISO 32000-1 7.5.8.2) -
763        // it has to be readable before the /Encrypt dictionary it points at
764        // can even be found. An /ObjStm, by contrast, IS encrypted, once, as a
765        // whole; that's exactly what makes its contents plaintext afterward.
766        $type = $value->dict['Type'] ?? null;
767
768        if (($type instanceof Value\Name) && ($type->name === 'XRef')) {
769            return $value;
770        }
771
772        try {
773            $decrypted = ($this->encryptionAlgorithm === 'AES256')
774                ? ObjectCipher::decryptAes256($this->fileKey, $value->raw)
775                : ObjectCipher::decryptAes128($this->fileKey, $objNum, $generation, $value->raw);
776        } catch (SecurityException $e) {
777            throw new Exception(
778                "Error: Could not decrypt the stream of object {$objNum}" . $e->getMessage(), $e->getCode(), $e
779            );
780        }
781
782        return new Value\Stream($value->dict, $decrypted);
783    }
784
785    /**
786     * Load offsets/trailer by following the startxref chain (classic tables and/or xref streams)
787     *
788     * @throws Exception
789     * @return array
790     */
791    protected function loadViaXref(): array
792    {
793        $startXrefPos = strrpos($this->data, 'startxref');
794        if ($startXrefPos === false) {
795            throw new Exception('Error: No startxref marker found.');
796        }
797
798        $tokenizer = new Tokenizer($this->data, $startXrefPos + strlen('startxref'));
799        $posToken  = $tokenizer->next();
800
801        if ($posToken['type'] !== 'number') {
802            throw new Exception('Error: Malformed startxref value.');
803        }
804
805        $offsets = [];
806        $trailer = [];
807        $visited = [];
808        $xrefPos = (int) $posToken['value'];
809
810        while (($xrefPos !== null) && (!isset($visited[$xrefPos]))) {
811            $visited[$xrefPos] = true;
812
813            $section = $this->isClassicXref($xrefPos)
814                ? Xref\Table::parse($this->data, $xrefPos)
815                : Xref\Stream::parse($this->data, $xrefPos, $this->decodeBudget);
816
817            $this->mergeXrefSection($section, $offsets, $trailer);
818
819            // A hybrid-reference file's classic xref table may point to a
820            // supplemental cross-reference stream (for compressed objects
821            // the classic table can't express) via /XRefStm, alongside a
822            // /Prev continuing the classic chain - both must be merged,
823            // per PDF spec 7.5.8.4, not treated as mutually exclusive.
824            if (isset($section['trailer']['XRefStm'])) {
825                $xrefStmPos = (int) $section['trailer']['XRefStm'];
826                if (!isset($visited[$xrefStmPos])) {
827                    $visited[$xrefStmPos] = true;
828                    $xrefStmSection = Xref\Stream::parse($this->data, $xrefStmPos, $this->decodeBudget);
829                    $this->mergeXrefSection($xrefStmSection, $offsets, $trailer);
830                }
831            }
832
833            $xrefPos = isset($section['trailer']['Prev']) ? (int) $section['trailer']['Prev'] : null;
834        }
835
836        return [$offsets, $trailer];
837    }
838
839    /**
840     * Merge one xref section's offsets/trailer into the accumulated result
841     *
842     * @param  array $section
843     * @param  array $offsets
844     * @param  array $trailer
845     * @return void
846     */
847    protected function mergeXrefSection(array $section, array &$offsets, array &$trailer): void
848    {
849        foreach ($section['offsets'] as $objNum => $location) {
850            if (!isset($offsets[$objNum])) {
851                $offsets[$objNum] = $location;
852            }
853        }
854
855        $trailer = $trailer + $section['trailer'];
856    }
857
858    /**
859     * Determine if the xref section at a position is a classic table (vs. an xref stream)
860     *
861     * @param  int $pos
862     * @return bool
863     */
864    protected function isClassicXref(int $pos): bool
865    {
866        $tokenizer = new Tokenizer($this->data, $pos);
867        $token     = $tokenizer->next();
868
869        return ($token['type'] === 'keyword') && ($token['value'] === 'xref');
870    }
871
872    /**
873     * Load offsets/trailer via brute-force repair scan
874     *
875     * @return array
876     */
877    protected function loadViaRepair(): array
878    {
879        $result  = Repair::scan($this->data);
880        $trailer = $result['trailer'];
881
882        if (!isset($trailer['Root'])) {
883            $trailer['Root'] = $this->findCatalogReference($result['offsets']);
884        }
885
886        return [$result['offsets'], $trailer];
887    }
888
889    /**
890     * A brute-force repair scan only finds objects that appear as literal
891     * "N G obj ... endobj" text - objects packed inside a /Type /ObjStm
892     * container's stream body don't match that pattern at all, since
893     * they're serialized inline within the ObjStm's own stream data rather
894     * than as their own "obj" markers. Without this second pass, any object
895     * that only exists inside an object stream would be silently
896     * unrecoverable after repair fires.
897     *
898     * These recovered objects don't have a byte offset the way normal
899     * repair-scanned objects do - they're already fully parsed values, not
900     * "here's where to find it" locations - so instead of trying to fit
901     * them into the offsets/inStream shape, this returns a ready-to-use
902     * [objNum => value] map that the caller seeds directly into the object
903     * cache (getObject() already checks the cache before consulting
904     * offsets).
905     *
906     * @param  array $offsets
907     * @return array
908     */
909    protected function expandObjectStreamsFromRepair(array $offsets): array
910    {
911        $preResolved = [];
912
913        foreach ($offsets as $objNum => $location) {
914            if (!isset($location['offset'])) {
915                continue;
916            }
917
918            try {
919                $generation = 0;
920                $value      = $this->parseAt($location['offset'], $generation);
921            } catch (\Throwable $e) {
922                continue;
923            }
924
925            if (!($value instanceof Value\Stream)) {
926                continue;
927            }
928
929            $type = $value->dict['Type'] ?? null;
930            if (!($type instanceof Value\Name) || ($type->name !== 'ObjStm')) {
931                continue;
932            }
933
934            try {
935                // An /ObjStm in an encrypted document is itself encrypted, and
936                // this pre-expansion pass bypasses getObject(), so it has to
937                // decrypt for itself before it can expand anything.
938                $value = $this->decryptStream((int) $objNum, $generation, $value);
939            } catch (\Throwable $e) {
940                continue;
941            }
942
943            try {
944                $expanded = ObjectStream::expand($value, $this->decodeBudget);
945            } catch (\Throwable $e) {
946                continue;
947            }
948
949            foreach ($expanded as $objNum => $objValue) {
950                if (!isset($preResolved[$objNum])) {
951                    $preResolved[$objNum] = $objValue;
952                }
953            }
954        }
955
956        return $preResolved;
957    }
958
959    /**
960     * Scan repair-recovered offsets for an object that looks like the document catalog
961     *
962     * @param  array $offsets
963     * @return ?Value\Reference
964     */
965    protected function findCatalogReference(array $offsets): ?Value\Reference
966    {
967        foreach ($offsets as $objNum => $location) {
968            if (!isset($location['offset'])) {
969                continue;
970            }
971
972            try {
973                $tokenizer = new Tokenizer($this->data, $location['offset']);
974                $tokenizer->next();
975                $genToken = $tokenizer->next();
976                $objToken = $tokenizer->next();
977
978                if (($objToken['type'] === 'keyword') && ($objToken['value'] === 'obj')) {
979                    $parser = new ObjectParser($tokenizer);
980                    $value  = $parser->parseValue();
981
982                    if (is_array($value) && isset($value['Type']) &&
983                        ($value['Type'] instanceof Value\Name) && ($value['Type']->name === 'Catalog')) {
984                        return new Value\Reference($objNum, (int) $genToken['value']);
985                    }
986                }
987            } catch (Exception $e) {
988                continue;
989            }
990        }
991
992        return null;
993    }
994
995    /**
996     * Determine if xref-derived offsets/trailer look usable (vs. needing repair)
997     *
998     * @param  array $offsets
999     * @param  array $trailer
1000     * @return bool
1001     */
1002    protected function isUsable(array $offsets, array $trailer): bool
1003    {
1004        if (empty($offsets) || !isset($trailer['Root'])) {
1005            return false;
1006        }
1007
1008        $root = $trailer['Root'];
1009        if (!($root instanceof Value\Reference) || !isset($offsets[$root->objNum])) {
1010            return false;
1011        }
1012
1013        $sample = array_slice($offsets, 0, 5, true);
1014        foreach ($sample as $location) {
1015            if (isset($location['offset']) && !$this->looksLikeObjectAt($location['offset'])) {
1016                return false;
1017            }
1018        }
1019
1020        return true;
1021    }
1022
1023    /**
1024     * Determine if a byte offset looks like the start of an "N G obj" object
1025     *
1026     * @param  int $offset
1027     * @return bool
1028     */
1029    protected function looksLikeObjectAt(int $offset): bool
1030    {
1031        if (($offset < 0) || ($offset >= strlen($this->data))) {
1032            return false;
1033        }
1034
1035        $chunk = substr($this->data, $offset, 32);
1036
1037        return (bool) preg_match('/^\s*\d+\s+\d+\s+obj\b/', $chunk);
1038    }
1039
1040}