Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.16% covered (success)
99.16%
235 / 237
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Compiler
99.16% covered (success)
99.16%
235 / 237
90.00% covered (success)
90.00%
9 / 10
81
0.00% covered (danger)
0.00%
0 / 1
 setDocument
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
 finalize
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
23
 prepareFonts
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
7.02
 prepareImages
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 preparePaths
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 prepareText
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
15
 prepareTextStreams
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 prepareAnnotations
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 prepareFields
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 prepareForms
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
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\Page\Text;
19
20/**
21 * Pdf compiler class
22 *
23 * @category   Pop
24 * @package    Pop\Pdf
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    6.0.0
29 */
30class Compiler extends AbstractCompiler
31{
32
33    /**
34     * Set the document object
35     *
36     * @param  Document $document
37     * @return Compiler
38     */
39    public function setDocument(Document $document): Compiler
40    {
41        $this->document = $document;
42
43        foreach ($this->document->getPages() as $key => $page) {
44            if (!in_array($page, $this->pages, true)) {
45                $this->pages[$key] = $page;
46            }
47        }
48
49        foreach ($this->document->getFonts() as $key => $font) {
50            if (!in_array($font, $this->fonts, true)) {
51                $this->fonts[$key] = $font;
52            }
53        }
54
55        $this->compression = $this->document->isCompressed();
56
57        if ($this->document->hasImportedObjects()) {
58            foreach ($this->document->getImportObjects() as $i => $object) {
59                if ($object instanceof PdfObject\RootObject) {
60                    $this->setRoot($object);
61                } else if ($object instanceof PdfObject\ParentObject) {
62                    $this->setParent($object);
63                } else if ($object instanceof PdfObject\InfoObject) {
64                    $this->setInfo($object);
65                } else {
66                    $this->addObject($i, $object);
67                }
68            }
69        }
70
71        if ($this->root === null) {
72            $this->setRoot(new PdfObject\RootObject());
73        }
74        if ($this->parent === null) {
75            $this->setParent(new PdfObject\ParentObject());
76        }
77        if ($this->info === null) {
78            $this->setInfo(new PdfObject\InfoObject());
79        }
80
81        $this->root->setVersion($this->document->getVersion());
82        $this->info->setMetadata($this->document->getMetadata());
83
84        return $this;
85    }
86
87    /**
88     * Compile and finalize the PDF document
89     *
90     * @param  ?Document $document
91     * @throws Exception
92     * @return void
93     */
94    public function finalize(?Document $document = null): void
95    {
96        if ($document !== null) {
97            $this->setDocument($document);
98        }
99        $this->prepareFonts();
100
101        $pageObjects = [];
102
103        foreach ($this->pages as $page) {
104            if ($page->hasImportedPageObject()) {
105                $pageObject = $page->getImportedPageObject();
106                $pageObject->setCurrentContentIndex(null);
107                $this->addObject($pageObject->getIndex(), $pageObject);
108            } else {
109                $page->setIndex($this->lastIndex() + 1);
110                $pageObject = new PdfObject\PageObject($page->getWidth(), $page->getHeight(), $page->getIndex());
111                $pageObject->setParentIndex($this->parent->getIndex());
112                $this->addObject($pageObject->getIndex(), $pageObject);
113                $this->parent->addKid($pageObject->getIndex());
114            }
115
116            foreach ($this->fontReferences as $fontReference) {
117                $pageObject->addFontReference($fontReference);
118            }
119
120            // Prepare image objects
121            if ($page->hasImages()) {
122                $this->prepareImages($page->getImages(), $pageObject);
123            }
124            // Prepare path objects
125            if ($page->hasPaths()) {
126                $this->preparePaths($page->getPaths(), $pageObject);
127            }
128            // Prepare text objects
129            if ($page->hasText()) {
130                $this->prepareText($page->getText(), $pageObject);
131            }
132            // Prepare text objects
133            if ($page->hasTextStreams()) {
134                $this->prepareTextStreams($page->getTextStreams(), $pageObject);
135            }
136            // Prepare field objects
137            if ($page->hasFields()) {
138                $this->prepareFields($page->getFields(), $pageObject);
139            }
140
141            $pageObjects[$pageObject->getIndex()] = $pageObject;
142        }
143
144        // Prepare annotation objects, after the pages have been set
145        foreach ($this->pages as $page) {
146            if ($page->hasAnnotations()) {
147                $this->prepareAnnotations($page->getAnnotations(), $pageObjects[$page->getIndex()]);
148            }
149        }
150
151        // If the document has forms
152        if ($document->hasForms()) {
153            $this->prepareForms();
154        }
155
156        // Compute each object's byte offset keyed by its real object number
157        // rather than assuming objects are inserted in dense, ascending,
158        // gapless order - imported/merged documents commonly have gaps
159        // (excluded source Root/Info numbers) and PHP array insertion order
160        // need not match ascending numeric order. The classical xref table
161        // requires row N to correspond exactly to object number N, so the
162        // table is built from this offset map afterward, not inline during
163        // emission.
164        $offsets = [];
165
166        // Intial Length is the length of the version string
167        $this->byteLength = 9;
168        $offsets[$this->root->getIndex()] = $this->byteLength;
169
170        // New Length is the distance to the second object
171        $rootString        = (string)$this->root;
172        $this->byteLength  = $this->calculateByteLength($rootString);
173
174        $this->output .= $rootString;
175
176        // Loop through the rest of the objects, calculate their size and length
177        // for the xref table and add their data to the output.
178        foreach ($this->objects as $object) {
179            if ($object->getIndex() != $this->root->getIndex()) {
180                if (($object instanceof PdfObject\StreamObject) && ($this->compression) && (!$object->isPalette()) &&
181                    (!$object->isEncoded() && !$object->isImported() && (stripos((string)$object->getDefinition(), '/length') === false))) {
182                    $object->encode();
183                }
184                $objectString  = (string)$object;
185                $offsets[$object->getIndex()] = $this->byteLength;
186                $this->output     .= $objectString;
187                $this->byteLength += $this->calculateByteLength($objectString);
188            }
189        }
190
191        $maxObjNum = max(array_keys($offsets));
192        $numObjs   = $maxObjNum + 1;
193
194        $this->trailer = "xref\n0 {$numObjs}\n0000000000 65535 f \n";
195        for ($i = 1; $i <= $maxObjNum; $i++) {
196            $this->trailer .= isset($offsets[$i])
197                ? $this->formatByteLength($offsets[$i]) . " 00000 n \n"
198                : "0000000000 65535 f \n";
199        }
200
201        // Finalize the trailer.
202        $this->trailer .= "trailer\n<</Size {$numObjs}/Root " . $this->root->getIndex() . " 0 R/Info " .
203            $this->info->getIndex() . " 0 R>>\nstartxref\n" . ($this->byteLength) . "\n%%EOF";
204
205        // Append the trailer to the final output.
206        $this->output .= $this->trailer;
207    }
208
209    /**
210     * Prepare the font objects
211     *
212     * @throws Exception|Font\Exception
213     * @return void
214     */
215    public function prepareFonts(): void
216    {
217        foreach ($this->fonts as $font) {
218            if ($font instanceof \Pop\Pdf\Document\Font) {
219                $f = count($this->fontReferences) + 1;
220                $i = $this->lastIndex() + 1;
221
222                if ($font->isStandard()) {
223                    $this->fontReferences[$font->getName()] = '/MF' . $f . ' ' . $i . ' 0 R';
224                    $this->addObject($i, PdfObject\StreamObject::parse(
225                        "{$i} 0 obj\n<<\n    /Type /Font\n    /Subtype /Type1\n    /Name /MF{$f}\n    /BaseFont /" .
226                        $font->getName() . "\n    /Encoding /WinAnsiEncoding\n>>\nendobj\n\n"
227                    ));
228                } else {
229                    $parser = $font->parser()
230                        ->setCompression($this->compression)
231                        ->setFontIndex($f)
232                        ->setFontObjectIndex($i);
233
234                    if ($font->isCid()) {
235                        $parser->setCidFontObjectIndex($i + 1)
236                            ->setFontDescIndex($i + 2)
237                            ->setFontFileIndex($i + 3)
238                            ->setToUnicodeIndex($i + 4);
239                    } else {
240                        $parser->setFontDescIndex($i + 1)
241                            ->setFontFileIndex($i + 2);
242                    }
243
244                    $parser->parse();
245
246                    $this->fontReferences[$parser->getFontName()] = $parser->getFontReference();
247                    foreach ($parser->getObjects() as $fontObject) {
248                        $this->addObject($fontObject->getIndex(), $fontObject);
249                    }
250                }
251            } else if (is_array($font)) {
252                $this->fontReferences[$font['name']] = $font['ref'];
253            }
254        }
255    }
256
257    /**
258     * Prepare the image objects
259     *
260     * @param  array $images
261     * @param  PdfObject\PageObject $pageObject
262     * @return void
263     */
264    protected function prepareImages(array $images, PdfObject\PageObject $pageObject): void
265    {
266        $imgs = [];
267
268        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
269        $this->addObject($contentObject->getIndex(), $contentObject);
270        $pageObject->addContentIndex($contentObject->getIndex());
271
272        // Page::addImage() always appends to $images with a fresh
273        // auto-incrementing key, so within a single call every $key here is
274        // unique - $imgs (built up only inside this same loop) can never
275        // already hold one.
276        foreach ($images as $key => $image) {
277            $coordinates = $this->getCoordinates($image['x'], $image['y'], $pageObject);
278            $i = $this->lastIndex() + 1;
279            if ($image['image']->isStream()) {
280                $imageParser = Image\Parser::createImageFromStream(
281                    $image['image']->getStream(), (int)round($coordinates['x']), (int)round($coordinates['y']),
282                    $image['image']->getResizeDimensions(), $image['image']->isPreserveResolution()
283                );
284            } else {
285                $imageParser = Image\Parser::createImageFromFile(
286                    $image['image']->getImage(), (int)round($coordinates['x']), (int)round($coordinates['y']),
287                    $image['image']->getResizeDimensions(), $image['image']->isPreserveResolution()
288                );
289            }
290
291            $imageParser->setIndex($i);
292            $contentObject->appendStream($imageParser->getStream());
293            $pageObject->addXObjectReference($imageParser->getXObject());
294            foreach ($imageParser->getObjects() as $oi => $imageObject) {
295                $this->addObject($oi, $imageObject);
296            }
297            $imgs[$key] = $imageParser;
298        }
299    }
300
301    /**
302     * Prepare the path objects
303     *
304     * @param  array $paths
305     * @param  PdfObject\PageObject $pageObject
306     * @return void
307     */
308    protected function preparePaths(array $paths, PdfObject\PageObject $pageObject): void
309    {
310        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
311        $this->addObject($contentObject->getIndex(), $contentObject);
312        $pageObject->addContentIndex($contentObject->getIndex());
313
314        foreach ($paths as $path) {
315            $stream  = null;
316            $streams = $path->getStreams();
317            foreach ($streams as $str) {
318                $s = $str['stream'];
319                if (isset($str['points'])) {
320                    foreach ($str['points'] as $points) {
321                        $keys = array_keys($points);
322                        $coordinates = $this->getCoordinates($points[$keys[0]], $points[$keys[1]], $pageObject);
323                        $s = str_replace(
324                            ['[{' . $keys[0] . '}]', '[{' . $keys[1] . '}]'], [$coordinates['x'], $coordinates['y']], $s
325                        );
326                    }
327                }
328                $stream .= $s;
329            }
330
331            $contentObject->appendStream($stream);
332        }
333    }
334
335    /**
336     * Prepare the text objects
337     *
338     * @param  array $text
339     * @param  PdfObject\PageObject $pageObject
340     * @throws Exception
341     * @return void
342     */
343    protected function prepareText(array $text, PdfObject\PageObject $pageObject): void
344    {
345        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
346        $this->addObject($contentObject->getIndex(), $contentObject);
347        $pageObject->addContentIndex($contentObject->getIndex());
348
349        foreach ($text as $txt) {
350            if ($this->document->hasStyle($txt['font'])) {
351                $style = $this->document->getStyle($txt['font']);
352                if ($style->hasSize()) {
353                    $txt['text']->setSize($style->getSize());
354                }
355                if ($style->hasFont()) {
356                    $txt['font'] = $style->getFont();
357                }
358            }
359            if (!isset($this->fontReferences[$txt['font']])) {
360                throw new Exception('Error: The font \'' . $txt['font'] . '\' has not been added to the document.');
361            }
362
363            $fontObject = $this->fonts[$txt['font']] ?? null;
364            if ($fontObject instanceof \Pop\Pdf\Document\Font) {
365                $txt['text']->setFont($fontObject);
366            }
367
368            $coordinates = $this->getCoordinates($txt['x'], $txt['y'], $pageObject);
369
370            // Auto-wrap text by character length
371            if ($txt['text']->hasCharWrap()) {
372                $font    = $this->fontReferences[$txt['font']];
373                $stream  = $txt['text']->startStream($font, $coordinates['x'], $coordinates['y']);
374                $stream .= $txt['text']->getPartialStream($font);
375                $stream .= $txt['text']->endStream();
376                $contentObject->appendStream($stream);
377            // Left/right/center align text
378            } else if ($txt['text']->hasAlignment()) {
379                $strings = $txt['text']->getAlignment()->getStrings($txt['text'], $fontObject, $coordinates['y']);
380                foreach ($strings as $string) {
381                    $textString = new Text($string['string'], $txt['text']->getSize());
382                    if ($fontObject instanceof \Pop\Pdf\Document\Font) {
383                        $textString->setFont($fontObject);
384                    }
385                    $contentObject->appendStream(
386                        $textString->getStream($this->fontReferences[$txt['font']], $string['x'], $string['y'])
387                    );
388                }
389            // Left/right wrap text around box boundary
390            } else if ($txt['text']->hasWrap()) {
391                $strings = $txt['text']->getWrap()->getStrings($txt['text'], $fontObject, $coordinates['y']);
392                $stream  = $txt['text']->getColorStream();
393                if (!empty($stream)) {
394                    $contentObject->appendStream($stream);
395                }
396                foreach ($strings as $string) {
397                    $textString = new Text($string['string'], $txt['text']->getSize());
398                    if ($fontObject instanceof \Pop\Pdf\Document\Font) {
399                        $textString->setFont($fontObject);
400                    }
401                    $contentObject->appendStream(
402                        $textString->getStream($this->fontReferences[$txt['font']], $string['x'], $string['y'])
403                    );
404                }
405            // Else, just append the text stream
406            } else {
407                $contentObject->appendStream(
408                    $txt['text']->getStream($this->fontReferences[$txt['font']], $coordinates['x'], $coordinates['y'])
409                );
410            }
411        }
412    }
413
414    /**
415     * Prepare the text streams objects
416     *
417     * @param  array $textStreams
418     * @param  PdfObject\PageObject $pageObject
419     * @throws Exception
420     * @return void
421     */
422    protected function prepareTextStreams(array $textStreams, PdfObject\PageObject $pageObject): void
423    {
424        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
425        $this->addObject($contentObject->getIndex(), $contentObject);
426        $pageObject->addContentIndex($contentObject->getIndex());
427
428        foreach ($textStreams as $txt) {
429            $stream = $txt->getStream($this->fonts, $this->fontReferences);
430            $contentObject->appendStream($stream);
431        }
432    }
433
434    /**
435     * Prepare the annotation objects
436     *
437     * @param  array $annotations
438     * @param  PdfObject\PageObject $pageObject
439     * @return void
440     */
441    protected function prepareAnnotations(array $annotations, PdfObject\PageObject $pageObject): void
442    {
443        foreach ($annotations as $annotation) {
444            $i = $this->lastIndex() + 1;
445            $pageObject->addAnnotIndex($i);
446
447            $coordinates = $this->getCoordinates($annotation['x'], $annotation['y'], $pageObject);
448            if ($annotation['annotation'] instanceof \Pop\Pdf\Document\Page\Annotation\Url) {
449                $stream = $annotation['annotation']->getStream($i, $coordinates['x'], $coordinates['y']);
450            } else {
451                $targetCoordinates = $this->getCoordinates(
452                    $annotation['annotation']->getXTarget(), $annotation['annotation']->getYTarget(), $pageObject
453                );
454
455                $annotation['annotation']->setXTarget($targetCoordinates['x']);
456                $annotation['annotation']->setYTarget($targetCoordinates['y']);
457                $stream = $annotation['annotation']->getStream(
458                    $i, $coordinates['x'], $coordinates['y'], $pageObject->getIndex(), $this->parent->getKids()
459                );
460            }
461            $this->addObject($i, PdfObject\StreamObject::parse($stream));
462        }
463    }
464
465    /**
466     * Prepare the field objects
467     *
468     * @param  array $fields
469     * @param  PdfObject\PageObject $pageObject
470     * @throws Exception
471     * @return void
472     */
473    protected function prepareFields(array $fields, PdfObject\PageObject $pageObject): void
474    {
475        foreach ($fields as $field) {
476            if ($this->document->getForm($field['form']) !== null) {
477                if (($field['field']->getFont() !== null) && (!isset($this->fontReferences[$field['field']->getFont()]))) {
478                    throw new Exception('Error: The font \'' . $field['field']->getFont() . '\' has not been added to the document.');
479                } else if (($field['field']->getFont() !== null) && (isset($this->fontReferences[$field['field']->getFont()]))) {
480                    $fontRef = $this->fontReferences[$field['field']->getFont()];
481                } else {
482                    $fontRef = null;
483                }
484                $i = $this->lastIndex() + 1;
485                $pageObject->addAnnotIndex($i);
486                $coordinates = $this->getCoordinates($field['x'], $field['y'], $pageObject);
487                $this->document->getForm($field['form'])->addFieldIndex($i);
488                $this->addObject($i, PdfObject\StreamObject::parse(
489                    $field['field']->getStream($i, $pageObject->getIndex(), $fontRef, $coordinates['x'], $coordinates['y'])
490                ));
491            }
492        }
493    }
494
495    /**
496     * Prepare the form objects
497     *
498     * @return void
499     */
500    protected function prepareForms(): void
501    {
502        // Per spec, a document's Catalog may only have a single /AcroForm
503        // entry, and its value must be a single form dictionary (not an
504        // array of them) - so every named Document\Form's field indices are
505        // combined into one dictionary object here, rather than compiling
506        // each Form into its own object and referencing them all as a list.
507        $fieldIndices = [];
508        foreach ($this->document->getForms() as $form) {
509            $fieldIndices = array_merge($fieldIndices, $form->getFieldIndices());
510        }
511
512        $fields = implode(' ', array_map(fn($index) => $index . ' 0 R', $fieldIndices));
513
514        $i = $this->lastIndex() + 1;
515        $this->addObject($i, PdfObject\StreamObject::parse("{$i} 0 obj\n<</Fields[{$fields}]>>\nendobj\n\n"));
516        $this->root->setFormReferences($i . ' 0 R');
517    }
518
519}