Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.97% covered (success)
94.97%
453 / 477
66.67% covered (warning)
66.67%
16 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Compiler
94.97% covered (success)
94.97%
453 / 477
66.67% covered (warning)
66.67%
16 / 24
164.30
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%
114 / 114
100.00% covered (success)
100.00%
1 / 1
47
 buildEncryptDictBody
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 stringEncryptor
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 createCheckableAppearance
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 appearanceDecorationStream
25.00% covered (danger)
25.00%
4 / 16
0.00% covered (danger)
0.00%
0 / 1
6.80
 createPushButtonAppearance
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
3.00
 sanitizeExportName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkMarkAppearanceStream
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 radioDotAppearanceStream
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 buildAppearanceXObject
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 encryptEmbeddedFontStrings
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 prepareFonts
85.19% covered (success)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
7.16
 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
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
18
 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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 prepareFields
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 groupRadioFields
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 resolveFieldFontRef
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 prepareSingleField
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
10
 prepareRadioGroup
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
11.06
 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;
19use Pop\Pdf\Document\Page\Field;
20use Pop\Pdf\Document\Page\Field\Button;
21use Pop\Pdf\Build\Security as PdfSecurity;
22
23/**
24 * Pdf compiler 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.2.0
32 */
33class Compiler extends AbstractCompiler
34{
35
36    /**
37     * Set the document object
38     *
39     * @param  Document $document
40     * @return Compiler
41     */
42    public function setDocument(Document $document): Compiler
43    {
44        $this->document = $document;
45
46        foreach ($this->document->getPages() as $key => $page) {
47            if (!in_array($page, $this->pages, true)) {
48                $this->pages[$key] = $page;
49            }
50        }
51
52        foreach ($this->document->getFonts() as $key => $font) {
53            if (!in_array($font, $this->fonts, true)) {
54                $this->fonts[$key] = $font;
55            }
56        }
57
58        $this->compression = $this->document->isCompressed();
59
60        if ($this->document->hasImportedObjects()) {
61            foreach ($this->document->getImportObjects() as $i => $object) {
62                if ($object instanceof PdfObject\RootObject) {
63                    $this->setRoot($object);
64                } else if ($object instanceof PdfObject\ParentObject) {
65                    $this->setParent($object);
66                } else if ($object instanceof PdfObject\InfoObject) {
67                    $this->setInfo($object);
68                } else {
69                    $this->addObject($i, $object);
70                }
71            }
72        }
73
74        if ($this->root === null) {
75            $this->setRoot(new PdfObject\RootObject());
76        }
77        if ($this->parent === null) {
78            $this->setParent(new PdfObject\ParentObject());
79        }
80        if ($this->info === null) {
81            $this->setInfo(new PdfObject\InfoObject());
82        }
83
84        $this->root->setVersion($this->document->getVersion());
85        $this->info->setMetadata($this->document->getMetadata());
86
87        return $this;
88    }
89
90    /**
91     * Compile and finalize the PDF document
92     *
93     * @param  ?Document $document
94     * @throws Exception
95     * @return void
96     */
97    public function finalize(?Document $document = null): void
98    {
99        if ($document !== null) {
100            $this->setDocument($document);
101        }
102        $this->prepareFonts();
103
104        // Raw bytes of the file identifier - used both for the trailer's
105        // /ID (hex-encoded further below) and, when the document is
106        // encrypted, as key-derivation/checksum input for AES-128/revision 4
107        // (revision 6 ignores it). Computed once so both uses agree on the
108        // same value.
109        $fileId = md5(uniqid((string)mt_rand(), true), true);
110
111        // Computed here, before the page/annotation/field preparation passes
112        // below, rather than immediately before serialization where this
113        // block used to live - prepareAnnotations(), prepareFields(), and
114        // font preparation each need $fileKey/$algorithm already resolved
115        // so they can encrypt each string-bearing object's literal content
116        // on the way in, instead of after the fact.
117        $fileKey     = null;
118        $encryptDict = null;
119        $algorithm   = null;
120
121        if ($document->hasSecurity()) {
122            $security  = $document->getSecurity();
123            $algorithm = $security->getAlgorithm();
124
125            if (($algorithm !== Document\Security::AES_128) && ($algorithm !== Document\Security::AES_256)) {
126                throw new PdfSecurity\Exception(
127                    "Error: Invalid encryption algorithm '{$algorithm}'. Expected '" .
128                    Document\Security::AES_128 . "' or '" . Document\Security::AES_256 . "'."
129                );
130            }
131
132            $built = ($algorithm === Document\Security::AES_128)
133                ? PdfSecurity\StandardSecurityHandler::buildRevision4($security, $fileId)
134                : PdfSecurity\StandardSecurityHandler::buildRevision6($security, $fileId);
135
136            $fileKey     = $built['fileKey'];
137            $encryptDict = $built['dict'];
138        }
139
140        $pageObjects = [];
141
142        foreach ($this->pages as $page) {
143            if ($page->hasImportedPageObject()) {
144                $pageObject = $page->getImportedPageObject();
145                $pageObject->setCurrentContentIndex(null);
146                $this->addObject($pageObject->getIndex(), $pageObject);
147            } else {
148                $page->setIndex($this->lastIndex() + 1);
149                $pageObject = new PdfObject\PageObject($page->getWidth(), $page->getHeight(), $page->getIndex());
150                $pageObject->setParentIndex($this->parent->getIndex());
151                $this->addObject($pageObject->getIndex(), $pageObject);
152                $this->parent->addKid($pageObject->getIndex());
153            }
154
155            foreach ($this->fontReferences as $fontReference) {
156                $pageObject->addFontReference($fontReference);
157            }
158
159            // Prepare image objects
160            if ($page->hasImages()) {
161                $this->prepareImages($page->getImages(), $pageObject);
162            }
163            // Prepare path objects
164            if ($page->hasPaths()) {
165                $this->preparePaths($page->getPaths(), $pageObject);
166            }
167            // Prepare text objects
168            if ($page->hasText()) {
169                $this->prepareText($page->getText(), $pageObject);
170            }
171            // Prepare text objects
172            if ($page->hasTextStreams()) {
173                $this->prepareTextStreams($page->getTextStreams(), $pageObject);
174            }
175            // Prepare field objects
176            if ($page->hasFields()) {
177                $this->prepareFields($page->getFields(), $pageObject, $fileKey, $algorithm);
178            }
179
180            $pageObjects[$pageObject->getIndex()] = $pageObject;
181        }
182
183        // A merge's own source subtrees are deferred rather than included
184        // directly in the page tree's kids up front, so that any pages the
185        // target document already had (just processed above) land before
186        // them instead of after - done before annotations are prepared,
187        // since those resolve internal-link targets against the final kids.
188        if ($this->parent->hasDeferredKids()) {
189            foreach ($this->parent->getDeferredKids() as $kid) {
190                $this->parent->addKid($kid);
191            }
192        }
193
194        // Prepare annotation objects, after the pages have been set
195        foreach ($this->pages as $page) {
196            if ($page->hasAnnotations()) {
197                $this->prepareAnnotations($page->getAnnotations(), $pageObjects[$page->getIndex()], $fileKey, $algorithm);
198            }
199        }
200
201        // If the document has forms
202        if ($document->hasForms()) {
203            $this->prepareForms();
204        }
205
206        // Compute each object's byte offset keyed by its real object number
207        // rather than assuming objects are inserted in dense, ascending,
208        // gapless order - imported/merged documents commonly have gaps
209        // (excluded source Root/Info numbers) and PHP array insertion order
210        // need not match ascending numeric order. The classical xref table
211        // requires row N to correspond exactly to object number N, so the
212        // table is built from this offset map afterward, not inline during
213        // emission.
214        $offsets = [];
215
216        // Intial Length is the length of the version string
217        $this->byteLength = 9;
218        $offsets[$this->root->getIndex()] = $this->byteLength;
219
220        // New Length is the distance to the second object
221        $rootString        = (string)$this->root;
222        $this->byteLength  = $this->calculateByteLength($rootString);
223
224        $this->output .= $rootString;
225
226        // Per-object compression pass, run to completion before anything is
227        // encrypted - encryption wraps whatever bytes the filter chain
228        // actually produced, so it must never run before compression.
229        foreach ($this->objects as $object) {
230            if ($object->getIndex() != $this->root->getIndex()) {
231                if (($object instanceof PdfObject\StreamObject) && ($this->compression) && (!$object->isPalette()) &&
232                    (!$object->isEncoded() && !$object->isImported() && (stripos((string)$object->getDefinition(), '/length') === false))) {
233                    $object->encode();
234                }
235            }
236        }
237
238        // Encryption pass - runs only when the document has security
239        // configured, after compression, and before serialization, so every
240        // stream's on-disk bytes (already run through whatever /Filter chain
241        // applies) are what gets encrypted.
242        if ($fileKey !== null) {
243            foreach ($this->objects as $object) {
244                if (($object->getIndex() == $this->root->getIndex()) || (!($object instanceof PdfObject\StreamObject))) {
245                    continue;
246                }
247                $stream = $object->getStream();
248                if (($stream !== null) && ($stream !== '')) {
249                    // Objects built via StreamObject::parse() (images,
250                    // embedded font files, palettes, and anything else with
251                    // a pre-existing declared /Length) retain, as part of
252                    // their stored stream, the leading end-of-line marker
253                    // captured from the original "stream\n<data>" text
254                    // alongside the real payload - a structural artifact of
255                    // round-tripping, not real content. The object itself
256                    // tracks exactly how many such bytes it is (regardless
257                    // of whether /Length is a literal integer, an indirect
258                    // reference, or absent - inferring it from the /Length
259                    // text here would mis-measure indirect references, whose
260                    // digits are an unrelated object number, not a byte
261                    // count). Only the bytes after that leading padding are
262                    // genuine payload; encrypting the padding too would
263                    // return it, scrambled, as part of what a reader
264                    // decrypts and then feeds to DCTDecode/FlateDecode/etc.
265                    // as if it were real data. Ordinary content-stream
266                    // objects (built via direct appendStream() calls, never
267                    // parse()) report 0 here, so they fall through
268                    // unchanged - their whole $stream is genuine content.
269                    $leadingPadding = $object->getLeadingEolLength();
270                    $payload        = (($leadingPadding > 0) && ($leadingPadding < strlen($stream)))
271                        ? substr($stream, $leadingPadding) : $stream;
272
273                    $encrypted = ($algorithm === Document\Security::AES_128)
274                        ? PdfSecurity\ObjectCipher::encryptAes128($fileKey, $object->getIndex(), 0, $payload)
275                        : PdfSecurity\ObjectCipher::encryptAes256($fileKey, $payload);
276                    $object->setStream($encrypted);
277
278                    // StreamObject::__toString() deliberately leaves /Length
279                    // untouched for every object type here (Image and
280                    // Length1/embedded-font-file objects specifically),
281                    // since those declare it explicitly rather than having
282                    // it recomputed from the stream. Encryption always
283                    // changes the on-disk byte count (16-byte IV + PKCS#7
284                    // padding), so the declared /Length needs correcting
285                    // here explicitly - otherwise a reader is handed a
286                    // stale, too-short length and a non-block-aligned
287                    // ciphertext buffer. This must handle an INDIRECT
288                    // /Length too (e.g. "6 0 R", common in imported/merged
289                    // PDFs, since Build\Parser/Build\Merger leave a source's
290                    // declared /Length untouched): replacing just the
291                    // leading digit(s) of "N G R" the same way a literal
292                    // integer is replaced would emit invalid syntax (an
293                    // indirect reference needs all three of object number,
294                    // generation, and "R"), so the WHOLE "N G R" span is
295                    // matched and replaced with a fresh literal instead -
296                    // the same pattern StreamObject::__toString() already
297                    // uses for its own (literal-length-only) dynamic
298                    // /Length recompute.
299                    $definition = (string)$object->getDefinition();
300                    if (preg_match('/\/Length\s+\d+(?:\s+\d+\s+R)?/', $definition) === 1) {
301                        $object->setDefinition(
302                            preg_replace('/\/Length\s+\d+(?:\s+\d+\s+R)?/', '/Length ' . strlen($encrypted), $definition, 1)
303                            ?? $definition
304                        );
305                    }
306                }
307            }
308
309            // /Info dictionary strings (title/author/subject/creator/
310            // producer/dates) are encrypted here to match /StrF /StdCF -
311            // see buildEncryptDictBody()'s docblock for why every literal
312            // string must actually be encrypted once that's declared.
313            // Annotation URLs, form-field strings, and embedded-font
314            // /CIDSystemInfo strings are handled in their own dedicated
315            // passes (prepareAnnotations(), prepareFields(), and
316            // encryptEmbeddedFontStrings(), defined above in this file).
317            $this->info->encryptWith($this->stringEncryptor($fileKey, $algorithm, $this->info->getIndex()));
318
319            $this->encryptEmbeddedFontStrings($fileKey, $algorithm);
320        }
321
322        // Loop through the rest of the objects, calculate their size and length
323        // for the xref table and add their data to the output.
324        foreach ($this->objects as $object) {
325            if ($object->getIndex() != $this->root->getIndex()) {
326                $objectString  = (string)$object;
327
328                // Encrypted stream content is opaque binary with no reason to
329                // start with the end-of-line marker ISO 32000-1 7.3.8.1
330                // requires immediately after the "stream" keyword - and
331                // unlike StreamObject::encode()'s own leading "\n" for
332                // FlateDecode's binary output, that EOL must NOT be part of
333                // what AES-CBC decrypts (an extra byte there desyncs the
334                // whole cipher, corrupting every block). So it is spliced in
335                // here, into the already-rendered string, after /Length has
336                // been computed from the untouched ciphertext - mirroring
337                // how the template's trailing "\nendstream\n" is likewise
338                // never counted in /Length.
339                if (($fileKey !== null) && ($object instanceof PdfObject\StreamObject)) {
340                    $streamContent = $object->getStream();
341                    if (($streamContent !== null) && ($streamContent !== '')) {
342                        $objectString = str_replace("stream" . $streamContent, "stream\n" . $streamContent, $objectString);
343                    }
344                }
345
346                $offsets[$object->getIndex()] = $this->byteLength;
347                $this->output     .= $objectString;
348                $this->byteLength += $this->calculateByteLength($objectString);
349            }
350        }
351
352        // Build and append the /Encrypt dictionary object, if the document
353        // is encrypted, before the xref table is built below - it needs an
354        // xref row of its own like any other object, so it must land in
355        // $offsets (and bump the object count) ahead of that computation.
356        $encryptIndex = null;
357
358        if ($encryptDict !== null) {
359            // Built as a raw object string rather than via PdfObject\StreamObject:
360            // that class's __toString() unconditionally rewrites the first
361            // "/Length N" it finds in the definition into the stream's own
362            // byte length (there being no stream here, that clobbers the
363            // dictionary's genuine /Length - the encryption key size in bits -
364            // down to 0, which some readers reject outright for V4/AESV2).
365            $encryptIndex  = $this->lastIndex() + 1;
366            $encryptString = "{$encryptIndex} 0 obj\n" . self::buildEncryptDictBody($algorithm, $encryptDict) .
367                "\nendobj\n\n";
368
369            $offsets[$encryptIndex] = $this->byteLength;
370            $this->output          .= $encryptString;
371            $this->byteLength      += $this->calculateByteLength($encryptString);
372        }
373
374        $maxObjNum = max(array_keys($offsets));
375        $numObjs   = $maxObjNum + 1;
376
377        $this->trailer = "xref\n0 {$numObjs}\n0000000000 65535 f \n";
378        for ($i = 1; $i <= $maxObjNum; $i++) {
379            $this->trailer .= isset($offsets[$i])
380                ? $this->formatByteLength($offsets[$i]) . " 00000 n \n"
381                : "0000000000 65535 f \n";
382        }
383
384        // Finalize the trailer.
385        $idHex      = bin2hex($fileId);
386        $encryptRef = ($encryptIndex !== null) ? "/Encrypt {$encryptIndex} 0 R" : '';
387
388        $this->trailer .= "trailer\n<</Size {$numObjs}/Root " . $this->root->getIndex() . " 0 R/Info " .
389            $this->info->getIndex() . " 0 R/ID[<{$idHex}><{$idHex}>]{$encryptRef}>>\nstartxref\n" . ($this->byteLength) . "\n%%EOF";
390
391        // Append the trailer to the final output.
392        $this->output .= $this->trailer;
393    }
394
395    /**
396     * Build the /Encrypt dictionary body text from either revision's field
397     * array produced by StandardSecurityHandler::buildRevision4()/buildRevision6().
398     *
399     * /StmF names the crypt filter for STREAMS and /StrF the one for literal
400     * STRINGS - both /StdCF here, since every literal string this library
401     * emits (Info metadata, annotation URLs, form-field strings, an embedded
402     * font's /CIDSystemInfo) is actually encrypted to match. Declaring
403     * /StdCF for strings while leaving any of them plaintext causes a
404     * conforming reader to "decrypt" that plaintext anyway, corrupting it -
405     * this dictionary must never be changed to /StrF /Identity (or have
406     * /StrF omitted, which is spec-equivalent to /Identity) without also
407     * removing every encryptWith() call site in prepareAnnotations(),
408     * prepareFields(), encryptEmbeddedFontStrings(), and the /Info
409     * encryption above, or real-world readers (confirmed: poppler-based
410     * Linux viewers, Chrome) will misdetect the cipher entirely and fail to
411     * open the document at all.
412     *
413     * @param  string $algorithm
414     * @param  array  $dict
415     * @return string
416     */
417    private static function buildEncryptDictBody(string $algorithm, array $dict): string
418    {
419        $hex = fn (string $s): string => '<' . bin2hex($s) . '>';
420
421        if ($algorithm === Document\Security::AES_128) {
422            return '<< /Filter /Standard /V 4 /R 4 /Length 128 ' .
423                '/CF << /StdCF << /CFM /AESV2 /AuthEvent /DocOpen /Length 16 >> >> /StmF /StdCF /StrF /StdCF ' .
424                "/O {$hex($dict['O'])} /U {$hex($dict['U'])} /P {$dict['P']} >>";
425        }
426
427        return '<< /Filter /Standard /V 5 /R 6 /Length 256 ' .
428            '/CF << /StdCF << /CFM /AESV3 /AuthEvent /DocOpen /Length 32 >> >> /StmF /StdCF /StrF /StdCF ' .
429            "/O {$hex($dict['O'])} /U {$hex($dict['U'])} /OE {$hex($dict['OE'])} /UE {$hex($dict['UE'])} " .
430            "/P {$dict['P']} /Perms {$hex($dict['Perms'])} >>";
431    }
432
433    /**
434     * Build a per-object string-encryptor closure for the current
435     * document's encryption settings, or null if the document isn't
436     * encrypted. Shared by every literal-string encryption call site
437     * (annotations, form fields, embedded fonts) - each needs its own
438     * closure bound to its own object index, since AES-128's per-object
439     * key derivation depends on it.
440     *
441     * @param  ?string $fileKey
442     * @param  ?string $algorithm
443     * @param  int     $objectIndex
444     * @return ?callable
445     */
446    private function stringEncryptor(?string $fileKey, ?string $algorithm, int $objectIndex): ?callable
447    {
448        if ($fileKey === null) {
449            return null;
450        }
451
452        return function (string $data) use ($algorithm, $fileKey, $objectIndex): string {
453            return ($algorithm === Document\Security::AES_128)
454                ? PdfSecurity\ObjectCipher::encryptAes128($fileKey, $objectIndex, 0, $data)
455                : PdfSecurity\ObjectCipher::encryptAes256($fileKey, $data);
456        };
457    }
458
459    /**
460     * Build the on/off appearance-stream XObjects for a checkbox or radio
461     * widget and return the pieces Button::getStream() needs to reference
462     * them - does not set 'checked', callers fill that in themselves since
463     * a radio group's checked state depends on its sibling widgets too.
464     *
465     * The export name is resolved entirely by the caller and passed in here
466     * rather than derived internally - a solo checkbox/radio and a group of
467     * siblings need different fallback rules when no HTML value was set (see
468     * prepareSingleField() and prepareRadioGroup()), and deriving the same
469     * fallback here for every caller previously made every valueless option
470     * in a radio group collapse onto the identical on-state name.
471     *
472     * @param  Button $field
473     * @param  float  $width
474     * @param  float  $height
475     * @param  string $exportName
476     * @return array
477     */
478    private function createCheckableAppearance(Button $field, float $width, float $height, string $exportName): array
479    {
480        // Once a widget has an explicit /AP appearance stream, most viewers
481        // treat it as authoritative and stop drawing /MK's border/background
482        // for that widget entirely - /MK becomes decoration-only metadata a
483        // conformant reader MAY use when synthesizing its own appearance,
484        // which it no longer needs to do once /AP exists. Both states must
485        // draw the border/background themselves, not just the "on" content,
486        // since either one may be the widget's currently-displayed state.
487        $decoration = $this->appearanceDecorationStream($field, $width, $height);
488        $onContent  = $decoration . (($field->isRadio())
489            ? $this->radioDotAppearanceStream($width, $height)
490            : $this->checkMarkAppearanceStream($width, $height));
491
492        $onIndex = $this->lastIndex() + 1;
493        $this->addObject($onIndex, $this->buildAppearanceXObject($onIndex, $width, $height, $onContent));
494
495        $offIndex = $this->lastIndex() + 1;
496        $this->addObject($offIndex, $this->buildAppearanceXObject($offIndex, $width, $height, $decoration));
497
498        return [
499            'onName' => $exportName,
500            'onRef'  => "{$onIndex} 0 R",
501            'offRef' => "{$offIndex} 0 R",
502        ];
503    }
504
505    /**
506     * Content stream fragment that draws a field's own border/background
507     * (the same /BC/BG/BS values getAppearanceCharacteristics()/
508     * getBorderStyle() would otherwise declare via /MK) directly into an
509     * appearance stream - needed because /MK is only ever honored by a
510     * reader synthesizing its OWN appearance, which it stops doing the
511     * moment an explicit /AP exists for that widget.
512     *
513     * @param  Button $field
514     * @param  float  $width
515     * @param  float  $height
516     * @return string
517     */
518    private function appearanceDecorationStream(Button $field, float $width, float $height): string
519    {
520        $content = '';
521
522        if ($field->getBackgroundColor() !== null) {
523            $bg = $field->getBackgroundColor();
524            $content .= sprintf(
525                "%.3F %.3F %.3F rg\n0 0 %.2F %.2F re\nf\n",
526                $bg[0] / 255, $bg[1] / 255, $bg[2] / 255, $width, $height
527            );
528        }
529
530        if ($field->getBorderWidth() > 0) {
531            $bw     = $field->getBorderWidth();
532            $bc     = $field->getBorderColor() ?? [0, 0, 0];
533            $inset  = $bw / 2;
534            $content .= sprintf(
535                "%.3F %.3F %.3F RG\n%.2F w\n%.2F %.2F %.2F %.2F re\nS\n",
536                $bc[0] / 255, $bc[1] / 255, $bc[2] / 255, $bw, $inset, $inset, $width - $bw, $height - $bw
537            );
538        }
539
540        return $content;
541    }
542
543    /**
544     * Build a static appearance-stream XObject that draws a push button's
545     * caption text, and return its object reference for Button::getStream()
546     * to place into /AP /N. Unlike a checkbox/radio's on/off appearance, a
547     * push button has one fixed appearance and needs its own /Font resource
548     * (reusing the same font object the rest of the document already
549     * embeds, by name and reference) so its Tj operator has something to
550     * draw with - relying on /MK /CA alone is not reliably synthesized into
551     * visible text by most viewers.
552     *
553     * @param  Button $field
554     * @param  string $fontReference
555     * @param  float  $width
556     * @param  float  $height
557     * @return string
558     */
559    private function createPushButtonAppearance(Button $field, string $fontReference, float $width, float $height): string
560    {
561        $resourceName  = substr($fontReference, 0, strpos($fontReference, ' '));
562        $fontObjectRef = substr($fontReference, strpos($fontReference, ' ') + 1);
563        $caption       = (string) $field->getCaption();
564        $size          = $field->getSize();
565        $fontName      = $field->getFont();
566
567        $textWidth = (($fontName !== null) && isset($this->fonts[$fontName]))
568            ? (float) $this->fonts[$fontName]->getStringWidth($caption, $size)
569            : (strlen($caption) * $size * 0.5);
570
571        $tx = max(2.0, ($width - $textWidth) / 2);
572        $ty = max(2.0, ($height - $size) / 2);
573
574        $content = $this->appearanceDecorationStream($field, $width, $height) .
575            "BT\n{$resourceName} {$size} Tf\n0 g\n" . round($tx, 2) . " " . round($ty, 2) .
576            " Td\n(" . Text::escape($caption) . ") Tj\nET\n";
577
578        $i      = $this->lastIndex() + 1;
579        $length = strlen($content);
580        $stream = "{$i} 0 obj\n<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 {$width} {$height}" .
581            "/Resources << /Font << {$resourceName} {$fontObjectRef} >> >> /Length {$length} >>\nstream\n{$content}\nendstream\nendobj\n\n";
582        $this->addObject($i, PdfObject\StreamObject::parse($stream));
583
584        return "{$i} 0 R";
585    }
586
587    /**
588     * Sanitize an arbitrary HTML checkbox/radio value into a valid bare PDF
589     * name token (letters, digits, underscore only)
590     *
591     * @param  string $value
592     * @return string
593     */
594    private function sanitizeExportName(string $value): string
595    {
596        $sanitized = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $value);
597        return ($sanitized === '') ? 'Yes' : $sanitized;
598    }
599
600    /**
601     * Content stream for a checked checkbox's "on" appearance: a simple
602     * filled square inset within the widget's own box
603     *
604     * @param  float $width
605     * @param  float $height
606     * @return string
607     */
608    private function checkMarkAppearanceStream(float $width, float $height): string
609    {
610        $inset = min($width, $height) * 0.25;
611        $w     = $width - (2 * $inset);
612        $h     = $height - (2 * $inset);
613
614        return sprintf("0 g\n%.2F %.2F %.2F %.2F re\nf\n", $inset, $inset, $w, $h);
615    }
616
617    /**
618     * Content stream for a selected radio button's "on" appearance: a
619     * filled circle (4-Bezier approximation, kappa = 0.5523) inset within
620     * the widget's own box
621     *
622     * @param  float $width
623     * @param  float $height
624     * @return string
625     */
626    private function radioDotAppearanceStream(float $width, float $height): string
627    {
628        $cx = $width / 2;
629        $cy = $height / 2;
630        $r  = min($width, $height) * 0.3;
631        $k  = $r * 0.5523;
632
633        $stream  = sprintf("0 g\n%.2F %.2F m\n", $cx + $r, $cy);
634        $stream .= sprintf("%.2F %.2F %.2F %.2F %.2F %.2F c\n", $cx + $r, $cy + $k, $cx + $k, $cy + $r, $cx, $cy + $r);
635        $stream .= sprintf("%.2F %.2F %.2F %.2F %.2F %.2F c\n", $cx - $k, $cy + $r, $cx - $r, $cy + $k, $cx - $r, $cy);
636        $stream .= sprintf("%.2F %.2F %.2F %.2F %.2F %.2F c\n", $cx - $r, $cy - $k, $cx - $k, $cy - $r, $cx, $cy - $r);
637        $stream .= sprintf("%.2F %.2F %.2F %.2F %.2F %.2F c\n", $cx + $k, $cy - $r, $cx + $r, $cy - $k, $cx + $r, $cy);
638        $stream .= "f\n";
639
640        return $stream;
641    }
642
643    /**
644     * Wrap a content stream fragment into a standalone Form XObject PDF object
645     *
646     * @param  int    $i
647     * @param  float  $width
648     * @param  float  $height
649     * @param  string $content
650     * @return PdfObject\StreamObject
651     */
652    private function buildAppearanceXObject(int $i, float $width, float $height, string $content): PdfObject\StreamObject
653    {
654        $length = strlen($content);
655        $stream = "{$i} 0 obj\n<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 {$width} {$height}] /Length {$length} >>\nstream\n{$content}\nendstream\nendobj\n\n";
656        return PdfObject\StreamObject::parse($stream);
657    }
658
659    /**
660     * Encrypt an embedded CID font's /CIDSystemInfo strings (the CID font
661     * dictionary's /Registry /Ordering pair) to match /StrF /StdCF.
662     *
663     * Build\Font\Parser builds these objects inside Document::embedFont(),
664     * long before this method's caller (finalize()) knows encryption is even
665     * configured - unlike annotations/fields, there is no live callback-hook
666     * opportunity here. The value is always one of a small set of fixed
667     * constants ("Adobe"/"Identity" for the CID font dictionary), so a
668     * targeted find-and-replace over the already-built object definition
669     * text - using that object's own index for per-object key derivation -
670     * is sufficient and avoids retrofitting Font\Parser's already-eager
671     * construction path.
672     *
673     * The ToUnicode CMap stream's own copy of /CIDSystemInfo
674     * ("Adobe"/"UCS") is NOT handled here: StreamObject::parse() splits that
675     * object into a bare "<</Length N>>" definition and a separate $stream
676     * holding the actual CMap program text (including that /CIDSystemInfo),
677     * so it never appears in getDefinition() here - it is already correctly
678     * encrypted as opaque stream bytes by the per-object stream-encryption
679     * pass above, and decrypts back to valid plaintext on read.
680     *
681     * @param  ?string $fileKey
682     * @param  ?string $algorithm
683     * @return void
684     */
685    private function encryptEmbeddedFontStrings(?string $fileKey, ?string $algorithm): void
686    {
687        if ($fileKey === null) {
688            return;
689        }
690
691        $pattern = '/\/CIDSystemInfo\s*<<\s*\/Registry\s*\(([^)]*)\)\s*\/Ordering\s*\(([^)]*)\)\s*\/Supplement\s+(\d+)\s*>>/';
692
693        foreach ($this->objects as $object) {
694            if (!($object instanceof PdfObject\StreamObject)) {
695                continue;
696            }
697
698            $definition = (string)$object->getDefinition();
699            if (preg_match($pattern, $definition) !== 1) {
700                continue;
701            }
702
703            $encryptor = $this->stringEncryptor($fileKey, $algorithm, $object->getIndex());
704
705            // preg_replace_callback(), not preg_replace(), is required here:
706            // the encrypted+escaped values below are essentially random
707            // ciphertext bytes, which can coincidentally contain a literal
708            // "$1"/"$2"-looking (or "\1"/"\2") sequence. preg_replace()
709            // treats its $replacement argument as a backreference template
710            // and would silently substitute (or blank out) such a sequence,
711            // corrupting the ciphertext. A callback's return value is used
712            // verbatim, with no backreference interpretation.
713            $object->setDefinition(preg_replace_callback(
714                $pattern,
715                function (array $matches) use ($encryptor): string {
716                    $registry = Text::escape($encryptor($matches[1]));
717                    $ordering = Text::escape($encryptor($matches[2]));
718                    return "/CIDSystemInfo <</Registry ({$registry}) /Ordering ({$ordering}) /Supplement {$matches[3]}>>";
719                },
720                $definition,
721                1
722            ) ?? $definition);
723        }
724    }
725
726    /**
727     * Prepare the font objects
728     *
729     * @throws Exception|Font\Exception
730     * @return void
731     */
732    public function prepareFonts(): void
733    {
734        foreach ($this->fonts as $font) {
735            if ($font instanceof \Pop\Pdf\Document\Font) {
736                $f = count($this->fontReferences) + 1;
737                $i = $this->lastIndex() + 1;
738
739                if ($font->isStandard()) {
740                    $this->fontReferences[$font->getName()] = '/MF' . $f . ' ' . $i . ' 0 R';
741                    $this->addObject($i, PdfObject\StreamObject::parse(
742                        "{$i} 0 obj\n<<\n    /Type /Font\n    /Subtype /Type1\n    /Name /MF{$f}\n    /BaseFont /" .
743                        $font->getName() . "\n    /Encoding /WinAnsiEncoding\n>>\nendobj\n\n"
744                    ));
745                } else {
746                    $parser = $font->parser()
747                        ->setCompression($this->compression)
748                        ->setFontIndex($f)
749                        ->setFontObjectIndex($i);
750
751                    if ($font->isCid()) {
752                        $parser->setCidFontObjectIndex($i + 1)
753                            ->setFontDescIndex($i + 2)
754                            ->setFontFileIndex($i + 3)
755                            ->setToUnicodeIndex($i + 4);
756                    } else {
757                        $parser->setFontDescIndex($i + 1)
758                            ->setFontFileIndex($i + 2);
759                    }
760
761                    $parser->parse();
762
763                    $this->fontReferences[$parser->getFontName()] = $parser->getFontReference();
764                    foreach ($parser->getObjects() as $fontObject) {
765                        $this->addObject($fontObject->getIndex(), $fontObject);
766                    }
767                }
768            } else if (is_array($font)) {
769                $this->fontReferences[$font['name']] = $font['ref'];
770            }
771        }
772    }
773
774    /**
775     * Prepare the image objects
776     *
777     * @param  array $images
778     * @param  PdfObject\PageObject $pageObject
779     * @return void
780     */
781    protected function prepareImages(array $images, PdfObject\PageObject $pageObject): void
782    {
783        $imgs = [];
784
785        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
786        $this->addObject($contentObject->getIndex(), $contentObject);
787        $pageObject->addContentIndex($contentObject->getIndex());
788
789        // Page::addImage() always appends to $images with a fresh
790        // auto-incrementing key, so within a single call every $key here is
791        // unique - $imgs (built up only inside this same loop) can never
792        // already hold one.
793        foreach ($images as $key => $image) {
794            $coordinates = $this->getCoordinates($image['x'], $image['y'], $pageObject);
795            $i = $this->lastIndex() + 1;
796            if ($image['image']->isStream()) {
797                $imageParser = Image\Parser::createImageFromStream(
798                    $image['image']->getStream(), (int)round($coordinates['x']), (int)round($coordinates['y']),
799                    $image['image']->getResizeDimensions(), $image['image']->isPreserveResolution()
800                );
801            } else {
802                $imageParser = Image\Parser::createImageFromFile(
803                    $image['image']->getImage(), (int)round($coordinates['x']), (int)round($coordinates['y']),
804                    $image['image']->getResizeDimensions(), $image['image']->isPreserveResolution()
805                );
806            }
807
808            $imageParser->setIndex($i);
809            $contentObject->appendStream($imageParser->getStream());
810            $pageObject->addXObjectReference($imageParser->getXObject());
811            foreach ($imageParser->getObjects() as $oi => $imageObject) {
812                $this->addObject($oi, $imageObject);
813            }
814            $imgs[$key] = $imageParser;
815        }
816    }
817
818    /**
819     * Prepare the path objects
820     *
821     * @param  array $paths
822     * @param  PdfObject\PageObject $pageObject
823     * @return void
824     */
825    protected function preparePaths(array $paths, PdfObject\PageObject $pageObject): void
826    {
827        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
828        $this->addObject($contentObject->getIndex(), $contentObject);
829        $pageObject->addContentIndex($contentObject->getIndex());
830
831        foreach ($paths as $path) {
832            $stream  = null;
833            $streams = $path->getStreams();
834            foreach ($streams as $str) {
835                $s = $str['stream'];
836                if (isset($str['points'])) {
837                    foreach ($str['points'] as $points) {
838                        $keys = array_keys($points);
839                        $coordinates = $this->getCoordinates($points[$keys[0]], $points[$keys[1]], $pageObject);
840                        $s = str_replace(
841                            ['[{' . $keys[0] . '}]', '[{' . $keys[1] . '}]'], [$coordinates['x'], $coordinates['y']], $s
842                        );
843                    }
844                }
845                $stream .= $s;
846            }
847
848            $contentObject->appendStream($stream);
849        }
850    }
851
852    /**
853     * Prepare the text objects
854     *
855     * @param  array $text
856     * @param  PdfObject\PageObject $pageObject
857     * @throws Exception
858     * @return void
859     */
860    protected function prepareText(array $text, PdfObject\PageObject $pageObject): void
861    {
862        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
863        $this->addObject($contentObject->getIndex(), $contentObject);
864        $pageObject->addContentIndex($contentObject->getIndex());
865
866        foreach ($text as $txt) {
867            $styleColor = null;
868
869            if ($this->document->hasStyle($txt['font'])) {
870                $style = $this->document->getStyle($txt['font']);
871                if ($style->hasSize()) {
872                    $txt['text']->setSize($style->getSize());
873                }
874                if ($style->hasFont()) {
875                    $txt['font'] = $style->getFont();
876                }
877                if ($style->hasColor()) {
878                    $styleColor = $style->getColor();
879                    $txt['text']->setFillColor($styleColor);
880                }
881            }
882            if (!isset($this->fontReferences[$txt['font']])) {
883                throw new Exception('Error: The font \'' . $txt['font'] . '\' has not been added to the document.');
884            }
885
886            $fontObject = $this->fonts[$txt['font']] ?? null;
887            if ($fontObject instanceof \Pop\Pdf\Document\Font) {
888                $txt['text']->setFont($fontObject);
889            }
890
891            $coordinates = $this->getCoordinates($txt['x'], $txt['y'], $pageObject);
892            $itemStream  = '';
893
894            // Auto-wrap text by character length
895            if ($txt['text']->hasCharWrap()) {
896                $font        = $this->fontReferences[$txt['font']];
897                $itemStream .= $txt['text']->startStream($font, $coordinates['x'], $coordinates['y']);
898                $itemStream .= $txt['text']->getPartialStream($font);
899                $itemStream .= $txt['text']->endStream();
900            // Left/right/center align text
901            } else if ($txt['text']->hasAlignment()) {
902                $strings = $txt['text']->getAlignment()->getStrings($txt['text'], $fontObject, $coordinates['y']);
903                foreach ($strings as $string) {
904                    $textString = new Text($string['string'], $txt['text']->getSize());
905                    if ($fontObject instanceof \Pop\Pdf\Document\Font) {
906                        $textString->setFont($fontObject);
907                    }
908                    if ($styleColor !== null) {
909                        $textString->setFillColor($styleColor);
910                    }
911                    $itemStream .= $textString->getStream($this->fontReferences[$txt['font']], $string['x'], $string['y']);
912                }
913            // Left/right wrap text around box boundary
914            } else if ($txt['text']->hasWrap()) {
915                $strings     = $txt['text']->getWrap()->getStrings($txt['text'], $fontObject, $coordinates['y']);
916                $colorStream = $txt['text']->getColorStream();
917                if (!empty($colorStream)) {
918                    $itemStream .= $colorStream;
919                }
920                foreach ($strings as $string) {
921                    $textString = new Text($string['string'], $txt['text']->getSize());
922                    if ($fontObject instanceof \Pop\Pdf\Document\Font) {
923                        $textString->setFont($fontObject);
924                    }
925                    $itemStream .= $textString->getStream($this->fontReferences[$txt['font']], $string['x'], $string['y']);
926                }
927            // Else, just append the text stream
928            } else {
929                $itemStream .= $txt['text']->getStream($this->fontReferences[$txt['font']], $coordinates['x'], $coordinates['y']);
930            }
931
932            // A style color is only meant to apply to this one piece of text -
933            // bracket it in its own graphics-state save/restore so it can't
934            // leak forward and tint whatever renders after it, the same way
935            // Path::openLayer()/closeLayer() isolate a path's own state.
936            if ($styleColor !== null) {
937                $itemStream = "\nq\n" . $itemStream . "\nQ\n";
938            }
939
940            $contentObject->appendStream($itemStream);
941        }
942    }
943
944    /**
945     * Prepare the text streams objects
946     *
947     * @param  array $textStreams
948     * @param  PdfObject\PageObject $pageObject
949     * @throws Exception
950     * @return void
951     */
952    protected function prepareTextStreams(array $textStreams, PdfObject\PageObject $pageObject): void
953    {
954        $contentObject = new PdfObject\StreamObject($this->lastIndex() + 1);
955        $this->addObject($contentObject->getIndex(), $contentObject);
956        $pageObject->addContentIndex($contentObject->getIndex());
957
958        foreach ($textStreams as $txt) {
959            $stream = $txt->getStream($this->fonts, $this->fontReferences);
960            $contentObject->appendStream($stream);
961        }
962    }
963
964    /**
965     * Prepare the annotation objects
966     *
967     * @param  array    $annotations
968     * @param  PdfObject\PageObject $pageObject
969     * @param  ?string  $fileKey
970     * @param  ?string  $algorithm
971     * @return void
972     */
973    protected function prepareAnnotations(
974        array $annotations, PdfObject\PageObject $pageObject, ?string $fileKey = null, ?string $algorithm = null
975    ): void
976    {
977        foreach ($annotations as $annotation) {
978            $i = $this->lastIndex() + 1;
979            $pageObject->addAnnotIndex($i);
980
981            $coordinates = $this->getCoordinates($annotation['x'], $annotation['y'], $pageObject);
982            if ($annotation['annotation'] instanceof \Pop\Pdf\Document\Page\Annotation\Url) {
983                if ($fileKey !== null) {
984                    $annotation['annotation']->encryptWith($this->stringEncryptor($fileKey, $algorithm, $i));
985                }
986                $stream = $annotation['annotation']->getStream($i, $coordinates['x'], $coordinates['y']);
987            } else {
988                $targetCoordinates = $this->getCoordinates(
989                    $annotation['annotation']->getXTarget(), $annotation['annotation']->getYTarget(), $pageObject
990                );
991
992                $annotation['annotation']->setXTarget($targetCoordinates['x']);
993                $annotation['annotation']->setYTarget($targetCoordinates['y']);
994                $stream = $annotation['annotation']->getStream(
995                    $i, $coordinates['x'], $coordinates['y'], $pageObject->getIndex(), $this->parent->getKids()
996                );
997            }
998            $this->addObject($i, PdfObject\StreamObject::parse($stream));
999        }
1000    }
1001
1002    /**
1003     * Prepare the field objects
1004     *
1005     * @param  array    $fields
1006     * @param  PdfObject\PageObject $pageObject
1007     * @param  ?string  $fileKey
1008     * @param  ?string  $algorithm
1009     * @throws Exception
1010     * @return void
1011     */
1012    protected function prepareFields(
1013        array $fields, PdfObject\PageObject $pageObject, ?string $fileKey = null, ?string $algorithm = null
1014    ): void
1015    {
1016        $groups = $this->groupRadioFields($fields);
1017
1018        foreach ($groups['grouped'] as $groupFields) {
1019            $this->prepareRadioGroup($groupFields, $pageObject, $fileKey, $algorithm);
1020        }
1021
1022        foreach ($groups['ungrouped'] as $field) {
1023            $this->prepareSingleField($field, $pageObject, $fileKey, $algorithm);
1024        }
1025    }
1026
1027    /**
1028     * Split a page's field entries into radio groups (2+ same-named,
1029     * same-form Button fields with isRadio() true) and everything else
1030     *
1031     * @param  array $fields
1032     * @return array
1033     */
1034    private function groupRadioFields(array $fields): array
1035    {
1036        $byKey = [];
1037        foreach ($fields as $field) {
1038            if (($field['field'] instanceof Button) && $field['field']->isRadio()) {
1039                $key = $field['form'] . '|' . $field['field']->getName();
1040                $byKey[$key][] = $field;
1041            }
1042        }
1043
1044        $grouped     = [];
1045        $groupedKeys = [];
1046        foreach ($byKey as $key => $groupFields) {
1047            if (count($groupFields) >= 2) {
1048                $grouped[$key] = $groupFields;
1049                $groupedKeys[] = $key;
1050            }
1051        }
1052
1053        $ungrouped = array_values(array_filter($fields, function ($field) use ($groupedKeys) {
1054            if (!(($field['field'] instanceof Button) && $field['field']->isRadio())) {
1055                return true;
1056            }
1057            return !in_array($field['form'] . '|' . $field['field']->getName(), $groupedKeys);
1058        }));
1059
1060        return ['grouped' => $grouped, 'ungrouped' => $ungrouped];
1061    }
1062
1063    /**
1064     * Resolve a field's font reference, throwing if it references a font
1065     * never added to the document
1066     *
1067     * @param  Field\AbstractField $field
1068     * @throws Exception
1069     * @return ?string
1070     */
1071    private function resolveFieldFontRef(Field\AbstractField $field): ?string
1072    {
1073        if (($field->getFont() !== null) && (!isset($this->fontReferences[$field->getFont()]))) {
1074            throw new Exception('Error: The font \'' . $field->getFont() . '\' has not been added to the document.');
1075        }
1076
1077        return ($field->getFont() !== null) ? $this->fontReferences[$field->getFont()] : null;
1078    }
1079
1080    /**
1081     * Emit a single, non-grouped field's PDF object (ordinary text/choice
1082     * fields, push buttons, plain checkboxes, and a solitary radio with no
1083     * same-named sibling)
1084     *
1085     * @param  array    $field
1086     * @param  PdfObject\PageObject $pageObject
1087     * @param  ?string  $fileKey
1088     * @param  ?string  $algorithm
1089     * @return void
1090     */
1091    private function prepareSingleField(
1092        array $field, PdfObject\PageObject $pageObject, ?string $fileKey, ?string $algorithm
1093    ): void
1094    {
1095        if ($this->document->getForm($field['form']) === null) {
1096            return;
1097        }
1098
1099        $fontRef = $this->resolveFieldFontRef($field['field']);
1100
1101        // For a checkbox/radio, the on/off appearance XObjects must be
1102        // allocated (and registered via addObject()) BEFORE this field's own
1103        // object index ($i) is computed below - $i is only reserved via
1104        // lastIndex()+1 here and not actually added to $this->objects until
1105        // addObject($i, ...) runs further down, so calling
1106        // createCheckableAppearance() (which also allocates via
1107        // lastIndex()+1) any later would collide with $i and its object
1108        // would silently clobber the field's own widget dict once
1109        // addObject($i, ...) ran.
1110        $appearance = null;
1111        if (($field['field'] instanceof Button) && (!$field['field']->isPushButton())) {
1112            // No sibling can ever collide here (this is the solo-widget
1113            // path), so the simple shared 'Yes' fallback is safe.
1114            $exportName = $this->sanitizeExportName($field['field']->getValue() ?? 'Yes');
1115            $appearance = $this->createCheckableAppearance(
1116                $field['field'], (float) $field['field']->getWidth(), (float) $field['field']->getHeight(), $exportName
1117            );
1118            $appearance['checked'] = $field['field']->isChecked();
1119        }
1120
1121        // Same allocation-ordering requirement as the checkbox/radio case
1122        // above: the caption XObject must be registered via addObject()
1123        // before $i is reserved for the button's own widget object.
1124        $captionRef = null;
1125        if (($field['field'] instanceof Button) && $field['field']->isPushButton() &&
1126            ($field['field']->getCaption() !== null) && ($fontRef !== null)) {
1127            $captionRef = $this->createPushButtonAppearance(
1128                $field['field'], $fontRef, (float) $field['field']->getWidth(), (float) $field['field']->getHeight()
1129            );
1130        }
1131
1132        $i           = $this->lastIndex() + 1;
1133        $pageObject->addAnnotIndex($i);
1134        $coordinates = $this->getCoordinates($field['x'], $field['y'], $pageObject);
1135        $this->document->getForm($field['form'])->addFieldIndex($i);
1136
1137        if ($fileKey !== null) {
1138            $field['field']->encryptWith($this->stringEncryptor($fileKey, $algorithm, $i));
1139        }
1140
1141        $stream = ($appearance !== null)
1142            ? $field['field']->getStream($i, $pageObject->getIndex(), $fontRef, $coordinates['x'], $coordinates['y'], $appearance)
1143            : $field['field']->getStream($i, $pageObject->getIndex(), $fontRef, $coordinates['x'], $coordinates['y'], null, null, $captionRef);
1144
1145        $this->addObject($i, PdfObject\StreamObject::parse($stream));
1146    }
1147
1148    /**
1149     * Emit a shared, non-visual parent field object plus one child widget
1150     * per radio option
1151     *
1152     * @param  array    $groupFields
1153     * @param  PdfObject\PageObject $pageObject
1154     * @param  ?string  $fileKey
1155     * @param  ?string  $algorithm
1156     * @return void
1157     */
1158    private function prepareRadioGroup(
1159        array $groupFields, PdfObject\PageObject $pageObject, ?string $fileKey, ?string $algorithm
1160    ): void
1161    {
1162        $formName = $groupFields[0]['form'];
1163        if ($this->document->getForm($formName) === null) {
1164            return;
1165        }
1166
1167        $representative = $groupFields[0]['field'];
1168
1169        // Resolve every kid's export name once, up front, using a
1170        // per-index fallback ('Option1', 'Option2', ...) so that
1171        // valueless siblings never collide on the same shared fallback
1172        // name the way a single shared 'Yes' fallback would - otherwise
1173        // every valueless option in the group would resolve to the same
1174        // on-state name and the parent's /V would match (and therefore
1175        // visually check) all of them at once.
1176        $exportNames = [];
1177        foreach ($groupFields as $index => $field) {
1178            $kid                  = $field['field'];
1179            $exportNames[$index] = $this->sanitizeExportName($kid->getValue() ?? ('Option' . ($index + 1)));
1180        }
1181
1182        // sanitizeExportName() is many-to-one (e.g. '9-5' and '9/5' both
1183        // sanitize to '9_5'), so two genuinely different HTML values can
1184        // still collide onto one export name within the same group,
1185        // reproducing the same "multiple options checked" symptom the
1186        // per-index fallback above already fixed for the valueless case.
1187        // Disambiguate here, before $exportNames is used for anything else:
1188        // the second kid to resolve to a given name gets "_2" appended, the
1189        // third "_3", and so on.
1190        $seenCounts = [];
1191        foreach ($exportNames as $index => $name) {
1192            if (!isset($seenCounts[$name])) {
1193                $seenCounts[$name] = 1;
1194            } else {
1195                $seenCounts[$name]++;
1196                $exportNames[$index] = $name . '_' . $seenCounts[$name];
1197            }
1198        }
1199
1200        $checkedValue = null;
1201        foreach ($groupFields as $index => $field) {
1202            if ($field['field']->isChecked()) {
1203                $checkedValue = $exportNames[$index];
1204            }
1205        }
1206
1207        $parentIndex = $this->lastIndex() + 1;
1208        if ($fileKey !== null) {
1209            $representative->encryptWith($this->stringEncryptor($fileKey, $algorithm, $parentIndex));
1210        }
1211        $this->addObject($parentIndex, PdfObject\StreamObject::parse(
1212            $representative->getParentFieldStream($parentIndex, $checkedValue)
1213        ));
1214        $this->document->getForm($formName)->addFieldIndex($parentIndex);
1215
1216        foreach ($groupFields as $index => $field) {
1217            $kid = $field['field'];
1218
1219            // Same ordering fix as prepareSingleField(): the kid's "on"
1220            // appearance XObject must be allocated and registered via
1221            // addObject() BEFORE the kid's own widget object index ($i) is
1222            // computed, or the kid's own widget dict would silently clobber
1223            // the appearance XObject once both reused the same reserved
1224            // lastIndex()+1 number.
1225            $appearance = $this->createCheckableAppearance($kid, (float) $kid->getWidth(), (float) $kid->getHeight(), $exportNames[$index]);
1226            $appearance['checked'] = ($checkedValue !== null) && ($exportNames[$index] === $checkedValue);
1227
1228            $i           = $this->lastIndex() + 1;
1229            $pageObject->addAnnotIndex($i);
1230            $coordinates = $this->getCoordinates($field['x'], $field['y'], $pageObject);
1231
1232            if ($fileKey !== null) {
1233                $kid->encryptWith($this->stringEncryptor($fileKey, $algorithm, $i));
1234            }
1235
1236            $fontRef = $this->resolveFieldFontRef($kid);
1237            $stream  = $kid->getStream($i, $pageObject->getIndex(), $fontRef, $coordinates['x'], $coordinates['y'], $appearance, $parentIndex);
1238            $this->addObject($i, PdfObject\StreamObject::parse($stream));
1239        }
1240    }
1241
1242    /**
1243     * Prepare the form objects
1244     *
1245     * @return void
1246     */
1247    protected function prepareForms(): void
1248    {
1249        // Per spec, a document's Catalog may only have a single /AcroForm
1250        // entry, and its value must be a single form dictionary (not an
1251        // array of them) - so every named Document\Form's field indices are
1252        // combined into one dictionary object here, rather than compiling
1253        // each Form into its own object and referencing them all as a list.
1254        $fieldIndices = [];
1255        foreach ($this->document->getForms() as $form) {
1256            $fieldIndices = array_merge($fieldIndices, $form->getFieldIndices());
1257        }
1258
1259        $fields = implode(' ', array_map(fn($index) => $index . ' 0 R', $fieldIndices));
1260
1261        $i = $this->lastIndex() + 1;
1262        $this->addObject($i, PdfObject\StreamObject::parse("{$i} 0 obj\n<</Fields[{$fields}]>>\nendobj\n\n"));
1263        $this->root->setFormReferences($i . ' 0 R');
1264    }
1265
1266}