Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.56% covered (success)
97.56%
160 / 164
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectGraphReader
97.56% covered (success)
97.56%
160 / 164
85.71% covered (success)
85.71%
6 / 7
75
0.00% covered (danger)
0.00%
0 / 1
 read
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
21
 walkPagesTree
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 collectFormObjNums
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 walkFieldTree
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 translatePage
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
25
 translateGeneric
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 asReferenceList
100.00% covered (success)
100.00%
5 / 5
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\Build\Import;
16
17use Pop\Pdf\Build\Exception;
18use Pop\Pdf\Build\PdfObject;
19use Pop\Pdf\Extract\Document;
20use Pop\Pdf\Extract\Value;
21
22/**
23 * Pdf build import object graph reader class
24 *
25 * Reads one source PDF via Extract\Document, densely renumbers every object
26 * under a given starting offset, rewrites indirect references throughout,
27 * and translates the result into Build\PdfObject instances - the same shapes
28 * Document::importObjects()/Page::importPageObject()/Build\Compiler already
29 * consume. Used identically by Build\Parser (one source, offset 0) and
30 * Build\Merger (N sources, increasing offsets).
31 *
32 * @category   Pop
33 * @package    Pop\Pdf
34 * @author     Nick Sagona, III <nick@popphp.org>
35 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
36 * @license    https://www.popphp.org/license     New BSD License
37 * @version    6.0.0
38 */
39class ObjectGraphReader
40{
41
42    /**
43     * Maximum page-tree recursion depth
44     */
45    protected const MAX_TREE_DEPTH = 64;
46
47    /**
48     * Read and translate one source document's entire object graph
49     *
50     * @param  Document $doc
51     * @param  int      $offset
52     * @throws Exception
53     * @return array
54     */
55    public static function read(Document $doc, int $offset): array
56    {
57        $trailer = $doc->getTrailer();
58        $rootRef = $trailer['Root'] ?? null;
59
60        if (!($rootRef instanceof Value\Reference)) {
61            throw new Exception('Error: Could not resolve the source PDF document catalog (Root).');
62        }
63
64        $root     = $doc->resolve($rootRef);
65        $pagesRef = is_array($root) ? ($root['Pages'] ?? null) : null;
66
67        if (!($pagesRef instanceof Value\Reference)) {
68            throw new Exception('Error: Could not resolve the source PDF page tree (Pages).');
69        }
70
71        // /Root's /Pages must point to an actual /Type /Pages node, never
72        // directly at a /Type /Page (a malformed shortcut some producers
73        // take) - without this check, the rest of read() treats that
74        // number as the top Pages node (excluding it from translation as a
75        // leaf page), leaving it silently absent from pageObjects and
76        // crashing downstream with a fatal, uncatchable Error rather than
77        // a clean, catchable exception.
78        $pagesNode = $doc->getObject($pagesRef->objNum);
79        $pagesType = is_array($pagesNode) ? ($pagesNode['Type'] ?? null) : null;
80
81        if (!is_array($pagesNode) || !($pagesType instanceof Value\Name) || ($pagesType->name !== 'Pages')) {
82            throw new Exception('Error: The source PDF page tree root (Pages) is malformed or missing.');
83        }
84
85        $objectNumbers = $doc->getObjectNumbers();
86        sort($objectNumbers);
87
88        $map  = [];
89        $next = $offset;
90        foreach ($objectNumbers as $objNum) {
91            $next++;
92            $map[$objNum] = $next;
93        }
94
95        $leafPageObjNums = [];
96        $inheritedByPage = [];
97        $visited         = [];
98        $inherited       = ['MediaBox' => null, 'Resources' => null, 'Rotate' => null];
99
100        self::walkPagesTree($doc, $pagesRef->objNum, $inherited, $leafPageObjNums, $inheritedByPage, $visited, 0);
101
102        $leafPageObjNumSet = array_flip($leafPageObjNums);
103
104        $infoRef        = $trailer['Info'] ?? null;
105        $infoObjNum     = ($infoRef instanceof Value\Reference) ? $infoRef->objNum : null;
106        $topPagesObjNum = $pagesRef->objNum;
107
108        // Widget annotations are excluded from a page's /Annots below (no
109        // /AcroForm is carried over for them to register against), but that
110        // by itself only stops the page from *pointing at* them - the
111        // widget objects (and the source's own /AcroForm dictionary) are
112        // still other object numbers in $objectNumbers, so without this
113        // exclusion they fall through to translateGeneric() and get written
114        // into the merged output as dead, unreferenced objects.
115        $excludedFormObjNums = self::collectFormObjNums($doc, $root['AcroForm'] ?? null);
116
117        $objects     = [];
118        $pageObjects = [];
119        $infoDict    = null;
120
121        foreach ($objectNumbers as $objNum) {
122            if (($objNum === $rootRef->objNum) || ($objNum === $topPagesObjNum) || ($objNum === $infoObjNum)
123                || isset($excludedFormObjNums[$objNum])) {
124                continue;
125            }
126
127            $newObjNum = $map[$objNum];
128
129            if (isset($leafPageObjNumSet[$objNum])) {
130                $node = $doc->getObject($objNum);
131                $pageObjects[$newObjNum] = self::translatePage(
132                    $doc, $newObjNum, is_array($node) ? $node : [], $inheritedByPage[$objNum], $map
133                );
134                continue;
135            }
136
137            $rewritten           = ReferenceRewriter::rewrite($doc->getObject($objNum), $map);
138            $objects[$newObjNum] = self::translateGeneric($newObjNum, $rewritten);
139        }
140
141        if ($infoObjNum !== null) {
142            $rewrittenInfo = ReferenceRewriter::rewrite($doc->getObject($infoObjNum), $map);
143            $infoDict      = is_array($rewrittenInfo) ? $rewrittenInfo : null;
144        }
145
146        $topPagesNode      = $doc->getObject($topPagesObjNum);
147        $topPagesRewritten = ReferenceRewriter::rewrite(is_array($topPagesNode) ? $topPagesNode : [], $map);
148
149        $orderedPageObjects = [];
150        foreach ($leafPageObjNums as $objNum) {
151            $orderedPageObjects[] = $pageObjects[$map[$objNum]];
152        }
153
154        return [
155            'objects'        => $objects,
156            'pageObjects'    => $orderedPageObjects,
157            'topPagesObjNum' => $map[$topPagesObjNum],
158            'topPagesDict'   => $topPagesRewritten,
159            'infoDict'       => $infoDict,
160            'nextOffset'     => $next,
161        ];
162    }
163
164    /**
165     * Recursively walk one page-tree node, accumulating inherited attributes
166     * and recording every leaf Page's object number in document order
167     *
168     * @param  Document $doc
169     * @param  int      $objNum
170     * @param  array    $inherited
171     * @param  array    $leafPageObjNums
172     * @param  array    $inheritedByPage
173     * @param  array    $visited
174     * @param  int      $depth
175     * @return void
176     */
177    protected static function walkPagesTree(
178        Document $doc, int $objNum, array $inherited, array &$leafPageObjNums,
179        array &$inheritedByPage, array &$visited, int $depth
180    ): void
181    {
182        if (($depth > self::MAX_TREE_DEPTH) || isset($visited[$objNum])) {
183            return;
184        }
185        $visited[$objNum] = true;
186
187        $node = $doc->getObject($objNum);
188        if (!is_array($node)) {
189            return;
190        }
191
192        foreach (['MediaBox', 'Resources', 'Rotate'] as $key) {
193            if (isset($node[$key])) {
194                $inherited[$key] = $doc->resolve($node[$key]);
195            }
196        }
197
198        $type     = $node['Type'] ?? null;
199        $typeName = ($type instanceof Value\Name) ? $type->name : null;
200
201        if ($typeName === 'Page') {
202            $leafPageObjNums[]        = $objNum;
203            $inheritedByPage[$objNum] = $inherited;
204            return;
205        }
206
207        $kids = $doc->resolve($node['Kids'] ?? null);
208        if (!is_array($kids)) {
209            return;
210        }
211
212        foreach ($kids as $kidRef) {
213            if ($kidRef instanceof Value\Reference) {
214                self::walkPagesTree($doc, $kidRef->objNum, $inherited, $leafPageObjNums, $inheritedByPage, $visited, $depth + 1);
215            }
216        }
217    }
218
219    /**
220     * Collect the object numbers of a source PDF's /AcroForm dictionary and
221     * every field/widget object reachable from its /Fields tree, so the
222     * caller can omit them entirely rather than leaving them as orphaned
223     * objects (see the exclusion built in read()).
224     *
225     * @param  Document $doc
226     * @param  mixed    $acroFormRef
227     * @return array
228     */
229    protected static function collectFormObjNums(Document $doc, mixed $acroFormRef): array
230    {
231        if (!($acroFormRef instanceof Value\Reference)) {
232            return [];
233        }
234
235        $excluded = [$acroFormRef->objNum => true];
236
237        $acroForm = $doc->resolve($acroFormRef);
238        $fields   = is_array($acroForm) ? $doc->resolve($acroForm['Fields'] ?? null) : null;
239
240        if (is_array($fields)) {
241            $visited = [];
242            foreach ($fields as $fieldRef) {
243                if ($fieldRef instanceof Value\Reference) {
244                    self::walkFieldTree($doc, $fieldRef->objNum, $excluded, $visited, 0);
245                }
246            }
247        }
248
249        return $excluded;
250    }
251
252    /**
253     * Recursively walk one form field node, excluding it and every
254     * descendant reachable via /Kids (a field hierarchy is a valid,
255     * real-world PDF structure - a parent field with child widgets/fields)
256     *
257     * @param  Document $doc
258     * @param  int      $objNum
259     * @param  array    $excluded
260     * @param  array    $visited
261     * @param  int      $depth
262     * @return void
263     */
264    protected static function walkFieldTree(Document $doc, int $objNum, array &$excluded, array &$visited, int $depth): void
265    {
266        if (($depth > self::MAX_TREE_DEPTH) || isset($visited[$objNum])) {
267            return;
268        }
269        $visited[$objNum]  = true;
270        $excluded[$objNum] = true;
271
272        $node = $doc->getObject($objNum);
273        $kids = is_array($node) ? $doc->resolve($node['Kids'] ?? null) : null;
274
275        if (!is_array($kids)) {
276            return;
277        }
278
279        foreach ($kids as $kidRef) {
280            if ($kidRef instanceof Value\Reference) {
281                self::walkFieldTree($doc, $kidRef->objNum, $excluded, $visited, $depth + 1);
282            }
283        }
284    }
285
286    /**
287     * Translate a leaf Page node into a fully-populated PageObject
288     *
289     * @param  Document $doc
290     * @param  int      $newObjNum
291     * @param  array    $node
292     * @param  array    $inherited
293     * @param  array    $map
294     * @return PdfObject\PageObject
295     */
296    protected static function translatePage(Document $doc, int $newObjNum, array $node, array $inherited, array $map): PdfObject\PageObject
297    {
298        $mediaBox  = $inherited['MediaBox'];
299        $resources = $inherited['Resources'];
300        $rotate    = $inherited['Rotate'];
301
302        $width  = 612.0;
303        $height = 792.0;
304        if (is_array($mediaBox) && isset($mediaBox[2], $mediaBox[3])) {
305            $width  = (float) $mediaBox[2];
306            $height = (float) $mediaBox[3];
307        }
308
309        $parentNewNum = 0;
310        if (($node['Parent'] ?? null) instanceof Value\Reference) {
311            $parentNewNum = $map[$node['Parent']->objNum] ?? 0;
312        }
313
314        $pageObject = new PdfObject\PageObject($width, $height, $newObjNum);
315        $pageObject->setParentIndex($parentNewNum);
316        $pageObject->setImported(true);
317
318        foreach (self::asReferenceList($node['Contents'] ?? null) as $ref) {
319            if (isset($map[$ref->objNum])) {
320                $pageObject->addContentIndex($map[$ref->objNum]);
321            }
322        }
323
324        foreach (self::asReferenceList($node['Annots'] ?? null) as $ref) {
325            $target  = $doc->resolve($ref);
326            $subtype = is_array($target) ? ($target['Subtype'] ?? null) : null;
327
328            if (($subtype instanceof Value\Name) && ($subtype->name === 'Widget')) {
329                // Dropped - no /AcroForm is carried over for it to register
330                // against (page/visual content only, per the design's scope).
331                continue;
332            }
333            if (isset($map[$ref->objNum])) {
334                $pageObject->addAnnotIndex($map[$ref->objNum]);
335            }
336        }
337
338        $pageExtra = '';
339        foreach ($node as $key => $value) {
340            if (!in_array($key, ['Type', 'Parent', 'MediaBox', 'Annots', 'Contents', 'Resources', 'Rotate'], true)) {
341                $pageExtra .= '/' . $key . ' ' . ObjectSerializer::serializeValue(ReferenceRewriter::rewrite($value, $map));
342            }
343        }
344        if ($rotate !== null) {
345            $pageExtra .= '/Rotate ' . ObjectSerializer::serializeValue(ReferenceRewriter::rewrite($rotate, $map));
346        }
347        $pageObject->setPageExtra($pageExtra);
348
349        $otherResources = '';
350        if (is_array($resources)) {
351            foreach ($resources as $key => $value) {
352                if (!in_array($key, ['ProcSet', 'XObject', 'Font'], true)) {
353                    $otherResources .= '/' . $key . ' ' . ObjectSerializer::serializeValue(ReferenceRewriter::rewrite($value, $map));
354                }
355            }
356
357            // /Font and /XObject may themselves be indirect references
358            // (a separate, independent indirection from /Resources itself
359            // already being indirect) - a valid, real-world PDF structure
360            // (e.g. producers that share one Font dict object across many
361            // pages/Resources dicts). Resolving here mirrors the same
362            // pattern already used for content-stream interpretation
363            // (Content\Interpreter's 'Tf' operator handling, which resolves
364            // $resources['Font'] the same way before reading it).
365            $fontResolved = $doc->resolve($resources['Font'] ?? null);
366            $fontDict     = is_array($fontResolved) ? $fontResolved : [];
367            foreach ($fontDict as $name => $ref) {
368                if (($ref instanceof Value\Reference) && isset($map[$ref->objNum])) {
369                    $pageObject->addFontReference('/' . $name . ' ' . $map[$ref->objNum] . ' 0 R');
370                }
371            }
372
373            $xObjectResolved = $doc->resolve($resources['XObject'] ?? null);
374            $xObjectDict      = is_array($xObjectResolved) ? $xObjectResolved : [];
375            foreach ($xObjectDict as $name => $ref) {
376                if (($ref instanceof Value\Reference) && isset($map[$ref->objNum])) {
377                    $pageObject->addXObjectReference('/' . $name . ' ' . $map[$ref->objNum] . ' 0 R');
378                }
379            }
380        }
381        $pageObject->setOtherResources($otherResources);
382
383        return $pageObject;
384    }
385
386    /**
387     * Translate any non-page, non-Root, non-Info, non-top-Pages object into a generic passthrough
388     *
389     * @param  int   $newObjNum
390     * @param  mixed $rewritten
391     * @return PdfObject\StreamObject
392     */
393    protected static function translateGeneric(int $newObjNum, mixed $rewritten): PdfObject\StreamObject
394    {
395        $object = new PdfObject\StreamObject($newObjNum);
396
397        if ($rewritten instanceof Value\Stream) {
398            $object->setDefinition(ObjectSerializer::serializeDict($rewritten->dict));
399            $object->appendStream("\n" . $rewritten->raw);
400        } else {
401            // A top-level indirect object can be any PDF value, not just a
402            // dict - most notably a plain array (e.g. a colorspace array
403            // like [/Separation /Black /DeviceCMYK 25 0 R], a common
404            // standalone indirect object in scanned/image-heavy PDFs).
405            // serializeValue() already dispatches dict-vs-array correctly
406            // via array_is_list(); calling serializeDict() unconditionally
407            // for every array - as this used to - treated a list array's
408            // integer keys (0,1,2,3) as dict key names, corrupting the
409            // colorspace into a meaningless dict no reader could parse.
410            $object->setDefinition(ObjectSerializer::serializeValue($rewritten));
411        }
412
413        $object->setImported(true);
414
415        return $object;
416    }
417
418    /**
419     * Normalize a /Contents or /Annots value into a flat list of References
420     *
421     * @param  mixed $value
422     * @return array
423     */
424    protected static function asReferenceList(mixed $value): array
425    {
426        if ($value instanceof Value\Reference) {
427            return [$value];
428        }
429        if (is_array($value)) {
430            return array_values(array_filter($value, static fn ($v) => $v instanceof Value\Reference));
431        }
432        return [];
433    }
434
435}