Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
165 / 165
100.00% covered (success)
100.00%
20 / 20
CRAP
100.00% covered (success)
100.00%
1 / 1
Document
100.00% covered (success)
100.00%
165 / 165
100.00% covered (success)
100.00%
20 / 20
78
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
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
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 expandObjectStreamsFromRepair
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 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\Extract\Filter\Budget;
18
19/**
20 * Pdf extract document class
21 *
22 * @category   Pop
23 * @package    Pop\Pdf
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.0.0
28 */
29class Document
30{
31
32    /**
33     * Maximum bytes retained by the font info cache
34     */
35    protected const MAX_FONT_INFO_CACHE_BYTES = 67108864; // 64MB
36
37    /**
38     * Maximum total bytes this document may decode across every stream over its lifetime.
39     *
40     * The Budget only throws once a charge pushes the running total past this ceiling - by
41     * then, the decode that tipped it over (up to Flate's own 64MB per-call cap) has already
42     * completed and its output is retained in memory alongside every prior charged chunk, so
43     * real peak usage runs measurably higher than this number, on top of the PHP process's own
44     * baseline footprint. This must stay comfortably below common PHP memory_limit floors (128M
45     * on conservative/shared hosting) or the process hits a hard, uncatchable OOM fatal before
46     * the Budget ever gets to throw its catchable Exception - which is exactly what this
47     * constant exists to prevent.
48     */
49    protected const MAX_TOTAL_DECODED_BYTES = 67108864; // 64MB
50
51    /**
52     * Raw PDF data
53     * @var string
54     */
55    protected string $data;
56
57    /**
58     * Object number to xref location map
59     * @var array
60     */
61    protected array $offsets = [];
62
63    /**
64     * Trailer dictionary
65     * @var array
66     */
67    protected array $trailer = [];
68
69    /**
70     * Resolved object cache
71     * @var array
72     */
73    protected array $cache = [];
74
75    /**
76     * Expanded object stream cache, keyed by stream object number
77     * @var array
78     */
79    protected array $objectStreamCache = [];
80
81    /**
82     * Positional view of each expanded object stream (index within the stream
83     * to object value), keyed by stream object number. Built once alongside
84     * objectStreamCache so index lookups don't re-run array_values() on every call.
85     * @var array
86     */
87    protected array $objectStreamIndexCache = [];
88
89    /**
90     * Object numbers currently being resolved, used to detect circular references
91     * @var array
92     */
93    protected array $resolving = [];
94
95    /**
96     * Object stream numbers currently being expanded, used to detect circular references
97     * @var array
98     */
99    protected array $expandingStreams = [];
100
101    /**
102     * Resolved FontInfo cache, keyed by a caller-supplied cache key
103     * @var array
104     */
105    protected array $fontInfoCache = [];
106
107    /**
108     * Running total of bytes retained in the font info cache
109     * @var int
110     */
111    protected int $fontInfoCacheBytes = 0;
112
113    /**
114     * Most-recently-used font info cache key, used once the byte budget is exhausted
115     * @var ?string
116     */
117    protected ?string $fontInfoMruKey = null;
118
119    /**
120     * Most-recently-used font info cache value, used once the byte budget is exhausted
121     * @var mixed
122     */
123    protected mixed $fontInfoMruValue = null;
124
125    /**
126     * Total decoded-byte budget shared by every stream this document decodes
127     * @var Budget
128     */
129    protected Budget $decodeBudget;
130
131    /**
132     * Constructor
133     *
134     * Instantiate a document from raw PDF data.
135     *
136     * @param string $data
137     */
138    public function __construct(string $data)
139    {
140        $this->decodeBudget = new Budget(self::MAX_TOTAL_DECODED_BYTES);
141        $this->data = $data;
142        $this->load();
143    }
144
145    /**
146     * Get this document's shared decode budget
147     *
148     * @return Budget
149     */
150    public function getDecodeBudget(): Budget
151    {
152        return $this->decodeBudget;
153    }
154
155    /**
156     * Create a document from a PDF file
157     *
158     * @param  string $file
159     * @throws Exception
160     * @return Document
161     */
162    public static function fromFile(string $file): Document
163    {
164        if (!file_exists($file)) {
165            throw new Exception('Error: That PDF file does not exist.');
166        }
167
168        // Suppressed: a read failure here (e.g. permission denied) is
169        // already converted into a typed Exception below, so the native
170        // PHP warning would just be noise on an already-handled failure.
171        $data = @file_get_contents($file);
172
173        if ($data === false) {
174            throw new Exception('Error: Could not read that PDF file.');
175        }
176
177        return new self($data);
178    }
179
180    /**
181     * Get the trailer dictionary
182     *
183     * @return array
184     */
185    public function getTrailer(): array
186    {
187        return $this->trailer;
188    }
189
190    /**
191     * Get every object number this document's xref exposes
192     *
193     * @return array
194     */
195    public function getObjectNumbers(): array
196    {
197        return array_keys($this->offsets);
198    }
199
200    /**
201     * Get the resolved document catalog (Root)
202     *
203     * @throws Exception
204     * @return array
205     */
206    public function getRoot(): array
207    {
208        $root = $this->resolve($this->trailer['Root'] ?? null);
209
210        if (!is_array($root)) {
211            throw new Exception('Error: Could not resolve the PDF document catalog (Root).');
212        }
213
214        return $root;
215    }
216
217    /**
218     * Get an object by object number, from the cache or by parsing/expanding it
219     *
220     * @param  int $objNum
221     * @return mixed
222     */
223    public function getObject(int $objNum): mixed
224    {
225        if (array_key_exists($objNum, $this->cache)) {
226            return $this->cache[$objNum];
227        }
228
229        if (!isset($this->offsets[$objNum])) {
230            return null;
231        }
232
233        $location = $this->offsets[$objNum];
234        $value    = isset($location['inStream'])
235            ? $this->getFromObjectStream($location['inStream'], $location['index'])
236            : $this->parseAt($location['offset']);
237
238        $this->cache[$objNum] = $value;
239
240        return $value;
241    }
242
243    /**
244     * Resolve a value, following indirect references until a direct value is reached
245     *
246     * @param  mixed $value
247     * @throws Exception
248     * @return mixed
249     */
250    public function resolve(mixed $value): mixed
251    {
252        if ($value instanceof Value\Reference) {
253            $objNum = $value->objNum;
254
255            if (isset($this->resolving[$objNum])) {
256                throw new Exception("Error: Circular reference detected while resolving object {$objNum}.");
257            }
258
259            $this->resolving[$objNum] = true;
260
261            try {
262                return $this->resolve($this->getObject($objNum));
263            } finally {
264                unset($this->resolving[$objNum]);
265            }
266        }
267
268        return $value;
269    }
270
271    /**
272     * Get a cached FontInfo result for a key, or compute and (budget permitting) cache it
273     *
274     * @param  string   $key
275     * @param  callable $factory
276     * @return mixed
277     */
278    public function getOrResolveFontInfo(string $key, callable $factory): mixed
279    {
280        if (array_key_exists($key, $this->fontInfoCache)) {
281            return $this->fontInfoCache[$key];
282        }
283
284        if ($this->fontInfoMruKey === $key) {
285            return $this->fontInfoMruValue;
286        }
287
288        $result = $factory();
289
290        // Bound how much decoded font data (e.g. decompressed embedded
291        // TrueType programs) this cache may retain for the document's whole
292        // lifetime - a PDF with more/larger distinct fonts than the budget
293        // still works correctly, it just stops benefiting from caching once
294        // exhausted, rather than retaining every font's data forever (a
295        // 206KB PDF with 20 fonts each decompressing to 10MB was confirmed
296        // to otherwise inflate peak memory ~8x during Phase D's final
297        // review).
298        $size = strlen(serialize($result));
299
300        if (($this->fontInfoCacheBytes + $size) <= self::MAX_FONT_INFO_CACHE_BYTES) {
301            $this->fontInfoCache[$key] = $result;
302            $this->fontInfoCacheBytes += $size;
303        } else {
304            // Even once the budget is exhausted, always keep the single
305            // MOST RECENTLY resolved result cached - consecutive runs
306            // overwhelmingly share the SAME font (Interpreter only
307            // re-resolves on Tf), so this collapses what would otherwise be
308            // a per-run recompute back down to a per-font-activation one,
309            // without giving up the overall memory ceiling. Without this, a
310            // single font whose resolved size alone exceeds the budget
311            // (e.g. one 70MB embedded TrueType program) would be
312            // re-decompressed on every single run referencing it - a worse
313            // CPU DoS than the memory regression this cache was added to
314            // fix (confirmed during Phase D's final re-review).
315            $this->fontInfoMruKey   = $key;
316            $this->fontInfoMruValue = $result;
317        }
318
319        return $result;
320    }
321
322    /**
323     * Parse an object directly at a byte offset
324     *
325     * @param  int $offset
326     * @throws Exception
327     * @return mixed
328     */
329    protected function parseAt(int $offset): mixed
330    {
331        $tokenizer = new Tokenizer($this->data, $offset);
332        $tokenizer->next(); // object number
333        $tokenizer->next(); // generation number
334        $objToken = $tokenizer->next();
335
336        if (($objToken['type'] !== 'keyword') || ($objToken['value'] !== 'obj')) {
337            throw new Exception('Error: Expected obj keyword while resolving a PDF object.');
338        }
339
340        $parser = new ObjectParser($tokenizer);
341
342        return $parser->parseValue();
343    }
344
345    /**
346     * Get an object at an index within an object stream, expanding and caching the stream if needed
347     *
348     * @param  int $streamObjNum
349     * @param  int $index
350     * @throws Exception
351     * @return mixed
352     */
353    protected function getFromObjectStream(int $streamObjNum, int $index): mixed
354    {
355        if (!isset($this->objectStreamCache[$streamObjNum])) {
356            if (isset($this->expandingStreams[$streamObjNum])) {
357                throw new Exception(
358                    "Error: Circular object stream reference detected while expanding object {$streamObjNum}."
359                );
360            }
361
362            $this->expandingStreams[$streamObjNum] = true;
363
364            try {
365                $streamObj = $this->getObject($streamObjNum);
366
367                if (!($streamObj instanceof Value\Stream)) {
368                    throw new Exception("Error: Object {$streamObjNum} is not a valid object stream.");
369                }
370
371                $this->objectStreamCache[$streamObjNum]      = ObjectStream::expand($streamObj, $this->decodeBudget);
372                $this->objectStreamIndexCache[$streamObjNum] = array_values($this->objectStreamCache[$streamObjNum]);
373            } finally {
374                unset($this->expandingStreams[$streamObjNum]);
375            }
376        }
377
378        return $this->objectStreamIndexCache[$streamObjNum][$index] ?? null;
379    }
380
381    /**
382     * Load offsets/trailer via xref, falling back to brute-force repair if unusable
383     *
384     * @throws Exception
385     * @return void
386     */
387    protected function load(): void
388    {
389        try {
390            [$offsets, $trailer] = $this->loadViaXref();
391        } catch (\Throwable $e) {
392            // Any lower-layer failure - not just this namespace's own
393            // Extract\Exception, but raw PHP errors from malformed data
394            // (e.g. a TypeError from a corrupt xref stream's /W array) -
395            // must trigger the repair fallback rather than leak out.
396            $offsets = [];
397            $trailer = [];
398        }
399
400        if (!$this->isUsable($offsets, $trailer)) {
401            [$offsets, $trailer, $preResolved] = $this->loadViaRepair();
402            $this->cache = $preResolved + $this->cache;
403        }
404
405        if (isset($trailer['Encrypt'])) {
406            throw new Exception('Error: Encrypted PDFs are not currently supported for text extraction.');
407        }
408
409        $this->offsets = $offsets;
410        $this->trailer = $trailer;
411    }
412
413    /**
414     * Load offsets/trailer by following the startxref chain (classic tables and/or xref streams)
415     *
416     * @throws Exception
417     * @return array
418     */
419    protected function loadViaXref(): array
420    {
421        $startXrefPos = strrpos($this->data, 'startxref');
422        if ($startXrefPos === false) {
423            throw new Exception('Error: No startxref marker found.');
424        }
425
426        $tokenizer = new Tokenizer($this->data, $startXrefPos + strlen('startxref'));
427        $posToken  = $tokenizer->next();
428
429        if ($posToken['type'] !== 'number') {
430            throw new Exception('Error: Malformed startxref value.');
431        }
432
433        $offsets = [];
434        $trailer = [];
435        $visited = [];
436        $xrefPos = (int) $posToken['value'];
437
438        while (($xrefPos !== null) && (!isset($visited[$xrefPos]))) {
439            $visited[$xrefPos] = true;
440
441            $section = $this->isClassicXref($xrefPos)
442                ? Xref\Table::parse($this->data, $xrefPos)
443                : Xref\Stream::parse($this->data, $xrefPos, $this->decodeBudget);
444
445            $this->mergeXrefSection($section, $offsets, $trailer);
446
447            // A hybrid-reference file's classic xref table may point to a
448            // supplemental cross-reference stream (for compressed objects
449            // the classic table can't express) via /XRefStm, alongside a
450            // /Prev continuing the classic chain - both must be merged,
451            // per PDF spec 7.5.8.4, not treated as mutually exclusive.
452            if (isset($section['trailer']['XRefStm'])) {
453                $xrefStmPos = (int) $section['trailer']['XRefStm'];
454                if (!isset($visited[$xrefStmPos])) {
455                    $visited[$xrefStmPos] = true;
456                    $xrefStmSection = Xref\Stream::parse($this->data, $xrefStmPos, $this->decodeBudget);
457                    $this->mergeXrefSection($xrefStmSection, $offsets, $trailer);
458                }
459            }
460
461            $xrefPos = isset($section['trailer']['Prev']) ? (int) $section['trailer']['Prev'] : null;
462        }
463
464        return [$offsets, $trailer];
465    }
466
467    /**
468     * Merge one xref section's offsets/trailer into the accumulated result
469     *
470     * @param  array $section
471     * @param  array $offsets
472     * @param  array $trailer
473     * @return void
474     */
475    protected function mergeXrefSection(array $section, array &$offsets, array &$trailer): void
476    {
477        foreach ($section['offsets'] as $objNum => $location) {
478            if (!isset($offsets[$objNum])) {
479                $offsets[$objNum] = $location;
480            }
481        }
482
483        $trailer = $trailer + $section['trailer'];
484    }
485
486    /**
487     * Determine if the xref section at a position is a classic table (vs. an xref stream)
488     *
489     * @param  int $pos
490     * @return bool
491     */
492    protected function isClassicXref(int $pos): bool
493    {
494        $tokenizer = new Tokenizer($this->data, $pos);
495        $token     = $tokenizer->next();
496
497        return ($token['type'] === 'keyword') && ($token['value'] === 'xref');
498    }
499
500    /**
501     * Load offsets/trailer via brute-force repair scan
502     *
503     * @return array
504     */
505    protected function loadViaRepair(): array
506    {
507        $result  = Repair::scan($this->data);
508        $trailer = $result['trailer'];
509
510        if (!isset($trailer['Root'])) {
511            $trailer['Root'] = $this->findCatalogReference($result['offsets']);
512        }
513
514        $preResolved = $this->expandObjectStreamsFromRepair($result['offsets']);
515
516        return [$result['offsets'], $trailer, $preResolved];
517    }
518
519    /**
520     * A brute-force repair scan only finds objects that appear as literal
521     * "N G obj ... endobj" text - objects packed inside a /Type /ObjStm
522     * container's stream body don't match that pattern at all, since
523     * they're serialized inline within the ObjStm's own stream data rather
524     * than as their own "obj" markers. Without this second pass, any object
525     * that only exists inside an object stream would be silently
526     * unrecoverable after repair fires.
527     *
528     * These recovered objects don't have a byte offset the way normal
529     * repair-scanned objects do - they're already fully parsed values, not
530     * "here's where to find it" locations - so instead of trying to fit
531     * them into the offsets/inStream shape, this returns a ready-to-use
532     * [objNum => value] map that the caller seeds directly into the object
533     * cache (getObject() already checks the cache before consulting
534     * offsets).
535     *
536     * @param  array $offsets
537     * @return array
538     */
539    protected function expandObjectStreamsFromRepair(array $offsets): array
540    {
541        $preResolved = [];
542
543        foreach ($offsets as $location) {
544            if (!isset($location['offset'])) {
545                continue;
546            }
547
548            try {
549                $value = $this->parseAt($location['offset']);
550            } catch (\Throwable $e) {
551                continue;
552            }
553
554            if (!($value instanceof Value\Stream)) {
555                continue;
556            }
557
558            $type = $value->dict['Type'] ?? null;
559            if (!($type instanceof Value\Name) || ($type->name !== 'ObjStm')) {
560                continue;
561            }
562
563            try {
564                $expanded = ObjectStream::expand($value, $this->decodeBudget);
565            } catch (\Throwable $e) {
566                continue;
567            }
568
569            foreach ($expanded as $objNum => $objValue) {
570                if (!isset($preResolved[$objNum])) {
571                    $preResolved[$objNum] = $objValue;
572                }
573            }
574        }
575
576        return $preResolved;
577    }
578
579    /**
580     * Scan repair-recovered offsets for an object that looks like the document catalog
581     *
582     * @param  array $offsets
583     * @return ?Value\Reference
584     */
585    protected function findCatalogReference(array $offsets): ?Value\Reference
586    {
587        foreach ($offsets as $objNum => $location) {
588            if (!isset($location['offset'])) {
589                continue;
590            }
591
592            try {
593                $tokenizer = new Tokenizer($this->data, $location['offset']);
594                $tokenizer->next();
595                $genToken = $tokenizer->next();
596                $objToken = $tokenizer->next();
597
598                if (($objToken['type'] === 'keyword') && ($objToken['value'] === 'obj')) {
599                    $parser = new ObjectParser($tokenizer);
600                    $value  = $parser->parseValue();
601
602                    if (is_array($value) && isset($value['Type']) &&
603                        ($value['Type'] instanceof Value\Name) && ($value['Type']->name === 'Catalog')) {
604                        return new Value\Reference($objNum, (int) $genToken['value']);
605                    }
606                }
607            } catch (Exception $e) {
608                continue;
609            }
610        }
611
612        return null;
613    }
614
615    /**
616     * Determine if xref-derived offsets/trailer look usable (vs. needing repair)
617     *
618     * @param  array $offsets
619     * @param  array $trailer
620     * @return bool
621     */
622    protected function isUsable(array $offsets, array $trailer): bool
623    {
624        if (empty($offsets) || !isset($trailer['Root'])) {
625            return false;
626        }
627
628        $root = $trailer['Root'];
629        if (!($root instanceof Value\Reference) || !isset($offsets[$root->objNum])) {
630            return false;
631        }
632
633        $sample = array_slice($offsets, 0, 5, true);
634        foreach ($sample as $location) {
635            if (isset($location['offset']) && !$this->looksLikeObjectAt($location['offset'])) {
636                return false;
637            }
638        }
639
640        return true;
641    }
642
643    /**
644     * Determine if a byte offset looks like the start of an "N G obj" object
645     *
646     * @param  int $offset
647     * @return bool
648     */
649    protected function looksLikeObjectAt(int $offset): bool
650    {
651        if (($offset < 0) || ($offset >= strlen($this->data))) {
652            return false;
653        }
654
655        $chunk = substr($this->data, $offset, 32);
656
657        return (bool) preg_match('/^\s*\d+\s+\d+\s+obj\b/', $chunk);
658    }
659
660}