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