Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.97% covered (warning)
89.97%
287 / 319
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfStamper
89.97% covered (warning)
89.97%
287 / 319
85.71% covered (warning)
85.71%
18 / 21
91.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 openString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stampText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 watermark
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addPageNumbers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 footer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stampImage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 stampPdf
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
85.99% covered (warning)
85.99%
135 / 157
0.00% covered (danger)
0.00%
0 / 1
45.63
 getVersionWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildTextOps
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 buildWatermarkOps
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 escapeText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildXObjectOps
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 registerImageXObject
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 registerPdfPageXObject
65.38% covered (warning)
65.38%
17 / 26
0.00% covered (danger)
0.00%
0 / 1
14.15
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Encoding\WinAnsiEncoder;
8use Phpdftk\ImageMetadata\ImageParser;
9use Phpdftk\Pdf\Core\Content\ContentStream;
10use Phpdftk\Pdf\Core\Content\Resources;
11use Phpdftk\Pdf\Core\File\IncrementalWriter;
12use Phpdftk\Filesystem\LocalFilesystem;
13use Phpdftk\Pdf\Core\Font\StandardFont;
14use Phpdftk\Pdf\Core\Font\Type1Font;
15use Phpdftk\Pdf\Core\Graphics\ExtGState;
16use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
17use Phpdftk\Pdf\Core\PdfArray;
18use Phpdftk\Pdf\Core\PdfDictionary;
19use Phpdftk\Pdf\Core\PdfName;
20use Phpdftk\Pdf\Core\PdfNumber;
21use Phpdftk\Pdf\Core\PdfObject;
22use Phpdftk\Pdf\Core\PdfReference;
23use Phpdftk\Pdf\Core\PdfStream;
24use Phpdftk\Pdf\Reader\PdfReader;
25use Phpdftk\Pdf\Toolkit\Internal\PageResolver;
26use Phpdftk\Pdf\Toolkit\Stamper\ImageStampStyle;
27use Phpdftk\Pdf\Toolkit\Stamper\StampPosition;
28use Phpdftk\Pdf\Toolkit\Stamper\StampStyle;
29use Phpdftk\Pdf\Toolkit\Stamper\WatermarkStyle;
30
31/**
32 * Add text overlays, watermarks, page numbers, headers and footers to PDFs.
33 *
34 * Usage:
35 *   PdfStamper::open('report.pdf')
36 *       ->watermark('DRAFT')
37 *       ->addPageNumbers(StampPosition::BottomCenter)
38 *       ->save('stamped.pdf');
39 *
40 * @api
41 */
42final class PdfStamper
43{
44    private string $originalBytes;
45
46    /** @var list<array{type: string, args: array}> */
47    private array $operations = [];
48
49    /** @var list<string> */
50    private array $lastVersionWarnings = [];
51
52    private function __construct(
53        private readonly PdfReader $reader,
54        string $originalBytes,
55    ) {
56        $this->originalBytes = $originalBytes;
57    }
58
59    public static function open(string $path, string $password = ''): self
60    {
61        $bytes = LocalFilesystem::readFile($path);
62        return new self(PdfReader::fromString($bytes, $password), $bytes);
63    }
64
65    public static function openString(string $pdfBytes, string $password = ''): self
66    {
67        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
68    }
69
70    // -----------------------------------------------------------------------
71    // Stamp operations
72    // -----------------------------------------------------------------------
73
74    public function stampText(
75        string $text,
76        StampPosition $position,
77        ?PageSelector $pages = null,
78        ?StampStyle $style = null,
79    ): self {
80        $this->operations[] = ['type' => 'text', 'args' => compact('text', 'position', 'pages', 'style')];
81        return $this;
82    }
83
84    public function watermark(
85        string $text,
86        ?PageSelector $pages = null,
87        ?WatermarkStyle $style = null,
88    ): self {
89        $this->operations[] = ['type' => 'watermark', 'args' => compact('text', 'pages', 'style')];
90        return $this;
91    }
92
93    public function addPageNumbers(
94        StampPosition $position = StampPosition::BottomCenter,
95        string $format = 'Page {n} of {total}',
96        ?StampStyle $style = null,
97        ?PageSelector $pages = null,
98    ): self {
99        $this->operations[] = ['type' => 'pageNumbers', 'args' => compact('position', 'format', 'style', 'pages')];
100        return $this;
101    }
102
103    public function header(string $text, ?StampStyle $style = null, ?PageSelector $pages = null): self
104    {
105        return $this->stampText($text, StampPosition::TopCenter, $pages, $style);
106    }
107
108    public function footer(string $text, ?StampStyle $style = null, ?PageSelector $pages = null): self
109    {
110        return $this->stampText($text, StampPosition::BottomCenter, $pages, $style);
111    }
112
113    /**
114     * Overlay a JPEG or PNG image at a given position on selected pages.
115     *
116     * Dimensions default to the image's native pixel size (at 72 DPI).
117     * Set width or height in the style to scale; setting one preserves
118     * the aspect ratio. Set both to stretch.
119     */
120    public function stampImage(
121        string $imagePath,
122        StampPosition $position,
123        ?PageSelector $pages = null,
124        ?ImageStampStyle $style = null,
125    ): self {
126        if (!is_file($imagePath)) {
127            throw new \RuntimeException("Image file not found: $imagePath");
128        }
129        $this->operations[] = ['type' => 'image', 'args' => compact('imagePath', 'position', 'pages', 'style')];
130        return $this;
131    }
132
133    /**
134     * Overlay a page from another PDF at a given position on selected pages.
135     *
136     * The source page is imported as a Form XObject. Dimensions default to
137     * the source page's MediaBox size. Use width/height in the style to scale.
138     *
139     * @param string $pdfPath   Path to the source PDF file
140     * @param int    $pageIndex 0-based page index in the source PDF
141     */
142    public function stampPdf(
143        string $pdfPath,
144        int $pageIndex = 0,
145        ?StampPosition $position = null,
146        ?PageSelector $pages = null,
147        ?ImageStampStyle $style = null,
148    ): self {
149        if (!is_file($pdfPath)) {
150            throw new \RuntimeException("PDF file not found: $pdfPath");
151        }
152        $sourceReader = PdfReader::fromFile($pdfPath);
153        if ($pageIndex < 0 || $pageIndex >= $sourceReader->getPageCount()) {
154            throw new \InvalidArgumentException(sprintf(
155                'Page index %d out of range (source has %d pages)',
156                $pageIndex,
157                $sourceReader->getPageCount(),
158            ));
159        }
160        $position ??= StampPosition::Center;
161        $this->operations[] = ['type' => 'pdf', 'args' => compact('sourceReader', 'pageIndex', 'position', 'pages', 'style')];
162        return $this;
163    }
164
165    // -----------------------------------------------------------------------
166    // Output
167    // -----------------------------------------------------------------------
168
169    public function save(string $path): void
170    {
171        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
172    }
173
174    public function toBytes(): string
175    {
176        if (empty($this->operations)) {
177            return $this->originalBytes;
178        }
179
180        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
181        $pageRefs = PageResolver::getPageReferences($this->reader);
182        $totalPages = count($pageRefs);
183
184        // Register a standard font for text stamps (only when needed)
185        $fontRef = null;
186        $fontName = 'StF1';
187        $needsFont = false;
188        foreach ($this->operations as $op) {
189            if (in_array($op['type'], ['text', 'watermark', 'pageNumbers'], true)) {
190                $needsFont = true;
191                break;
192            }
193        }
194        if ($needsFont) {
195            $font = new Type1Font(StandardFont::Helvetica);
196            $fontRef = $writer->addNewObject($font);
197        }
198
199        // Pre-create shared ExtGState for opacity if needed
200        $gsRefs = [];
201
202        // Pre-register XObject resources that are shared across pages
203        $xObjectCounter = 0;
204        /** @var array<string, PdfReference> $xObjectRefs  xoName => ref */
205        $xObjectRefs = [];
206
207        // Pre-process image and PDF operations to register XObjects once
208        foreach ($this->operations as $idx => $op) {
209            if ($op['type'] === 'image') {
210                $xObjectCounter++;
211                $xoName = 'StXo' . $xObjectCounter;
212                $xoRef = $this->registerImageXObject($writer, $op['args']['imagePath']);
213                $xObjectRefs[$xoName] = $xoRef;
214                $this->operations[$idx]['xoName'] = $xoName;
215
216                $info = ImageParser::parse($op['args']['imagePath']);
217                $this->operations[$idx]['sourceWidth'] = (float) $info->width;
218                $this->operations[$idx]['sourceHeight'] = (float) $info->height;
219            } elseif ($op['type'] === 'pdf') {
220                $xObjectCounter++;
221                $xoName = 'StXo' . $xObjectCounter;
222                $sourceReader = $op['args']['sourceReader'];
223                $pageIndex = $op['args']['pageIndex'];
224                $sourcePageDict = $sourceReader->getPage($pageIndex);
225                $sourceDims = PageResolver::getPageDimensions($sourcePageDict, $sourceReader);
226
227                $xoRef = $this->registerPdfPageXObject($writer, $sourceReader, $pageIndex, $sourceDims);
228                $xObjectRefs[$xoName] = $xoRef;
229                $this->operations[$idx]['xoName'] = $xoName;
230                $this->operations[$idx]['sourceWidth'] = $sourceDims['width'];
231                $this->operations[$idx]['sourceHeight'] = $sourceDims['height'];
232            }
233        }
234
235        // Collect stamp content per page
236        /** @var array<int, list<string>> $pageOps  0-indexed page => list of operator strings */
237        $pageOps = [];
238        /** @var array<int, array<string, PdfReference>> $pageExtGState */
239        $pageExtGState = [];
240        /** @var array<int, array<string, PdfReference>> $pageXObjects */
241        $pageXObjects = [];
242
243        foreach ($this->operations as $op) {
244            for ($i = 0; $i < $totalPages; $i++) {
245                $pageNum = $i + 1;
246                $selector = $op['args']['pages'] ?? null;
247                if ($selector !== null && !$selector->matches($pageNum, $totalPages)) {
248                    continue;
249                }
250
251                $pageDict = $this->reader->getPage($i);
252                $dims = PageResolver::getPageDimensions($pageDict, $this->reader);
253
254                $ops = match ($op['type']) {
255                    'text' => $this->buildTextOps(
256                        $op['args']['text'],
257                        $op['args']['position'],
258                        $op['args']['style'] ?? new StampStyle(),
259                        $dims,
260                        $fontName,
261                    ),
262                    'watermark' => $this->buildWatermarkOps(
263                        $op['args']['text'],
264                        $op['args']['style'] ?? new WatermarkStyle(),
265                        $dims,
266                        $fontName,
267                    ),
268                    'pageNumbers' => $this->buildTextOps(
269                        str_replace(['{n}', '{total}'], [(string) $pageNum, (string) $totalPages], $op['args']['format']),
270                        $op['args']['position'],
271                        $op['args']['style'] ?? new StampStyle(fontSize: 10.0),
272                        $dims,
273                        $fontName,
274                    ),
275                    'image', 'pdf' => $this->buildXObjectOps(
276                        $op['xoName'],
277                        $op['args']['position'],
278                        $op['args']['style'] ?? new ImageStampStyle(),
279                        $dims,
280                        $op['sourceWidth'],
281                        $op['sourceHeight'],
282                    ),
283                    default => [],
284                };
285
286                if (!empty($ops)) {
287                    $pageOps[$i] = array_merge($pageOps[$i] ?? [], $ops['operators']);
288                    if (isset($ops['extGState'])) {
289                        foreach ($ops['extGState'] as $gsName => $opacity) {
290                            if (!isset($gsRefs[$gsName])) {
291                                $gs = new ExtGState();
292                                $gs->ca = $opacity;
293                                $gs->caLower = $opacity;
294                                $gsRefs[$gsName] = $writer->addNewObject($gs);
295                            }
296                            $pageExtGState[$i][$gsName] = $gsRefs[$gsName];
297                        }
298                    }
299                    if (isset($ops['xObjects'])) {
300                        foreach ($ops['xObjects'] as $xoName) {
301                            $pageXObjects[$i][$xoName] = $xObjectRefs[$xoName];
302                        }
303                    }
304                }
305            }
306        }
307
308        // For each page with stamps, create content stream and modify page
309        foreach ($pageOps as $pageIdx => $operators) {
310            $cs = new ContentStream();
311            $cs->raw(implode("\n", $operators));
312
313            // Build resources for this content stream
314            $resources = new Resources();
315            if ($fontRef !== null) {
316                $resources->addFont($fontName, $fontRef);
317            }
318            foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) {
319                $resources->addExtGState($gsName, $gsRef);
320            }
321            foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) {
322                $resources->addXObject($xoName, $xoRef);
323            }
324
325            $csRef = $writer->addNewObject($cs);
326
327            // Modify the page to include the new content stream
328            $pageDict = $this->reader->getPage($pageIdx);
329            $existingContents = $pageDict->get('Contents');
330            $contentsArray = [];
331            if ($existingContents instanceof PdfReference) {
332                $contentsArray[] = $existingContents;
333            } elseif ($existingContents instanceof PdfArray) {
334                $contentsArray = $existingContents->items;
335            }
336            $contentsArray[] = $csRef;
337
338            $pageDict->set('Contents', new PdfArray($contentsArray));
339
340            // Merge resources: add font, extgstate, xobjects to existing page resources
341            $existingRes = $pageDict->get('Resources');
342            if ($existingRes instanceof PdfDictionary) {
343                // Add font
344                if ($fontRef !== null) {
345                    $fontDict = $existingRes->get('Font');
346                    if ($fontDict instanceof PdfDictionary) {
347                        $fontDict->set($fontName, $fontRef);
348                    } else {
349                        $existingRes->set('Font', (new PdfDictionary())->set($fontName, $fontRef));
350                    }
351                }
352                // Add ExtGState
353                foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) {
354                    $gsDict = $existingRes->get('ExtGState');
355                    if ($gsDict instanceof PdfDictionary) {
356                        $gsDict->set($gsName, $gsRef);
357                    } else {
358                        $existingRes->set('ExtGState', (new PdfDictionary())->set($gsName, $gsRef));
359                    }
360                }
361                // Add XObject
362                foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) {
363                    $xoDict = $existingRes->get('XObject');
364                    if ($xoDict instanceof PdfDictionary) {
365                        $xoDict->set($xoName, $xoRef);
366                    } else {
367                        $existingRes->set('XObject', (new PdfDictionary())->set($xoName, $xoRef));
368                    }
369                }
370            } else {
371                // No existing resources dict â€” build inline resource dict
372                $resDict = new PdfDictionary();
373                if ($fontRef !== null) {
374                    $resDict->set('Font', (new PdfDictionary())->set($fontName, $fontRef));
375                }
376                foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) {
377                    $gsDictEntry = $resDict->get('ExtGState');
378                    if (!$gsDictEntry instanceof PdfDictionary) {
379                        $gsDictEntry = new PdfDictionary();
380                        $resDict->set('ExtGState', $gsDictEntry);
381                    }
382                    $gsDictEntry->set($gsName, $gsRef);
383                }
384                foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) {
385                    $xoDictEntry = $resDict->get('XObject');
386                    if (!$xoDictEntry instanceof PdfDictionary) {
387                        $xoDictEntry = new PdfDictionary();
388                        $resDict->set('XObject', $xoDictEntry);
389                    }
390                    $xoDictEntry->set($xoName, $xoRef);
391                }
392                $pageDict->set('Resources', $resDict);
393            }
394
395            // Create a PdfObject wrapper for the modified page
396            $pageObj = new class ($pageDict) extends PdfObject {
397                public function __construct(private readonly PdfDictionary $dict) {}
398                public function toPdf(): string
399                {
400                    return $this->dict->toPdf();
401                }
402            };
403            $pageObj->objectNumber = $pageRefs[$pageIdx]->objectNumber;
404            $pageObj->generationNumber = 0;
405            $writer->addModifiedObject($pageObj);
406        }
407
408        $result = $writer->generate();
409        $this->lastVersionWarnings = $writer->getVersionWarnings();
410        return $result;
411    }
412
413    // -----------------------------------------------------------------------
414    // Escape hatches
415    // -----------------------------------------------------------------------
416
417    /** @return list<string> */
418    public function getVersionWarnings(): array
419    {
420        return $this->lastVersionWarnings;
421    }
422
423    public function getReader(): PdfReader
424    {
425        return $this->reader;
426    }
427
428    public function getPageCount(): int
429    {
430        return $this->reader->getPageCount();
431    }
432
433    // -----------------------------------------------------------------------
434    // Internal
435    // -----------------------------------------------------------------------
436
437    /**
438     * @return array{operators: list<string>, extGState?: array<string, float>}
439     */
440    private function buildTextOps(
441        string $text,
442        StampPosition $position,
443        StampStyle $style,
444        array $dims,
445        string $fontName,
446    ): array {
447        $textWidth = strlen($text) * $style->fontSize * 0.5; // approximate
448        $textHeight = $style->fontSize;
449        [$x, $y] = $position->computeCoordinates(
450            $dims['width'],
451            $dims['height'],
452            $textWidth,
453            $textHeight,
454        );
455
456        $escaped = $this->escapeText($text);
457        $operators = ['q'];
458
459        $extGState = [];
460        if ($style->opacity < 1.0) {
461            $gsName = 'GsStamp' . (int) ($style->opacity * 100);
462            $operators[] = "/$gsName gs";
463            $extGState[$gsName] = $style->opacity;
464        }
465
466        $operators[] = sprintf('%.3f %.3f %.3f rg', $style->r, $style->g, $style->b);
467        $operators[] = 'BT';
468        $operators[] = sprintf('/%s %.1f Tf', $fontName, $style->fontSize);
469        $operators[] = sprintf('%.2f %.2f Td', $x, $y);
470        $operators[] = sprintf('(%s) Tj', $escaped);
471        $operators[] = 'ET';
472        $operators[] = 'Q';
473
474        return ['operators' => $operators, 'extGState' => $extGState];
475    }
476
477    /**
478     * @return array{operators: list<string>, extGState?: array<string, float>}
479     */
480    private function buildWatermarkOps(
481        string $text,
482        WatermarkStyle $style,
483        array $dims,
484        string $fontName,
485    ): array {
486        $cx = $dims['width'] / 2;
487        $cy = $dims['height'] / 2;
488        $rad = deg2rad($style->rotation);
489        $cos = cos($rad);
490        $sin = sin($rad);
491
492        $escaped = $this->escapeText($text);
493        $textWidth = strlen($text) * $style->fontSize * 0.5;
494
495        $operators = ['q'];
496
497        $gsName = 'GsWm' . (int) ($style->opacity * 100);
498        $extGState = [$gsName => $style->opacity];
499        $operators[] = "/$gsName gs";
500
501        $operators[] = sprintf('%.3f %.3f %.3f rg', $style->r, $style->g, $style->b);
502        $operators[] = 'BT';
503        $operators[] = sprintf('/%s %.1f Tf', $fontName, $style->fontSize);
504        // Position: translate to center, then apply rotation matrix
505        $operators[] = sprintf(
506            '%.4f %.4f %.4f %.4f %.2f %.2f Tm',
507            $cos,
508            $sin,
509            -$sin,
510            $cos,
511            $cx - ($textWidth * $cos / 2),
512            $cy - ($textWidth * $sin / 2),
513        );
514        $operators[] = sprintf('(%s) Tj', $escaped);
515        $operators[] = 'ET';
516        $operators[] = 'Q';
517
518        return ['operators' => $operators, 'extGState' => $extGState];
519    }
520
521    private function escapeText(string $text): string
522    {
523        // The stamper always renders with Helvetica (WinAnsi), so convert
524        // UTF-8 input to its WinAnsi byte form before escaping reserved
525        // characters. Without this, an em dash would emit three WinAnsi
526        // glyphs (â€") instead of one.
527        $text = (new WinAnsiEncoder())->encode($text);
528        return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
529    }
530
531    /**
532     * Build content stream operators to render an XObject at a position.
533     *
534     * @return array{operators: list<string>, extGState?: array<string, float>, xObjects?: list<string>}
535     */
536    private function buildXObjectOps(
537        string $xoName,
538        StampPosition $position,
539        ImageStampStyle $style,
540        array $dims,
541        float $sourceWidth,
542        float $sourceHeight,
543    ): array {
544        // Compute display dimensions
545        if ($style->width !== null && $style->height !== null) {
546            $displayWidth = $style->width;
547            $displayHeight = $style->height;
548        } elseif ($style->width !== null) {
549            $displayWidth = $style->width;
550            $displayHeight = $sourceHeight * ($style->width / $sourceWidth);
551        } elseif ($style->height !== null) {
552            $displayHeight = $style->height;
553            $displayWidth = $sourceWidth * ($style->height / $sourceHeight);
554        } else {
555            $displayWidth = $sourceWidth;
556            $displayHeight = $sourceHeight;
557        }
558
559        [$x, $y] = $position->computeCoordinates(
560            $dims['width'],
561            $dims['height'],
562            $displayWidth,
563            $displayHeight,
564        );
565
566        $operators = ['q'];
567        $extGState = [];
568
569        if ($style->opacity < 1.0) {
570            $gsName = 'GsStamp' . (int) ($style->opacity * 100);
571            $operators[] = "/$gsName gs";
572            $extGState[$gsName] = $style->opacity;
573        }
574
575        // cm operator: scale and translate the XObject
576        $operators[] = sprintf(
577            '%.4f 0 0 %.4f %.4f %.4f cm',
578            $displayWidth,
579            $displayHeight,
580            $x,
581            $y,
582        );
583        $operators[] = "/$xoName Do";
584        $operators[] = 'Q';
585
586        $result = ['operators' => $operators, 'xObjects' => [$xoName]];
587        if (!empty($extGState)) {
588            $result['extGState'] = $extGState;
589        }
590        return $result;
591    }
592
593    /**
594     * Register a JPEG/PNG image as an ImageXObject in the incremental writer.
595     */
596    private function registerImageXObject(IncrementalWriter $writer, string $imagePath): PdfReference
597    {
598        $info = ImageParser::parse($imagePath);
599        $data = LocalFilesystem::readFile($imagePath);
600
601        $dict = new PdfDictionary([
602            'Type'             => new PdfName('XObject'),
603            'Subtype'          => new PdfName('Image'),
604            'Width'            => new PdfNumber($info->width),
605            'Height'           => new PdfNumber($info->height),
606            'ColorSpace'       => new PdfName($info->colorSpace),
607            'BitsPerComponent' => new PdfNumber($info->bitsPerComponent),
608        ]);
609
610        // Set the appropriate decode filter for pass-through formats
611        match ($info->format) {
612            'jpeg' => $dict->set('Filter', new PdfName('DCTDecode')),
613            'jpeg2000' => $dict->set('Filter', new PdfName('JPXDecode')),
614            default => null,
615        };
616
617        $xObject = new PdfStream($dict, $data);
618        return $writer->addNewObject($xObject);
619    }
620
621    /**
622     * Import a page from a source PDF as a Form XObject.
623     *
624     * Extracts the page's content streams and resources, wrapping them in
625     * a single Form XObject that can be rendered via the Do operator.
626     */
627    private function registerPdfPageXObject(
628        IncrementalWriter $writer,
629        PdfReader $sourceReader,
630        int $pageIndex,
631        array $sourceDims,
632    ): PdfReference {
633        $sourcePageDict = $sourceReader->getPage($pageIndex);
634
635        // Collect content stream data
636        $contentData = '';
637        $contents = $sourcePageDict->get('Contents');
638        if ($contents instanceof PdfReference) {
639            $obj = $sourceReader->resolveReference($contents);
640            if ($obj instanceof PdfStream) {
641                $contentData = $obj->data;
642            } elseif ($obj instanceof PdfDictionary) {
643                $contentData = '';
644            }
645        } elseif ($contents instanceof PdfArray) {
646            foreach ($contents->items as $ref) {
647                if ($ref instanceof PdfReference) {
648                    $obj = $sourceReader->resolveReference($ref);
649                    if ($obj instanceof PdfStream) {
650                        $contentData .= $obj->data . "\n";
651                    }
652                }
653            }
654        }
655
656        $bBox = new PdfArray([
657            new PdfNumber(0), new PdfNumber(0),
658            new PdfNumber($sourceDims['width']), new PdfNumber($sourceDims['height']),
659        ]);
660
661        $formXObject = new FormXObject($bBox, $contentData);
662
663        // Copy resources from the source page
664        $sourceResources = $sourcePageDict->get('Resources');
665        if ($sourceResources instanceof PdfReference) {
666            $sourceResources = $sourceReader->resolveReference($sourceResources);
667        }
668        if ($sourceResources instanceof PdfDictionary) {
669            $formXObject->resources = $sourceResources;
670        }
671
672        return $writer->addNewObject($formXObject);
673    }
674}