Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.97% |
453 / 477 |
|
66.67% |
16 / 24 |
CRAP | |
0.00% |
0 / 1 |
| Compiler | |
94.97% |
453 / 477 |
|
66.67% |
16 / 24 |
164.30 | |
0.00% |
0 / 1 |
| setDocument | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
13 | |||
| finalize | |
100.00% |
114 / 114 |
|
100.00% |
1 / 1 |
47 | |||
| buildEncryptDictBody | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| stringEncryptor | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| createCheckableAppearance | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| appearanceDecorationStream | |
25.00% |
4 / 16 |
|
0.00% |
0 / 1 |
6.80 | |||
| createPushButtonAppearance | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
3.00 | |||
| sanitizeExportName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| checkMarkAppearanceStream | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| radioDotAppearanceStream | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| buildAppearanceXObject | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| encryptEmbeddedFontStrings | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
5 | |||
| prepareFonts | |
85.19% |
23 / 27 |
|
0.00% |
0 / 1 |
7.16 | |||
| prepareImages | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
4 | |||
| preparePaths | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
| prepareText | |
97.96% |
48 / 49 |
|
0.00% |
0 / 1 |
18 | |||
| prepareTextStreams | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| prepareAnnotations | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| prepareFields | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| groupRadioFields | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
8 | |||
| resolveFieldFontRef | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| prepareSingleField | |
96.15% |
25 / 26 |
|
0.00% |
0 / 1 |
10 | |||
| prepareRadioGroup | |
91.89% |
34 / 37 |
|
0.00% |
0 / 1 |
11.06 | |||
| prepareForms | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace Pop\Pdf\Build; |
| 16 | |
| 17 | use Pop\Pdf\Document; |
| 18 | use Pop\Pdf\Document\Page\Text; |
| 19 | use Pop\Pdf\Document\Page\Field; |
| 20 | use Pop\Pdf\Document\Page\Field\Button; |
| 21 | use 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 | */ |
| 33 | class 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 | } |