Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.74% covered (success)
95.74%
675 / 705
84.72% covered (warning)
84.72%
61 / 72
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfDoc
95.74% covered (success)
95.74%
675 / 705
84.72% covered (warning)
84.72%
61 / 72
139
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
 wrap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 writer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAuthor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSubject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setKeywords
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCreator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ensureInfo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setMetadata
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 syncInfoToMetadata
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 addTextField
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 addCheckbox
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 addChoiceField
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 addSignatureField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 computeFieldFlags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 attachFieldWidget
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 ensureAcroForm
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addLinearGradient
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 addRadialGradient
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
2.00
 addLinearGradientStops
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 addRadialGradientStops
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 addConicShadingStops
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 buildConicPostScript
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
8.05
 buildConicSegmentChain
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 buildRgbStopFunction
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 buildRgbFunction
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 addLinearAlphaShading
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 addRadialAlphaShading
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 buildGrayStopFunction
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 buildGrayFunction
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 extendBothEnds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerSpotColor
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 createBarcode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 createTemplate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 createTransparencyGroup
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 setOpenAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addLayer
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 ensureOCPropertiesDict
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 attachFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 attachFileBytes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attachBytes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 setViewerPreferences
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 addLink
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 addStickyNote
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 addFreeText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addHighlight
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addUnderlineAnnotation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addSquiggly
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addStrikeout
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addCaret
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addInk
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
4
 addLineAnnotation
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 addPolygon
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addPolyline
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addSquare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addCircleAnnotation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addStamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addWatermarkAnnotation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addSoundAnnotation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addMovieAnnotation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add3DAnnotation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 attachAnnotation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rectToPdfArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 quadsToArrays
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 pointsToRectAndArray
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 setOutline
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addOutlineItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPageLabels
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 setNamedDestinations
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Writer;
6
7use Phpdftk\Barcode\BarcodeOptions;
8use Phpdftk\Barcode\BarcodeRenderer;
9use Phpdftk\Barcode\Symbology;
10use Phpdftk\Filesystem\LocalFilesystem;
11use Phpdftk\Geometry\Point;
12use Phpdftk\Geometry\Rectangle;
13use Phpdftk\Pdf\Core\Annotation\Annotation as CoreAnnotation;
14use Phpdftk\Pdf\Core\Annotation\BorderStyle;
15use Phpdftk\Pdf\Core\Annotation\CaretAnnotation;
16use Phpdftk\Pdf\Core\Annotation\CircleAnnotation;
17use Phpdftk\Pdf\Core\Annotation\FreeTextAnnotation;
18use Phpdftk\Pdf\Core\Annotation\HighlightAnnotation;
19use Phpdftk\Pdf\Core\Annotation\InkAnnotation;
20use Phpdftk\Pdf\Core\Annotation\LineAnnotation;
21use Phpdftk\Pdf\Core\Annotation\LinkAnnotation;
22use Phpdftk\Pdf\Core\Annotation\PolygonAnnotation;
23use Phpdftk\Pdf\Core\Annotation\PolyLineAnnotation;
24use Phpdftk\Pdf\Core\Annotation\SquareAnnotation;
25use Phpdftk\Pdf\Core\Annotation\SquigglyAnnotation;
26use Phpdftk\Pdf\Core\Annotation\StampAnnotation;
27use Phpdftk\Pdf\Core\Annotation\StrikeOutAnnotation;
28use Phpdftk\Pdf\Core\Annotation\TextAnnotation;
29use Phpdftk\Pdf\Core\Annotation\UnderlineAnnotation;
30use Phpdftk\Pdf\Core\Annotation\MovieAnnotation;
31use Phpdftk\Pdf\Core\Annotation\SoundAnnotation;
32use Phpdftk\Pdf\Core\Annotation\ThreeDAnnotation;
33use Phpdftk\Pdf\Core\Annotation\WatermarkAnnotation;
34use Phpdftk\Pdf\Core\Document\Destination;
35use Phpdftk\Pdf\Core\Document\Info;
36use Phpdftk\Pdf\Core\Document\Page as CorePage;
37use Phpdftk\Pdf\Core\Document\MetadataStream;
38use Phpdftk\Pdf\Core\Document\NameTree;
39use Phpdftk\Pdf\Core\Document\Outline;
40use Phpdftk\Pdf\Core\Document\OutlineItem;
41use Phpdftk\Pdf\Core\Document\PageLabel;
42use Phpdftk\Pdf\Core\Document\OCG;
43use Phpdftk\Pdf\Core\Document\OCPropertiesDict;
44use Phpdftk\Pdf\Core\Document\ViewerPreferences;
45use Phpdftk\Pdf\Core\File\PdfFileWriter;
46use Phpdftk\Pdf\Core\Content\ContentStream;
47use Phpdftk\Pdf\Core\Content\Resources;
48use Phpdftk\Pdf\Core\FileSpec\EmbeddedFile;
49use Phpdftk\Pdf\Core\FileSpec\FileSpec;
50use Phpdftk\Pdf\Core\Graphics\ColorSpace\Separation;
51use Phpdftk\Pdf\Core\Graphics\Function\FunctionType2;
52use Phpdftk\Pdf\Core\Graphics\Function\FunctionType3;
53use Phpdftk\Pdf\Core\Graphics\Function\FunctionType4;
54use Phpdftk\Pdf\Core\Graphics\Pattern\ShadingPattern;
55use Phpdftk\Pdf\Core\Graphics\Shading\ShadingType1;
56use Phpdftk\Pdf\Core\Graphics\Shading\ShadingType2;
57use Phpdftk\Pdf\Core\Graphics\Shading\ShadingType3;
58use Phpdftk\Pdf\Core\Annotation\WidgetAnnotation;
59use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
60use Phpdftk\Pdf\Core\Interactive\Form\AcroForm;
61use Phpdftk\Pdf\Core\Interactive\Form\ButtonField;
62use Phpdftk\Pdf\Core\Interactive\Form\ChoiceField;
63use Phpdftk\Pdf\Core\Interactive\Form\SignatureField;
64use Phpdftk\Pdf\Core\Interactive\Form\TextField;
65use Phpdftk\Pdf\Writer\Form\CheckboxOptions;
66use Phpdftk\Pdf\Writer\Form\ChoiceFieldOptions;
67use Phpdftk\Pdf\Writer\Form\TextFieldOptions;
68use Phpdftk\Pdf\Core\PdfArray;
69use Phpdftk\Pdf\Core\PdfDictionary;
70use Phpdftk\Pdf\Core\PdfName;
71use Phpdftk\Pdf\Core\PdfNumber;
72use Phpdftk\Pdf\Core\PdfReference;
73use Phpdftk\Pdf\Core\PdfStream;
74use Phpdftk\Pdf\Core\PdfString;
75use Phpdftk\Pdf\Core\PdfVersion;
76
77/**
78 * Level 2 â€” friendly API over the PDF document object model.
79 *
80 * `PdfDoc` wraps a {@see PdfWriter} and exposes one method per "thing
81 * a user wants to put in a document": pages, outlines, page labels,
82 * named destinations, info/metadata. Later phases extend this with
83 * annotation builders, form field builders, file attachments, viewer
84 * preferences, action factories, layers, gradients, and more.
85 *
86 * The split between `PdfDoc` and `PdfWriter` is:
87 *   - `PdfDoc` is about *what is in the document* (Catalog conveniences)
88 *   - `PdfWriter` is about *how bytes get written* (fonts, images,
89 *     content streams, signing, encryption, conformance, save)
90 *
91 * Drop down to the underlying {@see PdfWriter} via {@see writer()}
92 * when you need direct byte/resource control (custom fonts,
93 * encryption, etc.).
94 *
95 * @api
96 */
97class PdfDoc
98{
99    private PdfWriter $writer;
100
101    /** Lazily-created OCPropertiesDict, shared across {@see addLayer()} calls. */
102    private ?OCPropertiesDict $ocPropertiesDict = null;
103
104    /** Lazily-created AcroForm, shared across all form-field builders. */
105    private ?AcroForm $acroForm = null;
106
107    public function __construct(
108        bool $compressStreams = true,
109        PdfVersion|string $version = PdfFileWriter::DEFAULT_PDF_VERSION,
110    ) {
111        $this->writer = new PdfWriter($compressStreams, $version);
112    }
113
114    /**
115     * Wrap an existing PdfWriter â€” typically one already configured
116     * with conformance, signing, or encryption â€” and expose the
117     * friendly API on top of it.
118     */
119    public static function wrap(PdfWriter $writer): self
120    {
121        $instance = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor();
122        $instance->writer = $writer;
123        return $instance;
124    }
125
126    /**
127     * Escape hatch: the underlying PdfWriter for byte/resource control.
128     */
129    public function writer(): PdfWriter
130    {
131        return $this->writer;
132    }
133
134    /**
135     * Add a new page. Friendly wrapper that returns the same
136     * {@see Page} handle as PdfWriter::addPage().
137     */
138    public function addPage(Rectangle|float $widthOrRect = 612, float $height = 792): Page
139    {
140        return $this->writer->addPage($widthOrRect, $height);
141    }
142
143    // -----------------------------------------------------------------------
144    // Document metadata (Info dict + XMP)
145    // -----------------------------------------------------------------------
146
147    public function setInfo(Info $info): self
148    {
149        $this->writer->fileWriter()->setInfo($info);
150        return $this;
151    }
152
153    public function setTitle(string $title): self
154    {
155        $this->ensureInfo()->title = new PdfString($title);
156        return $this;
157    }
158
159    public function setAuthor(string $author): self
160    {
161        $this->ensureInfo()->author = new PdfString($author);
162        return $this;
163    }
164
165    public function setSubject(string $subject): self
166    {
167        $this->ensureInfo()->subject = new PdfString($subject);
168        return $this;
169    }
170
171    public function setKeywords(string $keywords): self
172    {
173        $this->ensureInfo()->keywords = new PdfString($keywords);
174        return $this;
175    }
176
177    public function setCreator(string $creator): self
178    {
179        $this->ensureInfo()->creator = new PdfString($creator);
180        return $this;
181    }
182
183    private function ensureInfo(): Info
184    {
185        $info = $this->writer->fileWriter()->getInfo();
186        if ($info === null) {
187            $info = new Info();
188            $this->writer->fileWriter()->setInfo($info);
189        }
190        return $info;
191    }
192
193    /**
194     * Attach an XMP metadata stream to the document catalog.
195     */
196    public function setMetadata(string $xmpXml): self
197    {
198        $metadataStream = new MetadataStream($xmpXml);
199        $this->writer->register($metadataStream);
200        $this->writer->getCatalog()->metadata = new PdfReference($metadataStream->objectNumber);
201        return $this;
202    }
203
204    /**
205     * Build and attach XMP metadata from the document's Info dictionary.
206     *
207     * Syncs Title, Author, Subject, Creator, Producer from the Info
208     * dict into XMP properties (dc:title, dc:creator, dc:description,
209     * xmp:CreatorTool, pdf:Producer) and attaches the result as a
210     * MetadataStream on the Catalog.
211     */
212    public function syncInfoToMetadata(): self
213    {
214        $info = $this->writer->fileWriter()->getInfo();
215        if ($info === null) {
216            return $this;
217        }
218
219        $packet = \Phpdftk\Xmp\XmpPacket::create();
220        if ($info->title !== null) {
221            $packet = $packet->set('dc:title', $info->title->value);
222        }
223        if ($info->author !== null) {
224            $packet = $packet->set('dc:creator', $info->author->value);
225        }
226        if ($info->subject !== null) {
227            $packet = $packet->set('dc:description', $info->subject->value);
228        }
229        if ($info->creator !== null) {
230            $packet = $packet->set('xmp:CreatorTool', $info->creator->value);
231        }
232        if ($info->producer !== null) {
233            $packet = $packet->set('pdf:Producer', $info->producer->value);
234        }
235
236        $xmpXml = (new \Phpdftk\Xmp\XmpWriter())->serialize($packet);
237        return $this->setMetadata($xmpXml);
238    }
239
240    // -----------------------------------------------------------------------
241    // Form fields
242    // -----------------------------------------------------------------------
243
244    /**
245     * Add a single-line (or multi-line) text input to the page. The
246     * field is registered in the document's AcroForm and a Widget
247     * annotation is attached to the page's `/Annots` array.
248     *
249     * Field flags (`/Ff`) are derived from {@see TextFieldOptions}:
250     *   - required â†’ bit 2 (0x0002)
251     *   - readOnly â†’ bit 1 (0x0001)
252     *   - multiline â†’ bit 13 (0x1000)
253     *   - password  â†’ bit 14 (0x2000)
254     */
255    public function addTextField(
256        string $name,
257        Page|CorePage $page,
258        Rectangle $rect,
259        ?TextFieldOptions $options = null,
260    ): TextField {
261        $options ??= new TextFieldOptions();
262        $field = new TextField();
263        $field->t = new PdfString($name);
264        $field->da = new PdfString($options->defaultAppearance);
265        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly)
266            | ($options->multiline ? 1 << 12 : 0)
267            | ($options->password ? 1 << 13 : 0);
268        if ($options->maxLength !== null) {
269            $field->maxLen = $options->maxLength;
270        }
271        if ($options->defaultValue !== null) {
272            $field->v = new PdfString($options->defaultValue);
273            $field->dv = new PdfString($options->defaultValue);
274        }
275        $this->attachFieldWidget($page, $rect, $field);
276        return $field;
277    }
278
279    /**
280     * Add a checkbox to the page. The export value (the string
281     * recorded as the field's `/V` when checked) defaults to `Yes`.
282     */
283    public function addCheckbox(
284        string $name,
285        Page|CorePage $page,
286        Rectangle $rect,
287        ?CheckboxOptions $options = null,
288    ): ButtonField {
289        $options ??= new CheckboxOptions();
290        $field = new ButtonField();
291        $field->t = new PdfString($name);
292        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly);
293        if ($options->defaultChecked) {
294            $field->v = new PdfName($options->onValue);
295            $field->dv = new PdfName($options->onValue);
296        } else {
297            $field->v = new PdfName('Off');
298            $field->dv = new PdfName('Off');
299        }
300        $this->attachFieldWidget($page, $rect, $field);
301        return $field;
302    }
303
304    /**
305     * Add a drop-down (combo) or list-box choice field. `$options`
306     * carries the list of allowed `[value, label]` choices and the
307     * usual required / read-only flags.
308     */
309    public function addChoiceField(
310        string $name,
311        Page|CorePage $page,
312        Rectangle $rect,
313        ChoiceFieldOptions $options,
314    ): ChoiceField {
315        $field = new ChoiceField();
316        $field->t = new PdfString($name);
317        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly)
318            | ($options->combo ? 1 << 17 : 0)
319            | ($options->editable ? 1 << 18 : 0)
320            | ($options->sort ? 1 << 19 : 0)
321            | ($options->multiSelect ? 1 << 21 : 0);
322
323        $optItems = [];
324        foreach ($options->choices as $choice) {
325            if (is_array($choice)) {
326                $optItems[] = new PdfArray([
327                    new PdfString($choice[0]),
328                    new PdfString($choice[1]),
329                ]);
330            } else {
331                $optItems[] = new PdfString($choice);
332            }
333        }
334        $field->opt = new PdfArray($optItems);
335        if ($options->defaultValue !== null) {
336            $field->v = new PdfString($options->defaultValue);
337            $field->dv = new PdfString($options->defaultValue);
338        }
339        $this->attachFieldWidget($page, $rect, $field);
340        return $field;
341    }
342
343    /**
344     * Add a signature field placeholder. Pair with
345     * {@see PdfWriter::setSigner()} to actually sign the document at
346     * generate time.
347     */
348    public function addSignatureField(
349        string $name,
350        Page|CorePage $page,
351        Rectangle $rect,
352    ): SignatureField {
353        $field = new SignatureField();
354        $field->t = new PdfString($name);
355        $this->attachFieldWidget($page, $rect, $field);
356
357        // Ensure the AcroForm declares /SigFlags = 3 (SignaturesExist
358        // + AppendOnly) so viewers handle the file as signed-ready.
359        $acroForm = $this->ensureAcroForm();
360        $acroForm->sigFlags = ($acroForm->sigFlags ?? 0) | 3;
361        return $field;
362    }
363
364    private function computeFieldFlags(bool $required, bool $readOnly): int
365    {
366        return ($readOnly ? 1 : 0) | ($required ? 2 : 0);
367    }
368
369    /**
370     * Wire a field into both the AcroForm fields list and a Widget
371     * annotation on the page. The Widget references the field as its
372     * /Parent; the field's /Kids list points back at the widget.
373     */
374    private function attachFieldWidget(
375        Page|CorePage $page,
376        Rectangle $rect,
377        \Phpdftk\Pdf\Core\Interactive\Form\Field $field,
378    ): void {
379        $widget = new WidgetAnnotation($this->rectToPdfArray($rect));
380        $this->writer->register($widget);
381        $this->writer->register($field);
382
383        $field->kids[] = new PdfReference($widget->objectNumber);
384        $widget->parent = new PdfReference($field->objectNumber);
385
386        $corePage = $page instanceof Page ? $page->corePage() : $page;
387        $corePage->annots[] = new PdfReference($widget->objectNumber);
388
389        $acroForm = $this->ensureAcroForm();
390        $acroForm->fields[] = new PdfReference($field->objectNumber);
391    }
392
393    private function ensureAcroForm(): AcroForm
394    {
395        if ($this->acroForm !== null) {
396            return $this->acroForm;
397        }
398        $form = new AcroForm();
399        // NeedAppearances asks viewers to generate widget appearances
400        // on open â€” appropriate when the writer doesn't pre-build /AP.
401        $form->needAppearances = true;
402        $this->writer->register($form);
403        $this->writer->getCatalog()->acroForm = new PdfReference($form->objectNumber);
404        $this->acroForm = $form;
405        return $form;
406    }
407
408    // -----------------------------------------------------------------------
409    // Gradients
410    // -----------------------------------------------------------------------
411
412    /**
413     * Register a two-stop axial (linear) gradient. The returned
414     * {@see ShadingPattern} can be used as a fill via
415     * {@see Writer\Page::useGradient()}.
416     *
417     * @param array{float,float,float} $startRgb RGB at gradient origin.
418     * @param array{float,float,float} $endRgb   RGB at gradient end.
419     */
420    public function addLinearGradient(
421        Point $from,
422        Point $to,
423        array $startRgb,
424        array $endRgb,
425        bool $extend = false,
426    ): ShadingPattern {
427        $fn = $this->buildRgbFunction($startRgb, $endRgb);
428        $shading = new ShadingType2(
429            new PdfName('DeviceRGB'),
430            new PdfArray([
431                new PdfNumber($from->x),
432                new PdfNumber($from->y),
433                new PdfNumber($to->x),
434                new PdfNumber($to->y),
435            ]),
436            new PdfReference($fn->objectNumber),
437        );
438        if ($extend) {
439            $shading->extend = self::extendBothEnds();
440        }
441        $this->writer->register($shading);
442
443        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
444        $this->writer->register($pattern);
445        return $pattern;
446    }
447
448    /**
449     * Register a two-stop radial gradient. `$inner` / `$outer` are
450     * concentric (or non-concentric) circles defining the gradient
451     * boundary.
452     *
453     * @param array{float,float,float} $startRgb RGB at inner radius.
454     * @param array{float,float,float} $endRgb   RGB at outer radius.
455     */
456    public function addRadialGradient(
457        Point $innerCenter,
458        float $innerRadius,
459        Point $outerCenter,
460        float $outerRadius,
461        array $startRgb,
462        array $endRgb,
463        bool $extend = false,
464    ): ShadingPattern {
465        $fn = $this->buildRgbFunction($startRgb, $endRgb);
466        $shading = new ShadingType3(
467            new PdfName('DeviceRGB'),
468            new PdfArray([
469                new PdfNumber($innerCenter->x),
470                new PdfNumber($innerCenter->y),
471                new PdfNumber($innerRadius),
472                new PdfNumber($outerCenter->x),
473                new PdfNumber($outerCenter->y),
474                new PdfNumber($outerRadius),
475            ]),
476            new PdfReference($fn->objectNumber),
477        );
478        if ($extend) {
479            $shading->extend = self::extendBothEnds();
480        }
481        $this->writer->register($shading);
482
483        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
484        $this->writer->register($pattern);
485        return $pattern;
486    }
487
488    /**
489     * Register an N-stop axial (linear) gradient. Each stop is a
490     * `{offset, rgb}` pair with `offset` in [0, 1]. Stops must be
491     * sorted ascending and start at 0 / end at 1 (the caller is
492     * responsible for normalising â€” typical CSS gradient resolution
493     * already does this). Two-stop input falls through to the same
494     * Type-2 function as `addLinearGradient`; three-or-more produces
495     * a Type-3 stitching function with N-1 Type-2 sub-functions.
496     *
497     * @param list<array{offset: float, rgb: array{float, float, float}, alpha?: float}> $stops
498     */
499    public function addLinearGradientStops(
500        Point $from,
501        Point $to,
502        array $stops,
503        bool $extend = false,
504    ): ShadingPattern {
505        $fn = $this->buildRgbStopFunction($stops);
506        $shading = new ShadingType2(
507            new PdfName('DeviceRGB'),
508            new PdfArray([
509                new PdfNumber($from->x),
510                new PdfNumber($from->y),
511                new PdfNumber($to->x),
512                new PdfNumber($to->y),
513            ]),
514            new PdfReference($fn->objectNumber),
515        );
516        if ($extend) {
517            $shading->extend = self::extendBothEnds();
518        }
519        $this->writer->register($shading);
520
521        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
522        $this->writer->register($pattern);
523        return $pattern;
524    }
525
526    /**
527     * Register an N-stop radial gradient. Same stop semantics as
528     * {@see addLinearGradientStops()}.
529     *
530     * @param list<array{offset: float, rgb: array{float, float, float}, alpha?: float}> $stops
531     */
532    public function addRadialGradientStops(
533        Point $innerCenter,
534        float $innerRadius,
535        Point $outerCenter,
536        float $outerRadius,
537        array $stops,
538        bool $extend = false,
539    ): ShadingPattern {
540        $fn = $this->buildRgbStopFunction($stops);
541        $shading = new ShadingType3(
542            new PdfName('DeviceRGB'),
543            new PdfArray([
544                new PdfNumber($innerCenter->x),
545                new PdfNumber($innerCenter->y),
546                new PdfNumber($innerRadius),
547                new PdfNumber($outerCenter->x),
548                new PdfNumber($outerCenter->y),
549                new PdfNumber($outerRadius),
550            ]),
551            new PdfReference($fn->objectNumber),
552        );
553        if ($extend) {
554            $shading->extend = self::extendBothEnds();
555        }
556        $this->writer->register($shading);
557
558        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
559        $this->writer->register($pattern);
560        return $pattern;
561    }
562
563    /**
564     * Register a CSS `conic-gradient()` as a function-based shading
565     * (ShadingType 1, ISO 32000-2 Â§8.7.4.5.2) driven by a PostScript
566     * calculator function (FunctionType 4) that maps each point (x, y)
567     * to its sweep angle around `$center` and interpolates the stop list.
568     *
569     * PDF has no native angular shading, so the calculator computes the
570     * CSS angle `atan2(x - cx, y - cy)` (0° at 12 o'clock, increasing
571     * clockwise in the PDF y-up space), subtracts the `from` angle, wraps
572     * to [0, 1), then walks the stop segments. Coordinates are absolute
573     * page-space (the `$domain` rect), with an identity pattern matrix â€”
574     * mirroring the linear path so pattern anchoring is CTM-independent.
575     *
576     * @param list<array{offset: float, rgb: array{float, float, float}, alpha?: float}> $stops
577     *   Resolved stops, offsets in [0, 1] ascending, first at 0 / last at 1.
578     * @param array{float, float, float, float} $domain [x0, x1, y0, y1] box rect.
579     */
580    public function addConicShadingStops(
581        Point $center,
582        float $fromAngleDeg,
583        array $stops,
584        array $domain,
585        bool $repeating = false,
586    ): ShadingPattern {
587        $postScript = $this->buildConicPostScript(
588            $center->x,
589            $center->y,
590            $fromAngleDeg,
591            $stops,
592            $repeating,
593        );
594        $fn = new FunctionType4(
595            new PdfArray([
596                new PdfNumber($domain[0]),
597                new PdfNumber($domain[1]),
598                new PdfNumber($domain[2]),
599                new PdfNumber($domain[3]),
600            ]),
601            new PdfArray([
602                new PdfNumber(0.0), new PdfNumber(1.0),
603                new PdfNumber(0.0), new PdfNumber(1.0),
604                new PdfNumber(0.0), new PdfNumber(1.0),
605            ]),
606            $postScript,
607        );
608        $this->writer->register($fn);
609        $shading = new ShadingType1(
610            new PdfName('DeviceRGB'),
611            new PdfReference($fn->objectNumber),
612        );
613        $shading->domain = new PdfArray([
614            new PdfNumber($domain[0]),
615            new PdfNumber($domain[1]),
616            new PdfNumber($domain[2]),
617            new PdfNumber($domain[3]),
618        ]);
619        $this->writer->register($shading);
620
621        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
622        $this->writer->register($pattern);
623        return $pattern;
624    }
625
626    /**
627     * Emit the PostScript calculator body for {@see addConicShadingStops()}.
628     * Consumes `x y` from the stack, leaves `r g b`.
629     *
630     * @param list<array{offset: float, rgb: array{float, float, float}, alpha?: float}> $stops
631     */
632    private function buildConicPostScript(
633        float $cx,
634        float $cy,
635        float $fromAngleDeg,
636        array $stops,
637        bool $repeating,
638    ): string {
639        $n = static fn(float $v): string => rtrim(rtrim(sprintf('%.6f', $v), '0'), '.') ?: '0';
640        // Segments between consecutive distinct-offset stops; zero-width
641        // pairs (hard stops) are skipped so adjacent solid bands abut.
642        $segments = [];
643        $count = count($stops);
644        for ($i = 0; $i < $count - 1; $i++) {
645            $o0 = $stops[$i]['offset'];
646            $o1 = $stops[$i + 1]['offset'];
647            if ($o1 <= $o0) {
648                continue;
649            }
650            $segments[] = [$o0, $o1, $stops[$i]['rgb'], $stops[$i + 1]['rgb']];
651        }
652        if ($segments === []) {
653            // Degenerate (all stops coincident) â€” emit a solid fill.
654            $rgb = $stops[0]['rgb'];
655            return sprintf('{ pop pop %s %s %s }', $n($rgb[0]), $n($rgb[1]), $n($rgb[2]));
656        }
657        // Angle â†’ t âˆˆ [0,1). Stack in: x y.
658        $ps = '{ ';
659        // exch cx sub exch cy sub â†’ dx dy ; atan â†’ CSS angle [0,360)
660        $ps .= sprintf('exch %s sub exch %s sub atan ', $n($cx), $n($cy));
661        // (angle - from) wrapped to [0, 360) â€” Type-4 `mod` is integer-only,
662        // so one conditional subtract does the wrap (value âˆˆ (0, 720)).
663        $ps .= sprintf('%s sub 360 add dup 360 ge { 360 sub } if 360 div ', $n($fromAngleDeg));
664        if ($repeating) {
665            // Wrap into one cycle = last segment offset, then rescale so the
666            // stop list (authored over [0, cycle]) tiles the full sweep.
667            $cycle = $segments[count($segments) - 1][1];
668            if ($cycle > 0.0 && $cycle < 1.0) {
669                $ps .= sprintf('%s div dup floor sub %s mul ', $n($cycle), $n($cycle));
670            }
671        }
672        // t is on the stack. Build the nested ifelse over segments.
673        $ps .= $this->buildConicSegmentChain($segments, 0, $n);
674        $ps .= ' }';
675        return $ps;
676    }
677
678    /**
679     * Recursively build the `dup <o1> le { interp } { rest } ifelse` chain
680     * that interpolates the conic stop segments. Consumes `t`, leaves
681     * `r g b`.
682     *
683     * @param list<array{float, float, array{float,float,float}, array{float,float,float}}> $segments
684     * @param \Closure(float): string $n number formatter
685     */
686    private function buildConicSegmentChain(array $segments, int $i, \Closure $n): string
687    {
688        $last = count($segments) - 1;
689        [$o0, $o1, $c0, $c1] = $segments[$i];
690        $span = $o1 - $o0;
691        $invSpan = $span > 0.0 ? 1.0 / $span : 0.0;
692        // frac = (t - o0) / span ; then R G B = c0 + frac*(c1 - c0).
693        $interp = sprintf('%s sub %s mul ', $n($o0), $n($invSpan))
694            . sprintf('dup %s mul %s add exch ', $n($c1[0] - $c0[0]), $n($c0[0]))
695            . sprintf('dup %s mul %s add exch ', $n($c1[1] - $c0[1]), $n($c0[1]))
696            . sprintf('%s mul %s add', $n($c1[2] - $c0[2]), $n($c0[2]));
697        if ($i === $last) {
698            // Final segment is the catch-all else branch (t already on stack).
699            return $interp;
700        }
701        return sprintf('dup %s le { %s } { %s } ifelse', $n($o1), $interp, $this->buildConicSegmentChain($segments, $i + 1, $n));
702    }
703
704    /**
705     * Build a Function object that maps [0,1] â†’ RGB through the given
706     * stop list. Two stops produce a Type-2 (exponential, n=1, linear
707     * interpolation). Three or more stops produce a Type-3 stitching
708     * function: bounds at the intermediate stop offsets, N-1 child
709     * Type-2 functions each linearly interpolating one segment.
710     *
711     * @param list<array{offset: float, rgb: array{float, float, float}, alpha?: float}> $stops
712     */
713    private function buildRgbStopFunction(array $stops): FunctionType2|FunctionType3
714    {
715        if (count($stops) < 2) {
716            throw new \InvalidArgumentException('Gradient requires at least 2 stops.');
717        }
718        if (count($stops) === 2) {
719            return $this->buildRgbFunction($stops[0]['rgb'], $stops[1]['rgb']);
720        }
721        // Build N-1 segment functions linearly interpolating between
722        // adjacent stops.
723        $subFunctions = [];
724        $bounds = [];
725        $encode = [];
726        $count = count($stops);
727        for ($i = 0; $i < $count - 1; $i++) {
728            $segment = $this->buildRgbFunction($stops[$i]['rgb'], $stops[$i + 1]['rgb']);
729            $subFunctions[] = new PdfReference($segment->objectNumber);
730            if ($i > 0) {
731                $bounds[] = new PdfNumber($stops[$i]['offset']);
732            }
733            // Each segment consumes its full [0, 1] domain.
734            $encode[] = new PdfNumber(0.0);
735            $encode[] = new PdfNumber(1.0);
736        }
737        $fn = new FunctionType3(
738            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
739            functions: new PdfArray($subFunctions),
740            bounds: new PdfArray($bounds),
741            encode: new PdfArray($encode),
742        );
743        $fn->range = new PdfArray([
744            new PdfNumber(0.0), new PdfNumber(1.0),
745            new PdfNumber(0.0), new PdfNumber(1.0),
746            new PdfNumber(0.0), new PdfNumber(1.0),
747        ]);
748        $this->writer->register($fn);
749        return $fn;
750    }
751
752    /**
753     * @param array{float,float,float} $startRgb
754     * @param array{float,float,float} $endRgb
755     */
756    private function buildRgbFunction(array $startRgb, array $endRgb): FunctionType2
757    {
758        $fn = new FunctionType2(
759            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
760            c0: new PdfArray([
761                new PdfNumber($startRgb[0]),
762                new PdfNumber($startRgb[1]),
763                new PdfNumber($startRgb[2]),
764            ]),
765            c1: new PdfArray([
766                new PdfNumber($endRgb[0]),
767                new PdfNumber($endRgb[1]),
768                new PdfNumber($endRgb[2]),
769            ]),
770            n: 1.0,
771        );
772        $fn->range = new PdfArray([
773            new PdfNumber(0.0), new PdfNumber(1.0),
774            new PdfNumber(0.0), new PdfNumber(1.0),
775            new PdfNumber(0.0), new PdfNumber(1.0),
776        ]);
777        $this->writer->register($fn);
778        return $fn;
779    }
780
781    /**
782     * Register a linear DeviceGray shading whose gray value at each
783     * offset equals the *alpha* of the corresponding gradient stop
784     * (0 â†’ black â†’ fully transparent, 1 â†’ white â†’ fully opaque). Painted
785     * with the `sh` operator inside a transparency group, it becomes the
786     * `/G` of a Luminosity soft mask (ISO 32000-2 Â§11.6.5.2) that
787     * modulates a colour gradient's per-stop opacity â€” the CSS
788     * `linear-gradient(rgba(c,0), rgba(c,1))` case, which the colour
789     * shading alone cannot express (PDF shadings carry no alpha).
790     *
791     * @param list<array{offset: float, gray: float}> $stops
792     */
793    public function addLinearAlphaShading(
794        Point $from,
795        Point $to,
796        array $stops,
797        bool $extend = true,
798    ): ShadingType2 {
799        $fn = $this->buildGrayStopFunction($stops);
800        $shading = new ShadingType2(
801            new PdfName('DeviceGray'),
802            new PdfArray([
803                new PdfNumber($from->x),
804                new PdfNumber($from->y),
805                new PdfNumber($to->x),
806                new PdfNumber($to->y),
807            ]),
808            new PdfReference($fn->objectNumber),
809        );
810        if ($extend) {
811            $shading->extend = self::extendBothEnds();
812        }
813        $this->writer->register($shading);
814        return $shading;
815    }
816
817    /**
818     * Radial counterpart to {@see addLinearAlphaShading()} â€” a
819     * DeviceGray Type-3 shading carrying the gradient's per-stop alpha
820     * as gray, for use as a Luminosity soft-mask `/G` form.
821     *
822     * @param list<array{offset: float, gray: float}> $stops
823     */
824    public function addRadialAlphaShading(
825        Point $innerCenter,
826        float $innerRadius,
827        Point $outerCenter,
828        float $outerRadius,
829        array $stops,
830        bool $extend = true,
831    ): ShadingType3 {
832        $fn = $this->buildGrayStopFunction($stops);
833        $shading = new ShadingType3(
834            new PdfName('DeviceGray'),
835            new PdfArray([
836                new PdfNumber($innerCenter->x),
837                new PdfNumber($innerCenter->y),
838                new PdfNumber($innerRadius),
839                new PdfNumber($outerCenter->x),
840                new PdfNumber($outerCenter->y),
841                new PdfNumber($outerRadius),
842            ]),
843            new PdfReference($fn->objectNumber),
844        );
845        if ($extend) {
846            $shading->extend = self::extendBothEnds();
847        }
848        $this->writer->register($shading);
849        return $shading;
850    }
851
852    /**
853     * DeviceGray analogue of {@see buildRgbStopFunction()} â€” maps
854     * [0,1] â†’ a single gray component through the stop list, for
855     * alpha soft-mask shadings.
856     *
857     * @param list<array{offset: float, gray: float}> $stops
858     */
859    private function buildGrayStopFunction(array $stops): FunctionType2|FunctionType3
860    {
861        if (count($stops) < 2) {
862            throw new \InvalidArgumentException('Alpha shading requires at least 2 stops.');
863        }
864        if (count($stops) === 2) {
865            return $this->buildGrayFunction($stops[0]['gray'], $stops[1]['gray']);
866        }
867        $subFunctions = [];
868        $bounds = [];
869        $encode = [];
870        $count = count($stops);
871        for ($i = 0; $i < $count - 1; $i++) {
872            $segment = $this->buildGrayFunction($stops[$i]['gray'], $stops[$i + 1]['gray']);
873            $subFunctions[] = new PdfReference($segment->objectNumber);
874            if ($i > 0) {
875                $bounds[] = new PdfNumber($stops[$i]['offset']);
876            }
877            $encode[] = new PdfNumber(0.0);
878            $encode[] = new PdfNumber(1.0);
879        }
880        $fn = new FunctionType3(
881            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
882            functions: new PdfArray($subFunctions),
883            bounds: new PdfArray($bounds),
884            encode: new PdfArray($encode),
885        );
886        $fn->range = new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]);
887        $this->writer->register($fn);
888        return $fn;
889    }
890
891    private function buildGrayFunction(float $startGray, float $endGray): FunctionType2
892    {
893        $fn = new FunctionType2(
894            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
895            c0: new PdfArray([new PdfNumber($startGray)]),
896            c1: new PdfArray([new PdfNumber($endGray)]),
897            n: 1.0,
898        );
899        $fn->range = new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]);
900        $this->writer->register($fn);
901        return $fn;
902    }
903
904    /**
905     * Build `/Extend [true true]` â€” used by the `addLinearGradient*` /
906     * `addRadialGradient*` registration helpers when the caller opts
907     * into endpoint-pad semantics on a Type 2 / Type 3 shading.
908     */
909    private static function extendBothEnds(): PdfArray
910    {
911        return new PdfArray([true, true]);
912    }
913
914    // -----------------------------------------------------------------------
915    // Spot colors
916    // -----------------------------------------------------------------------
917
918    /**
919     * Register a spot color (a {@see Separation} color space). The
920     * `$cmykTint` parameter specifies the device-CMYK approximation
921     * used by viewers that don't have the spot ink â€” values are 0–1.
922     *
923     * Use the returned `Separation` with
924     * {@see Writer\Page::useSpotColor()} to attach it to a page's
925     * resources and obtain the resource name for content-stream ops:
926     *
927     *   $sep = $doc->registerSpotColor('Pantone 185 C', [0, 0.85, 0.6, 0]);
928     *   $name = $page->useSpotColor($sep);
929     *   $page->contentStream()
930     *       ->setFillColorSpace($name)
931     *       ->setFillColor(1.0)   // full tint
932     *       ->rectangle(72, 600, 200, 80)
933     *       ->fill();
934     *
935     * @param array{float,float,float,float} $cmykTint
936     */
937    public function registerSpotColor(string $name, array $cmykTint): SpotColor
938    {
939        $tintFn = new FunctionType2(
940            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
941            c0: new PdfArray([
942                new PdfNumber(0.0),
943                new PdfNumber(0.0),
944                new PdfNumber(0.0),
945                new PdfNumber(0.0),
946            ]),
947            c1: new PdfArray([
948                new PdfNumber($cmykTint[0]),
949                new PdfNumber($cmykTint[1]),
950                new PdfNumber($cmykTint[2]),
951                new PdfNumber($cmykTint[3]),
952            ]),
953            n: 1.0,
954        );
955        $tintFn->range = new PdfArray([
956            new PdfNumber(0.0),
957            new PdfNumber(1.0),
958            new PdfNumber(0.0),
959            new PdfNumber(1.0),
960            new PdfNumber(0.0),
961            new PdfNumber(1.0),
962            new PdfNumber(0.0),
963            new PdfNumber(1.0),
964        ]);
965        $this->writer->register($tintFn);
966
967        $separation = new Separation(
968            new PdfName($name),
969            new PdfName('DeviceCMYK'),
970            new PdfReference($tintFn->objectNumber),
971        );
972        // `Separation` is a value type (implements Serializable) â€” it's
973        // inlined into the page's /Resources /ColorSpace entry rather
974        // than registered as an indirect object.
975        return new SpotColor($name, $separation);
976    }
977
978    // -----------------------------------------------------------------------
979    // Barcodes
980    // -----------------------------------------------------------------------
981
982    /**
983     * Build a reusable {@see FormXObject} containing a barcode
984     * rendering. The resulting template can be placed on multiple
985     * pages via `Writer\Page::drawTemplate()`.
986     *
987     * Only `Symbology::Code128` is implemented in v1; other cases
988     * throw at render time.
989     */
990    public function createBarcode(
991        Symbology $symbology,
992        string $data,
993        ?BarcodeOptions $options = null,
994    ): FormXObject {
995        $options ??= new BarcodeOptions();
996        $bitmap = BarcodeRenderer::render($symbology, $data, $options);
997
998        return $this->createTemplate(
999            new Rectangle(0.0, 0.0, $bitmap->totalWidth(), $bitmap->totalHeight()),
1000            function (ContentStream $cs) use ($bitmap): void {
1001                BarcodeRendering::renderInto($cs, $bitmap);
1002            },
1003        );
1004    }
1005
1006
1007    /**
1008     * Build a reusable Form XObject â€” a self-contained content stream
1009     * that can be placed on multiple pages without re-emitting the
1010     * underlying operators.
1011     *
1012     * The closure receives a fresh {@see ContentStream} sized to
1013     * `$bbox` (origin at `bbox->x, bbox->y`). Any drawing operators
1014     * the closure adds are captured into the FormXObject's stream;
1015     * resources (fonts, images) used inside the template must be
1016     * registered on the FormXObject's own resource dict â€” for v1, the
1017     * caller passes pre-registered Font handles into the closure if
1018     * needed and accepts that the template inherits resources from
1019     * the placing page.
1020     *
1021     * @param \Closure(ContentStream): void $draw
1022     */
1023    public function createTemplate(Rectangle $bbox, \Closure $draw): FormXObject
1024    {
1025        [$llx, $lly, $urx, $ury] = $bbox->toArray();
1026        $bboxArr = new PdfArray([
1027            new PdfNumber($llx),
1028            new PdfNumber($lly),
1029            new PdfNumber($urx),
1030            new PdfNumber($ury),
1031        ]);
1032
1033        $cs = new ContentStream();
1034        $draw($cs);
1035
1036        $template = new FormXObject($bboxArr, implode("\n", $cs->getOperators()));
1037        // Empty Resources so the placing page contributes shared
1038        // fonts / images via its own resource dict.
1039        $template->resources = new Resources();
1040        $this->writer->register($template);
1041        return $template;
1042    }
1043
1044    /**
1045     * Create a registered transparency-group Form XObject â€” a
1046     * {@see createTemplate()} whose content is drawn inside an isolated
1047     * `/Group << /S /Transparency /CS /DeviceRGB /I true >>` so it can
1048     * serve as the `/G` of an ExtGState soft mask (ISO 32000-2 Â§11.6.5).
1049     * The group carries its OWN {@see Resources} so a caller can register
1050     * shading patterns / images used only by the mask content on it.
1051     *
1052     * @param \Closure(ContentStream, Resources): void $draw receives the
1053     *   group's content stream and its resource dict.
1054     */
1055    public function createTransparencyGroup(Rectangle $bbox, \Closure $draw): FormXObject
1056    {
1057        [$llx, $lly, $urx, $ury] = $bbox->toArray();
1058        $bboxArr = new PdfArray([
1059            new PdfNumber($llx),
1060            new PdfNumber($lly),
1061            new PdfNumber($urx),
1062            new PdfNumber($ury),
1063        ]);
1064
1065        $resources = new Resources();
1066        $cs = new ContentStream();
1067        $draw($cs, $resources);
1068
1069        $group = new FormXObject($bboxArr, implode("\n", $cs->getOperators()));
1070        $group->resources = $resources;
1071        $groupAttrs = new \Phpdftk\Pdf\Core\Document\GroupAttributes('Transparency');
1072        $groupAttrs->cs = new \Phpdftk\Pdf\Core\PdfName('DeviceRGB');
1073        $groupAttrs->i = new \Phpdftk\Pdf\Core\PdfBoolean(true);
1074        $group->group = $groupAttrs;
1075        $this->writer->register($group);
1076        return $group;
1077    }
1078
1079    // -----------------------------------------------------------------------
1080    // Actions
1081    // -----------------------------------------------------------------------
1082
1083    /**
1084     * Set the document's open action â€” executed by the viewer when
1085     * the document is loaded. Typically used to jump to a specific
1086     * page or run JavaScript on open. The action is registered as an
1087     * indirect object; pass an instance from {@see Action}'s static
1088     * factories.
1089     */
1090    public function setOpenAction(\Phpdftk\Pdf\Core\Action\Action $action): self
1091    {
1092        $this->writer->register($action);
1093        $this->writer->getCatalog()->openAction = new PdfReference($action->objectNumber);
1094        return $this;
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // Optional content (layers)
1099    // -----------------------------------------------------------------------
1100
1101    /**
1102     * Register a new optional-content group (layer). The returned
1103     * `OCG` is referenced from the catalog's `/OCProperties` /OCGs
1104     * array; pass it to {@see Writer\Page::inLayer()} to tag drawing
1105     * operations as belonging to this layer.
1106     *
1107     * `$visible` controls the default state: visible layers go into
1108     * the default config's `/ON` list, hidden ones into `/OFF`.
1109     */
1110    public function addLayer(string $name, bool $visible = true): OCG
1111    {
1112        $ocg = new OCG($name);
1113        $this->writer->register($ocg);
1114        $ref = new PdfReference($ocg->objectNumber);
1115
1116        $props = $this->ensureOCPropertiesDict();
1117        $props->ocgs = new PdfArray([...$props->ocgs->items, $ref]);
1118
1119        $key = $visible ? 'ON' : 'OFF';
1120        $list = $props->d->get($key);
1121        $items = $list instanceof PdfArray ? $list->items : [];
1122        $items[] = $ref;
1123        $props->d->set($key, new PdfArray($items));
1124
1125        return $ocg;
1126    }
1127
1128    private function ensureOCPropertiesDict(): OCPropertiesDict
1129    {
1130        if ($this->ocPropertiesDict !== null) {
1131            return $this->ocPropertiesDict;
1132        }
1133        $defaultConfig = new PdfDictionary([
1134            'Name' => new PdfString('Default'),
1135            'BaseState' => new PdfName('ON'),
1136            'ON' => new PdfArray([]),
1137            'OFF' => new PdfArray([]),
1138        ]);
1139        $props = new OCPropertiesDict(new PdfArray([]), $defaultConfig);
1140        $this->writer->register($props);
1141        $this->writer->getCatalog()->ocProperties = new PdfReference($props->objectNumber);
1142        $this->ocPropertiesDict = $props;
1143        return $props;
1144    }
1145
1146    // -----------------------------------------------------------------------
1147    // File attachments
1148    // -----------------------------------------------------------------------
1149
1150    /**
1151     * Attach a file from disk. The file's bytes are read via
1152     * {@see LocalFilesystem::readFile()} and embedded as an
1153     * `EmbeddedFile`, wrapped in a `FileSpec`, and appended to the
1154     * catalog's `/AF` (Associated Files) array.
1155     *
1156     * `$relationship` populates `/AFRelationship` â€” the PDF 2.0 hint
1157     * to viewers about the file's role. ZUGFeRD invoices use
1158     * `Alternative` for the embedded XML; common values are `Source`,
1159     * `Data`, `Alternative`, `Supplement`, `EncryptedPayload`, and
1160     * `FormData`.
1161     */
1162    public function attachFile(
1163        string $path,
1164        ?string $description = null,
1165        ?string $mimeType = null,
1166        ?string $relationship = null,
1167    ): FileSpec {
1168        $bytes = LocalFilesystem::readFile($path);
1169        $name = basename($path);
1170        return $this->attachBytes($name, $bytes, $description, $mimeType, $relationship);
1171    }
1172
1173    /**
1174     * Attach a file from in-memory bytes â€” useful when the source
1175     * isn't on disk (generated XML for ZUGFeRD, downloaded content,
1176     * etc.).
1177     */
1178    public function attachFileBytes(
1179        string $name,
1180        string $bytes,
1181        ?string $description = null,
1182        ?string $mimeType = null,
1183        ?string $relationship = null,
1184    ): FileSpec {
1185        return $this->attachBytes($name, $bytes, $description, $mimeType, $relationship);
1186    }
1187
1188    private function attachBytes(
1189        string $name,
1190        string $bytes,
1191        ?string $description,
1192        ?string $mimeType,
1193        ?string $relationship,
1194    ): FileSpec {
1195        $embedded = new EmbeddedFile($bytes, $mimeType);
1196        $this->writer->register($embedded);
1197
1198        $fileSpec = new FileSpec($name);
1199        $fileSpec->attachEmbeddedFile(new PdfReference($embedded->objectNumber));
1200        if ($description !== null) {
1201            $fileSpec->desc = new PdfString($description);
1202        }
1203        if ($relationship !== null) {
1204            $fileSpec->afRelationship = new PdfName($relationship);
1205        }
1206        $this->writer->register($fileSpec);
1207
1208        $catalog = $this->writer->getCatalog();
1209        $existing = $catalog->af !== null ? $catalog->af->items : [];
1210        $existing[] = new PdfReference($fileSpec->objectNumber);
1211        $catalog->af = new PdfArray($existing);
1212
1213        return $fileSpec;
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // Viewer preferences
1218    // -----------------------------------------------------------------------
1219
1220    /**
1221     * Set the document's viewer preferences. Accepts either a
1222     * pre-constructed {@see ViewerPreferences} object or a closure
1223     * that receives a fresh instance and mutates it.
1224     *
1225     * Closure form:
1226     *   $doc->setViewerPreferences(function (ViewerPreferences $vp): void {
1227     *       $vp->displayDocTitle = true;
1228     *       $vp->fitWindow = true;
1229     *   });
1230     */
1231    public function setViewerPreferences(ViewerPreferences|\Closure $prefs): self
1232    {
1233        if ($prefs instanceof \Closure) {
1234            $vp = new ViewerPreferences();
1235            $prefs($vp);
1236        } else {
1237            $vp = $prefs;
1238        }
1239        $this->writer->register($vp);
1240        $this->writer->getCatalog()->viewerPreferences = new PdfReference($vp->objectNumber);
1241        return $this;
1242    }
1243
1244    // -----------------------------------------------------------------------
1245    // Annotations
1246    // -----------------------------------------------------------------------
1247
1248    /**
1249     * Add a link annotation to a page.
1250     *
1251     * `$target` accepts:
1252     *   - **string** â€” treated as a URI; an inline /A action dict is built.
1253     *   - **Destination** â€” an explicit destination (use the named
1254     *     constructors `Destination::fit($pageRef)`,
1255     *     `Destination::xyz(...)`, etc.).
1256     *   - **PdfReference** â€” points to a named destination that has been
1257     *     registered via {@see setNamedDestinations()}.
1258     */
1259    public function addLink(
1260        Page|CorePage $page,
1261        Rectangle $rect,
1262        string|Destination|PdfReference $target,
1263        ?BorderStyle $border = null,
1264    ): LinkAnnotation {
1265        $corePage = $page instanceof Page ? $page->corePage() : $page;
1266
1267        [$llx, $lly, $urx, $ury] = $rect->toArray();
1268        $rectArray = new PdfArray([
1269            new PdfNumber($llx),
1270            new PdfNumber($lly),
1271            new PdfNumber($urx),
1272            new PdfNumber($ury),
1273        ]);
1274
1275        $annotation = new LinkAnnotation($rectArray);
1276
1277        if (is_string($target)) {
1278            $actionDict = new PdfDictionary();
1279            $actionDict->set('Type', new PdfName('Action'));
1280            $actionDict->set('S', new PdfName('URI'));
1281            $actionDict->set('URI', new PdfString($target));
1282            $annotation->a = $actionDict;
1283        } else {
1284            $annotation->dest = $target;
1285        }
1286
1287        if ($border !== null) {
1288            $annotation->bs = $border;
1289        }
1290
1291        $this->writer->register($annotation);
1292        $corePage->annots[] = new PdfReference($annotation->objectNumber);
1293
1294        return $annotation;
1295    }
1296
1297    /**
1298     * Add a sticky-note ("text") annotation â€” a small icon that opens
1299     * a popup with `$content` text when clicked. `$point` is the
1300     * lower-left corner; the rect defaults to a 16×16 box around it.
1301     */
1302    public function addStickyNote(
1303        Page|CorePage $page,
1304        float $x,
1305        float $y,
1306        string $content,
1307        ?string $iconName = null,
1308    ): TextAnnotation {
1309        $rect = new Rectangle($x, $y, 16.0, 16.0);
1310        $annotation = new TextAnnotation($this->rectToPdfArray($rect));
1311        $annotation->contents = new PdfString($content);
1312        if ($iconName !== null) {
1313            $annotation->name = new PdfName($iconName);
1314        }
1315        return $this->attachAnnotation($page, $annotation);
1316    }
1317
1318    /**
1319     * Add a free-text annotation â€” text drawn directly on the page
1320     * (rather than in a popup like a sticky note). `$defaultAppearance`
1321     * is the PDF "default appearance" string controlling font + colour
1322     * (e.g. `/Helv 10 Tf 0 0 0 rg`).
1323     */
1324    public function addFreeText(
1325        Page|CorePage $page,
1326        Rectangle $rect,
1327        string $content,
1328        string $defaultAppearance = '/Helv 10 Tf 0 0 0 rg',
1329    ): FreeTextAnnotation {
1330        $annotation = new FreeTextAnnotation(
1331            $this->rectToPdfArray($rect),
1332            new PdfString($defaultAppearance),
1333        );
1334        $annotation->contents = new PdfString($content);
1335        return $this->attachAnnotation($page, $annotation);
1336    }
1337
1338    /**
1339     * Add a text-highlight annotation. `$quads` is a list of
1340     * `Rectangle`s â€” one per highlighted span (typically each text
1341     * line). The annotation's bounding rect is the union of all quads.
1342     *
1343     * @param list<Rectangle> $quads
1344     */
1345    public function addHighlight(Page|CorePage $page, array $quads): HighlightAnnotation
1346    {
1347        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
1348        $annotation = new HighlightAnnotation($rectArr, $quadArr);
1349        return $this->attachAnnotation($page, $annotation);
1350    }
1351
1352    /**
1353     * Add an underline annotation â€” visually similar to highlight but
1354     * draws a line under each text span. `$quads` is one rect per span.
1355     *
1356     * @param list<Rectangle> $quads
1357     */
1358    public function addUnderlineAnnotation(Page|CorePage $page, array $quads): UnderlineAnnotation
1359    {
1360        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
1361        $annotation = new UnderlineAnnotation($rectArr);
1362        $annotation->quadPoints = $quadArr;
1363        return $this->attachAnnotation($page, $annotation);
1364    }
1365
1366    /**
1367     * Add a squiggly-underline annotation â€” wavy line below each text span.
1368     *
1369     * @param list<Rectangle> $quads
1370     */
1371    public function addSquiggly(Page|CorePage $page, array $quads): SquigglyAnnotation
1372    {
1373        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
1374        $annotation = new SquigglyAnnotation($rectArr);
1375        $annotation->quadPoints = $quadArr;
1376        return $this->attachAnnotation($page, $annotation);
1377    }
1378
1379    /**
1380     * Add a strikeout annotation â€” line through each text span.
1381     *
1382     * @param list<Rectangle> $quads
1383     */
1384    public function addStrikeout(Page|CorePage $page, array $quads): StrikeOutAnnotation
1385    {
1386        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
1387        $annotation = new StrikeOutAnnotation($rectArr);
1388        $annotation->quadPoints = $quadArr;
1389        return $this->attachAnnotation($page, $annotation);
1390    }
1391
1392    /**
1393     * Add a caret annotation â€” small upward-pointing wedge typically
1394     * used to mark an insertion point.
1395     */
1396    public function addCaret(Page|CorePage $page, Rectangle $rect): CaretAnnotation
1397    {
1398        $annotation = new CaretAnnotation($this->rectToPdfArray($rect));
1399        return $this->attachAnnotation($page, $annotation);
1400    }
1401
1402    /**
1403     * Add a free-form ink annotation. `$paths` is a list of strokes,
1404     * each stroke a flat list of `[x0, y0, x1, y1, ...]` points.
1405     *
1406     * @param list<list<float>> $paths
1407     */
1408    public function addInk(Page|CorePage $page, array $paths): InkAnnotation
1409    {
1410        $minX = PHP_FLOAT_MAX;
1411        $minY = PHP_FLOAT_MAX;
1412        $maxX = -PHP_FLOAT_MAX;
1413        $maxY = -PHP_FLOAT_MAX;
1414        $inkPaths = [];
1415        foreach ($paths as $path) {
1416            $pdfPath = [];
1417            $count = count($path);
1418            for ($i = 0; $i + 1 < $count; $i += 2) {
1419                $x = (float) $path[$i];
1420                $y = (float) $path[$i + 1];
1421                $minX = min($minX, $x);
1422                $minY = min($minY, $y);
1423                $maxX = max($maxX, $x);
1424                $maxY = max($maxY, $y);
1425                $pdfPath[] = new PdfNumber($x);
1426                $pdfPath[] = new PdfNumber($y);
1427            }
1428            $inkPaths[] = new PdfArray($pdfPath);
1429        }
1430        if ($minX === PHP_FLOAT_MAX) {
1431            $minX = $minY = $maxX = $maxY = 0.0;
1432        }
1433        $rectArr = new PdfArray([
1434            new PdfNumber($minX),
1435            new PdfNumber($minY),
1436            new PdfNumber($maxX),
1437            new PdfNumber($maxY),
1438        ]);
1439        $annotation = new InkAnnotation($rectArr, new PdfArray($inkPaths));
1440        return $this->attachAnnotation($page, $annotation);
1441    }
1442
1443    /**
1444     * Add a line annotation between two points.
1445     */
1446    public function addLineAnnotation(
1447        Page|CorePage $page,
1448        float $x1,
1449        float $y1,
1450        float $x2,
1451        float $y2,
1452    ): LineAnnotation {
1453        $rectArr = new PdfArray([
1454            new PdfNumber(min($x1, $x2)),
1455            new PdfNumber(min($y1, $y2)),
1456            new PdfNumber(max($x1, $x2)),
1457            new PdfNumber(max($y1, $y2)),
1458        ]);
1459        $annotation = new LineAnnotation($rectArr);
1460        $annotation->l = new PdfArray([
1461            new PdfNumber($x1),
1462            new PdfNumber($y1),
1463            new PdfNumber($x2),
1464            new PdfNumber($y2),
1465        ]);
1466        return $this->attachAnnotation($page, $annotation);
1467    }
1468
1469    /**
1470     * Add a polygon annotation. `$points` is a list of `[x, y]` pairs;
1471     * the polygon is implicitly closed back to the first vertex.
1472     *
1473     * @param list<array{float,float}> $points
1474     */
1475    public function addPolygon(Page|CorePage $page, array $points): PolygonAnnotation
1476    {
1477        [$rectArr, $vertices] = $this->pointsToRectAndArray($points);
1478        $annotation = new PolygonAnnotation($rectArr);
1479        $annotation->vertices = $vertices;
1480        return $this->attachAnnotation($page, $annotation);
1481    }
1482
1483    /**
1484     * Add a polyline annotation â€” like a polygon but open (last point
1485     * does not connect back to the first).
1486     *
1487     * @param list<array{float,float}> $points
1488     */
1489    public function addPolyline(Page|CorePage $page, array $points): PolyLineAnnotation
1490    {
1491        [$rectArr, $vertices] = $this->pointsToRectAndArray($points);
1492        $annotation = new PolyLineAnnotation($rectArr);
1493        $annotation->vertices = $vertices;
1494        return $this->attachAnnotation($page, $annotation);
1495    }
1496
1497    /**
1498     * Add a rectangular shape annotation â€” visible as a stroked
1499     * rectangle on the page.
1500     */
1501    public function addSquare(Page|CorePage $page, Rectangle $rect): SquareAnnotation
1502    {
1503        $annotation = new SquareAnnotation($this->rectToPdfArray($rect));
1504        return $this->attachAnnotation($page, $annotation);
1505    }
1506
1507    /**
1508     * Add a circular / elliptical shape annotation â€” visible as a
1509     * stroked ellipse inscribed in the rectangle.
1510     */
1511    public function addCircleAnnotation(Page|CorePage $page, Rectangle $rect): CircleAnnotation
1512    {
1513        $annotation = new CircleAnnotation($this->rectToPdfArray($rect));
1514        return $this->attachAnnotation($page, $annotation);
1515    }
1516
1517    /**
1518     * Add a rubber-stamp annotation. `$stampName` is the standard
1519     * stamp identifier (`Approved`, `Confidential`, `Draft`, etc.).
1520     */
1521    public function addStamp(
1522        Page|CorePage $page,
1523        Rectangle $rect,
1524        string $stampName = 'Draft',
1525    ): StampAnnotation {
1526        $annotation = new StampAnnotation($this->rectToPdfArray($rect));
1527        $annotation->name = new PdfName($stampName);
1528        return $this->attachAnnotation($page, $annotation);
1529    }
1530
1531    /**
1532     * Add a watermark annotation â€” fixed page-level overlay that
1533     * doesn't print by default (PDF 1.7).
1534     */
1535    public function addWatermarkAnnotation(Page|CorePage $page, Rectangle $rect): WatermarkAnnotation
1536    {
1537        $annotation = new WatermarkAnnotation($this->rectToPdfArray($rect));
1538        return $this->attachAnnotation($page, $annotation);
1539    }
1540
1541    /**
1542     * Add a sound annotation. Caller supplies a pre-constructed
1543     * {@see \Phpdftk\Pdf\Core\Multimedia\Sound} stream (with sample
1544     * rate + bytes). Deprecated in PDF 2.0 â€” prefer Rich Media for
1545     * new documents.
1546     */
1547    public function addSoundAnnotation(
1548        Page|CorePage $page,
1549        Rectangle $rect,
1550        \Phpdftk\Pdf\Core\Multimedia\Sound $sound,
1551    ): SoundAnnotation {
1552        $this->writer->register($sound);
1553        $annotation = new SoundAnnotation($this->rectToPdfArray($rect));
1554        $annotation->sound = new PdfReference($sound->objectNumber);
1555        return $this->attachAnnotation($page, $annotation);
1556    }
1557
1558    /**
1559     * Add a movie annotation. Deprecated in PDF 2.0 in favour of
1560     * Rich Media / Screen annotations; provided for legacy workflows.
1561     */
1562    public function addMovieAnnotation(
1563        Page|CorePage $page,
1564        Rectangle $rect,
1565        \Phpdftk\Pdf\Core\Multimedia\Movie $movie,
1566    ): MovieAnnotation {
1567        $this->writer->register($movie);
1568        $annotation = new MovieAnnotation($this->rectToPdfArray($rect));
1569        $annotation->movie = new PdfReference($movie->objectNumber);
1570        return $this->attachAnnotation($page, $annotation);
1571    }
1572
1573    /**
1574     * Add a 3D annotation. Caller supplies a pre-constructed
1575     * {@see \Phpdftk\Pdf\Core\ThreeD\ThreeDStream} containing the U3D
1576     * or PRC payload.
1577     */
1578    public function add3DAnnotation(
1579        Page|CorePage $page,
1580        Rectangle $rect,
1581        \Phpdftk\Pdf\Core\ThreeD\ThreeDStream $stream,
1582    ): ThreeDAnnotation {
1583        $this->writer->register($stream);
1584        $annotation = new ThreeDAnnotation($this->rectToPdfArray($rect));
1585        $annotation->dd = new PdfReference($stream->objectNumber);
1586        return $this->attachAnnotation($page, $annotation);
1587    }
1588
1589    /**
1590     * @template T of CoreAnnotation
1591     * @param T $annotation
1592     * @return T
1593     */
1594    private function attachAnnotation(Page|CorePage $page, CoreAnnotation $annotation): CoreAnnotation
1595    {
1596        $corePage = $page instanceof Page ? $page->corePage() : $page;
1597        $this->writer->register($annotation);
1598        $corePage->annots[] = new PdfReference($annotation->objectNumber);
1599        return $annotation;
1600    }
1601
1602    private function rectToPdfArray(Rectangle $rect): PdfArray
1603    {
1604        [$llx, $lly, $urx, $ury] = $rect->toArray();
1605        return new PdfArray([
1606            new PdfNumber($llx),
1607            new PdfNumber($lly),
1608            new PdfNumber($urx),
1609            new PdfNumber($ury),
1610        ]);
1611    }
1612
1613    /**
1614     * Convert a list of Rectangles representing text-markup spans
1615     * into the bounding rect + quad-points array required by
1616     * highlight / underline / squiggly / strikeout annotations.
1617     *
1618     * @param list<Rectangle> $quads
1619     * @return array{0: PdfArray, 1: PdfArray}
1620     */
1621    private function quadsToArrays(array $quads): array
1622    {
1623        if ($quads === []) {
1624            throw new \InvalidArgumentException('At least one quad rectangle is required.');
1625        }
1626        $minX = PHP_FLOAT_MAX;
1627        $minY = PHP_FLOAT_MAX;
1628        $maxX = -PHP_FLOAT_MAX;
1629        $maxY = -PHP_FLOAT_MAX;
1630        $quadPoints = [];
1631        foreach ($quads as $q) {
1632            [$llx, $lly, $urx, $ury] = $q->toArray();
1633            $minX = min($minX, $llx);
1634            $minY = min($minY, $lly);
1635            $maxX = max($maxX, $urx);
1636            $maxY = max($maxY, $ury);
1637            // PDF QuadPoints order: ULx ULy URx URy LLx LLy LRx LRy.
1638            array_push(
1639                $quadPoints,
1640                new PdfNumber($llx),
1641                new PdfNumber($ury),
1642                new PdfNumber($urx),
1643                new PdfNumber($ury),
1644                new PdfNumber($llx),
1645                new PdfNumber($lly),
1646                new PdfNumber($urx),
1647                new PdfNumber($lly),
1648            );
1649        }
1650        $rectArr = new PdfArray([
1651            new PdfNumber($minX),
1652            new PdfNumber($minY),
1653            new PdfNumber($maxX),
1654            new PdfNumber($maxY),
1655        ]);
1656        return [$rectArr, new PdfArray($quadPoints)];
1657    }
1658
1659    /**
1660     * @param list<array{float,float}> $points
1661     * @return array{0: PdfArray, 1: PdfArray}
1662     */
1663    private function pointsToRectAndArray(array $points): array
1664    {
1665        if ($points === []) {
1666            throw new \InvalidArgumentException('At least one point is required.');
1667        }
1668        $minX = PHP_FLOAT_MAX;
1669        $minY = PHP_FLOAT_MAX;
1670        $maxX = -PHP_FLOAT_MAX;
1671        $maxY = -PHP_FLOAT_MAX;
1672        $flat = [];
1673        foreach ($points as [$x, $y]) {
1674            $minX = min($minX, $x);
1675            $minY = min($minY, $y);
1676            $maxX = max($maxX, $x);
1677            $maxY = max($maxY, $y);
1678            $flat[] = new PdfNumber($x);
1679            $flat[] = new PdfNumber($y);
1680        }
1681        $rectArr = new PdfArray([
1682            new PdfNumber($minX),
1683            new PdfNumber($minY),
1684            new PdfNumber($maxX),
1685            new PdfNumber($maxY),
1686        ]);
1687        return [$rectArr, new PdfArray($flat)];
1688    }
1689
1690    // -----------------------------------------------------------------------
1691    // Navigation: outlines, page labels, named destinations
1692    // -----------------------------------------------------------------------
1693
1694    /**
1695     * Register an Outline root and wire it to the Catalog. Returns the
1696     * Outline for further configuration (setting First/Last/Count).
1697     */
1698    public function setOutline(Outline $outline): Outline
1699    {
1700        $this->writer->register($outline);
1701        $this->writer->getCatalog()->outlines = new PdfReference($outline->objectNumber);
1702        return $outline;
1703    }
1704
1705    /**
1706     * Register an OutlineItem and return a reference to it. Callers
1707     * are responsible for linking Prev/Next/First/Last/Parent.
1708     */
1709    public function addOutlineItem(OutlineItem $item): PdfReference
1710    {
1711        return $this->writer->register($item);
1712    }
1713
1714    /**
1715     * Set a flat page-labels number tree on the Catalog. Pass an
1716     * associative array of zero-based page index => PageLabel.
1717     *
1718     * Example: [0 => $frontMatter, 4 => $mainContent]
1719     *
1720     * @param array<int, PageLabel> $labels
1721     */
1722    public function setPageLabels(array $labels): self
1723    {
1724        $nums = [];
1725        ksort($labels);
1726        foreach ($labels as $pageIndex => $label) {
1727            $this->writer->register($label);
1728            $nums[] = new PdfNumber($pageIndex);
1729            $nums[] = new PdfReference($label->objectNumber);
1730        }
1731
1732        $tree = new PdfDictionary(['Nums' => new PdfArray($nums)]);
1733        $treeStream = new PdfStream($tree, '');
1734        $this->writer->register($treeStream);
1735        $this->writer->getCatalog()->pageLabels = new PdfReference($treeStream->objectNumber);
1736        return $this;
1737    }
1738
1739    /**
1740     * Set named destinations on the document. Pass an associative
1741     * array of name => Destination.
1742     *
1743     * @param array<string, Destination> $destinations
1744     */
1745    public function setNamedDestinations(array $destinations): self
1746    {
1747        ksort($destinations);
1748        $namesArray = [];
1749        foreach ($destinations as $name => $dest) {
1750            $namesArray[] = new PdfString($name);
1751            $namesArray[] = $dest;
1752        }
1753
1754        $nameTree = new NameTree();
1755        $nameTree->names = new PdfArray($namesArray);
1756        $this->writer->register($nameTree);
1757
1758        $namesDict = new PdfDictionary(['Dests' => new PdfReference($nameTree->objectNumber)]);
1759        $namesDictObj = new PdfStream($namesDict, '');
1760        $this->writer->register($namesDictObj);
1761        $this->writer->getCatalog()->names = new PdfReference($namesDictObj->objectNumber);
1762        return $this;
1763    }
1764}