Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PageWalker
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
4 / 4
26
100.00% covered (success)
100.00%
1 / 1
 walk
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 walkNode
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
17
 resolveContent
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 decodeStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Content;
16
17use Pop\Pdf\Extract\Document;
18use Pop\Pdf\Extract\Exception;
19use Pop\Pdf\Extract\Filter\Budget;
20use Pop\Pdf\Extract\Filter\Registry;
21use Pop\Pdf\Extract\Value;
22
23/**
24 * Pdf extract content page walker class
25 *
26 * @category   Pop
27 * @package    Pop\Pdf
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    6.0.0
32 */
33class PageWalker
34{
35
36    /**
37     * Maximum page-tree recursion depth
38     */
39    protected const MAX_TREE_DEPTH = 64;
40
41    /**
42     * Walk a document's page tree into a flat, 0-indexed array of PageInfo objects (index 0 = page 1)
43     *
44     * @param  Document $doc
45     * @param  ?array   $pageNumbers
46     * @param  ?int     $pageLimit
47     * @throws Exception
48     * @return array
49     */
50    public static function walk(Document $doc, ?array $pageNumbers = null, ?int $pageLimit = null): array
51    {
52        $root  = $doc->getRoot();
53        $pages = $doc->resolve($root['Pages'] ?? null);
54
55        if (!is_array($pages)) {
56            throw new Exception('Error: Could not resolve the PDF page tree (Pages).');
57        }
58
59        $result    = [];
60        $inherited = ['Resources' => null, 'MediaBox' => null, 'Rotate' => null];
61        $visited   = [];
62        $pageCount = 0;
63
64        // Precompute a flipped lookup set once (O(n)) instead of letting
65        // walkNode() do an in_array() scan of $pageNumbers (O(n)) for every
66        // single page visited - that turned page-subset extraction into
67        // O(pages * count($pageNumbers)) instead of O(pages).
68        $pageNumberSet = ($pageNumbers !== null) ? array_flip($pageNumbers) : null;
69
70        self::walkNode($doc, $pages, $inherited, $result, $visited, 0, $pageNumberSet, $pageLimit, $pageCount);
71
72        return $result;
73    }
74
75    /**
76     * Recursively walk one page-tree node, appending PageInfo objects for each Page found
77     *
78     * @param  Document $doc
79     * @param  array    $node
80     * @param  array    $inherited
81     * @param  array    $result
82     * @param  array    $visited
83     * @param  int      $depth
84     * @param  ?array   $pageNumberSet flipped [pageNumber => key] lookup set, or null for "all pages"
85     * @param  ?int     $pageLimit
86     * @param  int      $pageCount
87     * @return void
88     */
89    protected static function walkNode(
90        Document $doc, array $node, array $inherited, array &$result, array &$visited, int $depth,
91        ?array $pageNumberSet, ?int $pageLimit, int &$pageCount
92    ): void
93    {
94        if ($depth > self::MAX_TREE_DEPTH) {
95            return;
96        }
97
98        foreach (['Resources', 'MediaBox', 'Rotate'] as $key) {
99            if (isset($node[$key])) {
100                $inherited[$key] = $node[$key];
101            }
102        }
103
104        $type     = $node['Type'] ?? null;
105        $typeName = ($type instanceof Value\Name) ? $type->name : null;
106
107        if ($typeName === 'Page') {
108            $pageCount++;
109
110            $needed = true;
111            if ($pageNumberSet !== null) {
112                $needed = isset($pageNumberSet[$pageCount]);
113            } elseif (is_int($pageLimit) && ($pageLimit > 0)) {
114                $needed = ($pageCount <= $pageLimit);
115            }
116
117            if (!$needed) {
118                // A page the caller didn't ask for (outside $pageNumbers, or
119                // past $pageLimit) still gets a PageInfo entry so page
120                // count/1-indexing stays correct - it just skips the
121                // expensive resolve/decompress work entirely, since a
122                // caller passing e.g. pageLimit=1 on untrusted input expects
123                // that to actually bound the work done, not just the output
124                // returned (confirmed during Phase D's final review: without
125                // this, a 40-page PDF with 5MB/page content still cost 5.7s
126                // and 211MB even when only page 1 was requested).
127                $result[] = new PageInfo($node, [], null, null, '');
128                return;
129            }
130
131            try {
132                $resources = $doc->resolve($inherited['Resources']);
133                $mediaBox  = $doc->resolve($inherited['MediaBox']);
134                $rotate    = $doc->resolve($inherited['Rotate']);
135                $content   = self::resolveContent($doc, $node);
136            } catch (Exception $e) {
137                // A single malformed/unsupported page (bad stream filter,
138                // circular reference in its own Resources/Contents/MediaBox)
139                // must not abort extraction of every OTHER page in the
140                // document - degrade this page to empty content instead of
141                // letting the exception unwind the whole walk(), and keep
142                // this page's PageInfo entry (rather than skipping it) so
143                // page count/1-indexing stays correct for $pages selectors
144                // and pageLimit.
145                $resources = [];
146                $mediaBox  = null;
147                $rotate    = null;
148                $content   = '';
149            }
150
151            $result[] = new PageInfo(
152                $node,
153                is_array($resources) ? $resources : [],
154                $mediaBox,
155                $rotate,
156                $content
157            );
158
159            return;
160        }
161
162        $kids = $doc->resolve($node['Kids'] ?? null);
163
164        if (!is_array($kids)) {
165            return;
166        }
167
168        foreach ($kids as $kidRef) {
169            if ($kidRef instanceof Value\Reference) {
170                if (isset($visited[$kidRef->objNum])) {
171                    continue;
172                }
173                $visited[$kidRef->objNum] = true;
174            }
175
176            $kid = $doc->resolve($kidRef);
177
178            if (is_array($kid)) {
179                self::walkNode($doc, $kid, $inherited, $result, $visited, $depth + 1, $pageNumberSet, $pageLimit, $pageCount);
180            }
181        }
182    }
183
184    /**
185     * Resolve and decode a page's /Contents into a single content stream string
186     *
187     * @param  Document $doc
188     * @param  array    $page
189     * @throws Exception
190     * @return string
191     */
192    protected static function resolveContent(Document $doc, array $page): string
193    {
194        $contents = $doc->resolve($page['Contents'] ?? null);
195        $budget   = $doc->getDecodeBudget();
196
197        if ($contents instanceof Value\Stream) {
198            return self::decodeStream($contents, $budget);
199        }
200
201        if (is_array($contents)) {
202            $parts = [];
203
204            foreach ($contents as $ref) {
205                $stream = $doc->resolve($ref);
206                if ($stream instanceof Value\Stream) {
207                    $parts[] = self::decodeStream($stream, $budget);
208                }
209            }
210
211            return implode("\n", $parts);
212        }
213
214        return '';
215    }
216
217    /**
218     * Decode a content stream through its filter(s)
219     *
220     * @param  Value\Stream $stream
221     * @param  ?Budget      $budget
222     * @throws Exception
223     * @return string
224     */
225    protected static function decodeStream(Value\Stream $stream, ?Budget $budget = null): string
226    {
227        return Registry::decode($stream->raw, $stream->dict['Filter'] ?? null, $stream->dict['DecodeParms'] ?? null, $budget);
228    }
229
230}