Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
Merger
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
3 / 3
13
100.00% covered (success)
100.00%
1 / 1
 mergeFiles
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 mergeData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 mergeSources
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
6
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;
16
17use Pop\Pdf\Document\AbstractDocument;
18use Pop\Pdf\Extract\Document as ExtractDocument;
19use Pop\Pdf\Extract\Value;
20
21/**
22 * Pdf merger class
23 *
24 * Combines whole PDF documents into one, natively - no external
25 * dependencies. Each source is read via the same ObjectGraphReader used by
26 * Build\Parser, at an increasing per-source object-number offset, then each
27 * source's entire original /Pages subtree is spliced under one new master
28 * /Pages node.
29 *
30 * @category   Pop
31 * @package    Pop\Pdf
32 * @author     Nick Sagona, III <nick@popphp.org>
33 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
34 * @license    https://www.popphp.org/license     New BSD License
35 * @version    6.0.0
36 */
37class Merger
38{
39
40    /**
41     * Merge PDF files into one document
42     *
43     * @param  array $files
44     * @throws Exception
45     * @return AbstractDocument
46     */
47    public function mergeFiles(array $files): AbstractDocument
48    {
49        $sources = [];
50
51        foreach ($files as $file) {
52            if (!file_exists($file)) {
53                throw new Exception("Error: The PDF file '{$file}' does not exist.");
54            }
55            try {
56                $sources[] = ExtractDocument::fromFile($file);
57            } catch (\Pop\Pdf\Extract\Exception $e) {
58                throw new Exception($e->getMessage(), $e->getCode(), $e);
59            }
60        }
61
62        return $this->mergeSources($sources);
63    }
64
65    /**
66     * Merge raw PDF data streams into one document
67     *
68     * @param  array $dataList
69     * @throws Exception
70     * @return AbstractDocument
71     */
72    public function mergeData(array $dataList): AbstractDocument
73    {
74        $sources = [];
75
76        foreach ($dataList as $data) {
77            try {
78                $sources[] = new ExtractDocument($data);
79            } catch (\Pop\Pdf\Extract\Exception $e) {
80                throw new Exception($e->getMessage(), $e->getCode(), $e);
81            }
82        }
83
84        return $this->mergeSources($sources);
85    }
86
87    /**
88     * Merge a set of already-parsed Extract\Document sources
89     *
90     * @param  array $sources
91     * @throws Exception
92     * @return AbstractDocument
93     */
94    protected function mergeSources(array $sources): AbstractDocument
95    {
96        if (count($sources) < 2) {
97            throw new Exception('Error: Merging requires at least 2 source PDF documents.');
98        }
99
100        try {
101            $graphs           = [];
102            $objectLists      = [];
103            $pageObjectLists  = [];
104            $offset           = 0;
105
106            foreach ($sources as $source) {
107                $graph              = Import\ObjectGraphReader::read($source, $offset);
108                $graphs[]           = $graph;
109                $objectLists[]      = $graph['objects'];
110                $pageObjectLists[]  = $graph['pageObjects'];
111                $offset             = $graph['nextOffset'];
112            }
113
114            // Object arrays are keyed by object number, and array_merge()
115            // would renumber integer keys, so a union preserving those keys
116            // is required. array_replace() lets a later argument overwrite
117            // an earlier one on key collision, which is the opposite of the
118            // '+=' union semantics being replicated here (first source
119            // wins), so the collected lists are combined in reverse order.
120            $allObjects = array_replace(...array_reverse($objectLists));
121            $allPages   = array_merge(...$pageObjectLists);
122        } catch (\Pop\Pdf\Extract\Exception $e) {
123            throw new Exception($e->getMessage(), $e->getCode(), $e);
124        }
125
126        $masterObjNum = $offset + 1;
127        $rootObjNum   = $masterObjNum + 1;
128        $infoObjNum   = $rootObjNum + 1;
129        $masterKids   = [];
130
131        foreach ($graphs as $graph) {
132            $dict            = $graph['topPagesDict'];
133            $dict['Parent']  = new Value\Reference($masterObjNum, 0);
134
135            $streamObject = new PdfObject\StreamObject($graph['topPagesObjNum']);
136            $streamObject->setDefinition(Import\ObjectSerializer::serializeDict($dict));
137            $streamObject->setImported(true);
138
139            $allObjects[$graph['topPagesObjNum']] = $streamObject;
140            $masterKids[]                         = $graph['topPagesObjNum'];
141        }
142
143        $masterParent = new PdfObject\ParentObject($masterObjNum);
144        $masterParent->setKids($masterKids);
145        $masterParent->setCount(count($allPages));
146        $masterParent->setImported(true);
147        $allObjects[$masterObjNum] = $masterParent;
148
149        // Compiler::setDocument() synthesizes its own default RootObject
150        // (hardcoded index 1, pointing at hardcoded Pages index 2) and
151        // InfoObject (hardcoded index 3) whenever the document's imported
152        // objects don't already include one - unconditionally overwriting
153        // whatever real merged object landed at that number. Both must be
154        // supplied explicitly, at guaranteed-collision-free numbers beyond
155        // everything already allocated (this exact bug was found and fixed
156        // in Build\Parser during Task 6's review - Merger repeats the same
157        // assembly shape and needs the same fix built in from the start).
158        $root = new PdfObject\RootObject($rootObjNum);
159        $root->setParentIndex($masterObjNum);
160        $root->setImported(true);
161        $allObjects[$rootObjNum] = $root;
162
163        $info = new PdfObject\InfoObject($infoObjNum);
164        $info->setImported(true);
165        $allObjects[$infoObjNum] = $info;
166
167        $document = new \Pop\Pdf\Document();
168        $document->importObjects($allObjects);
169
170        foreach ($allPages as $pageObject) {
171            $page = new \Pop\Pdf\Document\Page($pageObject->getWidth(), $pageObject->getHeight(), $pageObject->getIndex());
172            $page->importPageObject($pageObject);
173            $document->addPage($page);
174        }
175
176        return $document;
177    }
178
179}