Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.63% covered (success)
94.63%
458 / 484
86.67% covered (warning)
86.67%
52 / 60
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfDoc
94.63% covered (success)
94.63%
458 / 484
86.67% covered (warning)
86.67%
52 / 60
105.68
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%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 addRadialGradient
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 buildRgbFunction
100.00% covered (success)
100.00%
21 / 21
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
 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\Pattern\ShadingPattern;
53use Phpdftk\Pdf\Core\Graphics\Shading\ShadingType2;
54use Phpdftk\Pdf\Core\Graphics\Shading\ShadingType3;
55use Phpdftk\Pdf\Core\Annotation\WidgetAnnotation;
56use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
57use Phpdftk\Pdf\Core\Interactive\Form\AcroForm;
58use Phpdftk\Pdf\Core\Interactive\Form\ButtonField;
59use Phpdftk\Pdf\Core\Interactive\Form\ChoiceField;
60use Phpdftk\Pdf\Core\Interactive\Form\SignatureField;
61use Phpdftk\Pdf\Core\Interactive\Form\TextField;
62use Phpdftk\Pdf\Writer\Form\CheckboxOptions;
63use Phpdftk\Pdf\Writer\Form\ChoiceFieldOptions;
64use Phpdftk\Pdf\Writer\Form\TextFieldOptions;
65use Phpdftk\Pdf\Core\PdfArray;
66use Phpdftk\Pdf\Core\PdfDictionary;
67use Phpdftk\Pdf\Core\PdfName;
68use Phpdftk\Pdf\Core\PdfNumber;
69use Phpdftk\Pdf\Core\PdfReference;
70use Phpdftk\Pdf\Core\PdfStream;
71use Phpdftk\Pdf\Core\PdfString;
72use Phpdftk\Pdf\Core\PdfVersion;
73
74/**
75 * Level 2 â€” friendly API over the PDF document object model.
76 *
77 * `PdfDoc` wraps a {@see PdfWriter} and exposes one method per "thing
78 * a user wants to put in a document": pages, outlines, page labels,
79 * named destinations, info/metadata. Later phases extend this with
80 * annotation builders, form field builders, file attachments, viewer
81 * preferences, action factories, layers, gradients, and more.
82 *
83 * The split between `PdfDoc` and `PdfWriter` is:
84 *   - `PdfDoc` is about *what is in the document* (Catalog conveniences)
85 *   - `PdfWriter` is about *how bytes get written* (fonts, images,
86 *     content streams, signing, encryption, conformance, save)
87 *
88 * Drop down to the underlying {@see PdfWriter} via {@see writer()}
89 * when you need direct byte/resource control (custom fonts,
90 * encryption, etc.).
91 *
92 * @api
93 */
94class PdfDoc
95{
96    private PdfWriter $writer;
97
98    /** Lazily-created OCPropertiesDict, shared across {@see addLayer()} calls. */
99    private ?OCPropertiesDict $ocPropertiesDict = null;
100
101    /** Lazily-created AcroForm, shared across all form-field builders. */
102    private ?AcroForm $acroForm = null;
103
104    public function __construct(
105        bool $compressStreams = true,
106        PdfVersion|string $version = PdfFileWriter::DEFAULT_PDF_VERSION,
107    ) {
108        $this->writer = new PdfWriter($compressStreams, $version);
109    }
110
111    /**
112     * Wrap an existing PdfWriter â€” typically one already configured
113     * with conformance, signing, or encryption â€” and expose the
114     * friendly API on top of it.
115     */
116    public static function wrap(PdfWriter $writer): self
117    {
118        $instance = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor();
119        $instance->writer = $writer;
120        return $instance;
121    }
122
123    /**
124     * Escape hatch: the underlying PdfWriter for byte/resource control.
125     */
126    public function writer(): PdfWriter
127    {
128        return $this->writer;
129    }
130
131    /**
132     * Add a new page. Friendly wrapper that returns the same
133     * {@see Page} handle as PdfWriter::addPage().
134     */
135    public function addPage(Rectangle|float $widthOrRect = 612, float $height = 792): Page
136    {
137        return $this->writer->addPage($widthOrRect, $height);
138    }
139
140    // -----------------------------------------------------------------------
141    // Document metadata (Info dict + XMP)
142    // -----------------------------------------------------------------------
143
144    public function setInfo(Info $info): self
145    {
146        $this->writer->fileWriter()->setInfo($info);
147        return $this;
148    }
149
150    public function setTitle(string $title): self
151    {
152        $this->ensureInfo()->title = new PdfString($title);
153        return $this;
154    }
155
156    public function setAuthor(string $author): self
157    {
158        $this->ensureInfo()->author = new PdfString($author);
159        return $this;
160    }
161
162    public function setSubject(string $subject): self
163    {
164        $this->ensureInfo()->subject = new PdfString($subject);
165        return $this;
166    }
167
168    public function setKeywords(string $keywords): self
169    {
170        $this->ensureInfo()->keywords = new PdfString($keywords);
171        return $this;
172    }
173
174    public function setCreator(string $creator): self
175    {
176        $this->ensureInfo()->creator = new PdfString($creator);
177        return $this;
178    }
179
180    private function ensureInfo(): Info
181    {
182        $info = $this->writer->fileWriter()->getInfo();
183        if ($info === null) {
184            $info = new Info();
185            $this->writer->fileWriter()->setInfo($info);
186        }
187        return $info;
188    }
189
190    /**
191     * Attach an XMP metadata stream to the document catalog.
192     */
193    public function setMetadata(string $xmpXml): self
194    {
195        $metadataStream = new MetadataStream($xmpXml);
196        $this->writer->register($metadataStream);
197        $this->writer->getCatalog()->metadata = new PdfReference($metadataStream->objectNumber);
198        return $this;
199    }
200
201    /**
202     * Build and attach XMP metadata from the document's Info dictionary.
203     *
204     * Syncs Title, Author, Subject, Creator, Producer from the Info
205     * dict into XMP properties (dc:title, dc:creator, dc:description,
206     * xmp:CreatorTool, pdf:Producer) and attaches the result as a
207     * MetadataStream on the Catalog.
208     */
209    public function syncInfoToMetadata(): self
210    {
211        $info = $this->writer->fileWriter()->getInfo();
212        if ($info === null) {
213            return $this;
214        }
215
216        $packet = \Phpdftk\Xmp\XmpPacket::create();
217        if ($info->title !== null) {
218            $packet = $packet->set('dc:title', $info->title->value);
219        }
220        if ($info->author !== null) {
221            $packet = $packet->set('dc:creator', $info->author->value);
222        }
223        if ($info->subject !== null) {
224            $packet = $packet->set('dc:description', $info->subject->value);
225        }
226        if ($info->creator !== null) {
227            $packet = $packet->set('xmp:CreatorTool', $info->creator->value);
228        }
229        if ($info->producer !== null) {
230            $packet = $packet->set('pdf:Producer', $info->producer->value);
231        }
232
233        $xmpXml = (new \Phpdftk\Xmp\XmpWriter())->serialize($packet);
234        return $this->setMetadata($xmpXml);
235    }
236
237    // -----------------------------------------------------------------------
238    // Form fields
239    // -----------------------------------------------------------------------
240
241    /**
242     * Add a single-line (or multi-line) text input to the page. The
243     * field is registered in the document's AcroForm and a Widget
244     * annotation is attached to the page's `/Annots` array.
245     *
246     * Field flags (`/Ff`) are derived from {@see TextFieldOptions}:
247     *   - required â†’ bit 2 (0x0002)
248     *   - readOnly â†’ bit 1 (0x0001)
249     *   - multiline â†’ bit 13 (0x1000)
250     *   - password  â†’ bit 14 (0x2000)
251     */
252    public function addTextField(
253        string $name,
254        Page|CorePage $page,
255        Rectangle $rect,
256        ?TextFieldOptions $options = null,
257    ): TextField {
258        $options ??= new TextFieldOptions();
259        $field = new TextField();
260        $field->t = new PdfString($name);
261        $field->da = new PdfString($options->defaultAppearance);
262        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly)
263            | ($options->multiline ? 1 << 12 : 0)
264            | ($options->password ? 1 << 13 : 0);
265        if ($options->maxLength !== null) {
266            $field->maxLen = $options->maxLength;
267        }
268        if ($options->defaultValue !== null) {
269            $field->v = new PdfString($options->defaultValue);
270            $field->dv = new PdfString($options->defaultValue);
271        }
272        $this->attachFieldWidget($page, $rect, $field);
273        return $field;
274    }
275
276    /**
277     * Add a checkbox to the page. The export value (the string
278     * recorded as the field's `/V` when checked) defaults to `Yes`.
279     */
280    public function addCheckbox(
281        string $name,
282        Page|CorePage $page,
283        Rectangle $rect,
284        ?CheckboxOptions $options = null,
285    ): ButtonField {
286        $options ??= new CheckboxOptions();
287        $field = new ButtonField();
288        $field->t = new PdfString($name);
289        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly);
290        if ($options->defaultChecked) {
291            $field->v = new PdfName($options->onValue);
292            $field->dv = new PdfName($options->onValue);
293        } else {
294            $field->v = new PdfName('Off');
295            $field->dv = new PdfName('Off');
296        }
297        $this->attachFieldWidget($page, $rect, $field);
298        return $field;
299    }
300
301    /**
302     * Add a drop-down (combo) or list-box choice field. `$options`
303     * carries the list of allowed `[value, label]` choices and the
304     * usual required / read-only flags.
305     */
306    public function addChoiceField(
307        string $name,
308        Page|CorePage $page,
309        Rectangle $rect,
310        ChoiceFieldOptions $options,
311    ): ChoiceField {
312        $field = new ChoiceField();
313        $field->t = new PdfString($name);
314        $field->ff = $this->computeFieldFlags($options->required, $options->readOnly)
315            | ($options->combo ? 1 << 17 : 0)
316            | ($options->editable ? 1 << 18 : 0)
317            | ($options->sort ? 1 << 19 : 0)
318            | ($options->multiSelect ? 1 << 21 : 0);
319
320        $optItems = [];
321        foreach ($options->choices as $choice) {
322            if (is_array($choice)) {
323                $optItems[] = new PdfArray([
324                    new PdfString($choice[0]),
325                    new PdfString($choice[1]),
326                ]);
327            } else {
328                $optItems[] = new PdfString($choice);
329            }
330        }
331        $field->opt = new PdfArray($optItems);
332        if ($options->defaultValue !== null) {
333            $field->v = new PdfString($options->defaultValue);
334            $field->dv = new PdfString($options->defaultValue);
335        }
336        $this->attachFieldWidget($page, $rect, $field);
337        return $field;
338    }
339
340    /**
341     * Add a signature field placeholder. Pair with
342     * {@see PdfWriter::setSigner()} to actually sign the document at
343     * generate time.
344     */
345    public function addSignatureField(
346        string $name,
347        Page|CorePage $page,
348        Rectangle $rect,
349    ): SignatureField {
350        $field = new SignatureField();
351        $field->t = new PdfString($name);
352        $this->attachFieldWidget($page, $rect, $field);
353
354        // Ensure the AcroForm declares /SigFlags = 3 (SignaturesExist
355        // + AppendOnly) so viewers handle the file as signed-ready.
356        $acroForm = $this->ensureAcroForm();
357        $acroForm->sigFlags = ($acroForm->sigFlags ?? 0) | 3;
358        return $field;
359    }
360
361    private function computeFieldFlags(bool $required, bool $readOnly): int
362    {
363        return ($readOnly ? 1 : 0) | ($required ? 2 : 0);
364    }
365
366    /**
367     * Wire a field into both the AcroForm fields list and a Widget
368     * annotation on the page. The Widget references the field as its
369     * /Parent; the field's /Kids list points back at the widget.
370     */
371    private function attachFieldWidget(
372        Page|CorePage $page,
373        Rectangle $rect,
374        \Phpdftk\Pdf\Core\Interactive\Form\Field $field,
375    ): void {
376        $widget = new WidgetAnnotation($this->rectToPdfArray($rect));
377        $this->writer->register($widget);
378        $this->writer->register($field);
379
380        $field->kids[] = new PdfReference($widget->objectNumber);
381        $widget->parent = new PdfReference($field->objectNumber);
382
383        $corePage = $page instanceof Page ? $page->corePage() : $page;
384        $corePage->annots[] = new PdfReference($widget->objectNumber);
385
386        $acroForm = $this->ensureAcroForm();
387        $acroForm->fields[] = new PdfReference($field->objectNumber);
388    }
389
390    private function ensureAcroForm(): AcroForm
391    {
392        if ($this->acroForm !== null) {
393            return $this->acroForm;
394        }
395        $form = new AcroForm();
396        // NeedAppearances asks viewers to generate widget appearances
397        // on open â€” appropriate when the writer doesn't pre-build /AP.
398        $form->needAppearances = true;
399        $this->writer->register($form);
400        $this->writer->getCatalog()->acroForm = new PdfReference($form->objectNumber);
401        $this->acroForm = $form;
402        return $form;
403    }
404
405    // -----------------------------------------------------------------------
406    // Gradients
407    // -----------------------------------------------------------------------
408
409    /**
410     * Register a two-stop axial (linear) gradient. The returned
411     * {@see ShadingPattern} can be used as a fill via
412     * {@see Writer\Page::useGradient()}.
413     *
414     * @param array{float,float,float} $startRgb RGB at gradient origin.
415     * @param array{float,float,float} $endRgb   RGB at gradient end.
416     */
417    public function addLinearGradient(Point $from, Point $to, array $startRgb, array $endRgb): ShadingPattern
418    {
419        $fn = $this->buildRgbFunction($startRgb, $endRgb);
420        $shading = new ShadingType2(
421            new PdfName('DeviceRGB'),
422            new PdfArray([
423                new PdfNumber($from->x),
424                new PdfNumber($from->y),
425                new PdfNumber($to->x),
426                new PdfNumber($to->y),
427            ]),
428            new PdfReference($fn->objectNumber),
429        );
430        $this->writer->register($shading);
431
432        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
433        $this->writer->register($pattern);
434        return $pattern;
435    }
436
437    /**
438     * Register a two-stop radial gradient. `$inner` / `$outer` are
439     * concentric (or non-concentric) circles defining the gradient
440     * boundary.
441     *
442     * @param array{float,float,float} $startRgb RGB at inner radius.
443     * @param array{float,float,float} $endRgb   RGB at outer radius.
444     */
445    public function addRadialGradient(
446        Point $innerCenter,
447        float $innerRadius,
448        Point $outerCenter,
449        float $outerRadius,
450        array $startRgb,
451        array $endRgb,
452    ): ShadingPattern {
453        $fn = $this->buildRgbFunction($startRgb, $endRgb);
454        $shading = new ShadingType3(
455            new PdfName('DeviceRGB'),
456            new PdfArray([
457                new PdfNumber($innerCenter->x),
458                new PdfNumber($innerCenter->y),
459                new PdfNumber($innerRadius),
460                new PdfNumber($outerCenter->x),
461                new PdfNumber($outerCenter->y),
462                new PdfNumber($outerRadius),
463            ]),
464            new PdfReference($fn->objectNumber),
465        );
466        $this->writer->register($shading);
467
468        $pattern = new ShadingPattern(new PdfReference($shading->objectNumber));
469        $this->writer->register($pattern);
470        return $pattern;
471    }
472
473    /**
474     * @param array{float,float,float} $startRgb
475     * @param array{float,float,float} $endRgb
476     */
477    private function buildRgbFunction(array $startRgb, array $endRgb): FunctionType2
478    {
479        $fn = new FunctionType2(
480            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
481            c0: new PdfArray([
482                new PdfNumber($startRgb[0]),
483                new PdfNumber($startRgb[1]),
484                new PdfNumber($startRgb[2]),
485            ]),
486            c1: new PdfArray([
487                new PdfNumber($endRgb[0]),
488                new PdfNumber($endRgb[1]),
489                new PdfNumber($endRgb[2]),
490            ]),
491            n: 1.0,
492        );
493        $fn->range = new PdfArray([
494            new PdfNumber(0.0), new PdfNumber(1.0),
495            new PdfNumber(0.0), new PdfNumber(1.0),
496            new PdfNumber(0.0), new PdfNumber(1.0),
497        ]);
498        $this->writer->register($fn);
499        return $fn;
500    }
501
502    // -----------------------------------------------------------------------
503    // Spot colors
504    // -----------------------------------------------------------------------
505
506    /**
507     * Register a spot color (a {@see Separation} color space). The
508     * `$cmykTint` parameter specifies the device-CMYK approximation
509     * used by viewers that don't have the spot ink â€” values are 0–1.
510     *
511     * Use the returned `Separation` with
512     * {@see Writer\Page::useSpotColor()} to attach it to a page's
513     * resources and obtain the resource name for content-stream ops:
514     *
515     *   $sep = $doc->registerSpotColor('Pantone 185 C', [0, 0.85, 0.6, 0]);
516     *   $name = $page->useSpotColor($sep);
517     *   $page->contentStream()
518     *       ->setFillColorSpace($name)
519     *       ->setFillColor(1.0)   // full tint
520     *       ->rectangle(72, 600, 200, 80)
521     *       ->fill();
522     *
523     * @param array{float,float,float,float} $cmykTint
524     */
525    public function registerSpotColor(string $name, array $cmykTint): SpotColor
526    {
527        $tintFn = new FunctionType2(
528            domain: new PdfArray([new PdfNumber(0.0), new PdfNumber(1.0)]),
529            c0: new PdfArray([
530                new PdfNumber(0.0),
531                new PdfNumber(0.0),
532                new PdfNumber(0.0),
533                new PdfNumber(0.0),
534            ]),
535            c1: new PdfArray([
536                new PdfNumber($cmykTint[0]),
537                new PdfNumber($cmykTint[1]),
538                new PdfNumber($cmykTint[2]),
539                new PdfNumber($cmykTint[3]),
540            ]),
541            n: 1.0,
542        );
543        $tintFn->range = new PdfArray([
544            new PdfNumber(0.0),
545            new PdfNumber(1.0),
546            new PdfNumber(0.0),
547            new PdfNumber(1.0),
548            new PdfNumber(0.0),
549            new PdfNumber(1.0),
550            new PdfNumber(0.0),
551            new PdfNumber(1.0),
552        ]);
553        $this->writer->register($tintFn);
554
555        $separation = new Separation(
556            new PdfName($name),
557            new PdfName('DeviceCMYK'),
558            new PdfReference($tintFn->objectNumber),
559        );
560        // `Separation` is a value type (implements Serializable) â€” it's
561        // inlined into the page's /Resources /ColorSpace entry rather
562        // than registered as an indirect object.
563        return new SpotColor($name, $separation);
564    }
565
566    // -----------------------------------------------------------------------
567    // Barcodes
568    // -----------------------------------------------------------------------
569
570    /**
571     * Build a reusable {@see FormXObject} containing a barcode
572     * rendering. The resulting template can be placed on multiple
573     * pages via `Writer\Page::drawTemplate()`.
574     *
575     * Only `Symbology::Code128` is implemented in v1; other cases
576     * throw at render time.
577     */
578    public function createBarcode(
579        Symbology $symbology,
580        string $data,
581        ?BarcodeOptions $options = null,
582    ): FormXObject {
583        $options ??= new BarcodeOptions();
584        $bitmap = BarcodeRenderer::render($symbology, $data, $options);
585
586        return $this->createTemplate(
587            new Rectangle(0.0, 0.0, $bitmap->totalWidth(), $bitmap->totalHeight()),
588            function (ContentStream $cs) use ($bitmap): void {
589                BarcodeRendering::renderInto($cs, $bitmap);
590            },
591        );
592    }
593
594
595    /**
596     * Build a reusable Form XObject â€” a self-contained content stream
597     * that can be placed on multiple pages without re-emitting the
598     * underlying operators.
599     *
600     * The closure receives a fresh {@see ContentStream} sized to
601     * `$bbox` (origin at `bbox->x, bbox->y`). Any drawing operators
602     * the closure adds are captured into the FormXObject's stream;
603     * resources (fonts, images) used inside the template must be
604     * registered on the FormXObject's own resource dict â€” for v1, the
605     * caller passes pre-registered Font handles into the closure if
606     * needed and accepts that the template inherits resources from
607     * the placing page.
608     *
609     * @param \Closure(ContentStream): void $draw
610     */
611    public function createTemplate(Rectangle $bbox, \Closure $draw): FormXObject
612    {
613        [$llx, $lly, $urx, $ury] = $bbox->toArray();
614        $bboxArr = new PdfArray([
615            new PdfNumber($llx),
616            new PdfNumber($lly),
617            new PdfNumber($urx),
618            new PdfNumber($ury),
619        ]);
620
621        $cs = new ContentStream();
622        $draw($cs);
623
624        $template = new FormXObject($bboxArr, implode("\n", $cs->getOperators()));
625        // Empty Resources so the placing page contributes shared
626        // fonts / images via its own resource dict.
627        $template->resources = new Resources();
628        $this->writer->register($template);
629        return $template;
630    }
631
632    // -----------------------------------------------------------------------
633    // Actions
634    // -----------------------------------------------------------------------
635
636    /**
637     * Set the document's open action â€” executed by the viewer when
638     * the document is loaded. Typically used to jump to a specific
639     * page or run JavaScript on open. The action is registered as an
640     * indirect object; pass an instance from {@see Action}'s static
641     * factories.
642     */
643    public function setOpenAction(\Phpdftk\Pdf\Core\Action\Action $action): self
644    {
645        $this->writer->register($action);
646        $this->writer->getCatalog()->openAction = new PdfReference($action->objectNumber);
647        return $this;
648    }
649
650    // -----------------------------------------------------------------------
651    // Optional content (layers)
652    // -----------------------------------------------------------------------
653
654    /**
655     * Register a new optional-content group (layer). The returned
656     * `OCG` is referenced from the catalog's `/OCProperties` /OCGs
657     * array; pass it to {@see Writer\Page::inLayer()} to tag drawing
658     * operations as belonging to this layer.
659     *
660     * `$visible` controls the default state: visible layers go into
661     * the default config's `/ON` list, hidden ones into `/OFF`.
662     */
663    public function addLayer(string $name, bool $visible = true): OCG
664    {
665        $ocg = new OCG($name);
666        $this->writer->register($ocg);
667        $ref = new PdfReference($ocg->objectNumber);
668
669        $props = $this->ensureOCPropertiesDict();
670        $props->ocgs = new PdfArray([...$props->ocgs->items, $ref]);
671
672        $key = $visible ? 'ON' : 'OFF';
673        $list = $props->d->get($key);
674        $items = $list instanceof PdfArray ? $list->items : [];
675        $items[] = $ref;
676        $props->d->set($key, new PdfArray($items));
677
678        return $ocg;
679    }
680
681    private function ensureOCPropertiesDict(): OCPropertiesDict
682    {
683        if ($this->ocPropertiesDict !== null) {
684            return $this->ocPropertiesDict;
685        }
686        $defaultConfig = new PdfDictionary([
687            'Name' => new PdfString('Default'),
688            'BaseState' => new PdfName('ON'),
689            'ON' => new PdfArray([]),
690            'OFF' => new PdfArray([]),
691        ]);
692        $props = new OCPropertiesDict(new PdfArray([]), $defaultConfig);
693        $this->writer->register($props);
694        $this->writer->getCatalog()->ocProperties = new PdfReference($props->objectNumber);
695        $this->ocPropertiesDict = $props;
696        return $props;
697    }
698
699    // -----------------------------------------------------------------------
700    // File attachments
701    // -----------------------------------------------------------------------
702
703    /**
704     * Attach a file from disk. The file's bytes are read via
705     * {@see LocalFilesystem::readFile()} and embedded as an
706     * `EmbeddedFile`, wrapped in a `FileSpec`, and appended to the
707     * catalog's `/AF` (Associated Files) array.
708     *
709     * `$relationship` populates `/AFRelationship` â€” the PDF 2.0 hint
710     * to viewers about the file's role. ZUGFeRD invoices use
711     * `Alternative` for the embedded XML; common values are `Source`,
712     * `Data`, `Alternative`, `Supplement`, `EncryptedPayload`, and
713     * `FormData`.
714     */
715    public function attachFile(
716        string $path,
717        ?string $description = null,
718        ?string $mimeType = null,
719        ?string $relationship = null,
720    ): FileSpec {
721        $bytes = LocalFilesystem::readFile($path);
722        $name = basename($path);
723        return $this->attachBytes($name, $bytes, $description, $mimeType, $relationship);
724    }
725
726    /**
727     * Attach a file from in-memory bytes â€” useful when the source
728     * isn't on disk (generated XML for ZUGFeRD, downloaded content,
729     * etc.).
730     */
731    public function attachFileBytes(
732        string $name,
733        string $bytes,
734        ?string $description = null,
735        ?string $mimeType = null,
736        ?string $relationship = null,
737    ): FileSpec {
738        return $this->attachBytes($name, $bytes, $description, $mimeType, $relationship);
739    }
740
741    private function attachBytes(
742        string $name,
743        string $bytes,
744        ?string $description,
745        ?string $mimeType,
746        ?string $relationship,
747    ): FileSpec {
748        $embedded = new EmbeddedFile($bytes, $mimeType);
749        $this->writer->register($embedded);
750
751        $fileSpec = new FileSpec($name);
752        $fileSpec->attachEmbeddedFile(new PdfReference($embedded->objectNumber));
753        if ($description !== null) {
754            $fileSpec->desc = new PdfString($description);
755        }
756        if ($relationship !== null) {
757            $fileSpec->afRelationship = new PdfName($relationship);
758        }
759        $this->writer->register($fileSpec);
760
761        $catalog = $this->writer->getCatalog();
762        $existing = $catalog->af !== null ? $catalog->af->items : [];
763        $existing[] = new PdfReference($fileSpec->objectNumber);
764        $catalog->af = new PdfArray($existing);
765
766        return $fileSpec;
767    }
768
769    // -----------------------------------------------------------------------
770    // Viewer preferences
771    // -----------------------------------------------------------------------
772
773    /**
774     * Set the document's viewer preferences. Accepts either a
775     * pre-constructed {@see ViewerPreferences} object or a closure
776     * that receives a fresh instance and mutates it.
777     *
778     * Closure form:
779     *   $doc->setViewerPreferences(function (ViewerPreferences $vp): void {
780     *       $vp->displayDocTitle = true;
781     *       $vp->fitWindow = true;
782     *   });
783     */
784    public function setViewerPreferences(ViewerPreferences|\Closure $prefs): self
785    {
786        if ($prefs instanceof \Closure) {
787            $vp = new ViewerPreferences();
788            $prefs($vp);
789        } else {
790            $vp = $prefs;
791        }
792        $this->writer->register($vp);
793        $this->writer->getCatalog()->viewerPreferences = new PdfReference($vp->objectNumber);
794        return $this;
795    }
796
797    // -----------------------------------------------------------------------
798    // Annotations
799    // -----------------------------------------------------------------------
800
801    /**
802     * Add a link annotation to a page.
803     *
804     * `$target` accepts:
805     *   - **string** â€” treated as a URI; an inline /A action dict is built.
806     *   - **Destination** â€” an explicit destination (use the named
807     *     constructors `Destination::fit($pageRef)`,
808     *     `Destination::xyz(...)`, etc.).
809     *   - **PdfReference** â€” points to a named destination that has been
810     *     registered via {@see setNamedDestinations()}.
811     */
812    public function addLink(
813        Page|CorePage $page,
814        Rectangle $rect,
815        string|Destination|PdfReference $target,
816        ?BorderStyle $border = null,
817    ): LinkAnnotation {
818        $corePage = $page instanceof Page ? $page->corePage() : $page;
819
820        [$llx, $lly, $urx, $ury] = $rect->toArray();
821        $rectArray = new PdfArray([
822            new PdfNumber($llx),
823            new PdfNumber($lly),
824            new PdfNumber($urx),
825            new PdfNumber($ury),
826        ]);
827
828        $annotation = new LinkAnnotation($rectArray);
829
830        if (is_string($target)) {
831            $actionDict = new PdfDictionary();
832            $actionDict->set('Type', new PdfName('Action'));
833            $actionDict->set('S', new PdfName('URI'));
834            $actionDict->set('URI', new PdfString($target));
835            $annotation->a = $actionDict;
836        } else {
837            $annotation->dest = $target;
838        }
839
840        if ($border !== null) {
841            $annotation->bs = $border;
842        }
843
844        $this->writer->register($annotation);
845        $corePage->annots[] = new PdfReference($annotation->objectNumber);
846
847        return $annotation;
848    }
849
850    /**
851     * Add a sticky-note ("text") annotation â€” a small icon that opens
852     * a popup with `$content` text when clicked. `$point` is the
853     * lower-left corner; the rect defaults to a 16×16 box around it.
854     */
855    public function addStickyNote(
856        Page|CorePage $page,
857        float $x,
858        float $y,
859        string $content,
860        ?string $iconName = null,
861    ): TextAnnotation {
862        $rect = new Rectangle($x, $y, 16.0, 16.0);
863        $annotation = new TextAnnotation($this->rectToPdfArray($rect));
864        $annotation->contents = new PdfString($content);
865        if ($iconName !== null) {
866            $annotation->name = new PdfName($iconName);
867        }
868        return $this->attachAnnotation($page, $annotation);
869    }
870
871    /**
872     * Add a free-text annotation â€” text drawn directly on the page
873     * (rather than in a popup like a sticky note). `$defaultAppearance`
874     * is the PDF "default appearance" string controlling font + colour
875     * (e.g. `/Helv 10 Tf 0 0 0 rg`).
876     */
877    public function addFreeText(
878        Page|CorePage $page,
879        Rectangle $rect,
880        string $content,
881        string $defaultAppearance = '/Helv 10 Tf 0 0 0 rg',
882    ): FreeTextAnnotation {
883        $annotation = new FreeTextAnnotation(
884            $this->rectToPdfArray($rect),
885            new PdfString($defaultAppearance),
886        );
887        $annotation->contents = new PdfString($content);
888        return $this->attachAnnotation($page, $annotation);
889    }
890
891    /**
892     * Add a text-highlight annotation. `$quads` is a list of
893     * `Rectangle`s â€” one per highlighted span (typically each text
894     * line). The annotation's bounding rect is the union of all quads.
895     *
896     * @param list<Rectangle> $quads
897     */
898    public function addHighlight(Page|CorePage $page, array $quads): HighlightAnnotation
899    {
900        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
901        $annotation = new HighlightAnnotation($rectArr, $quadArr);
902        return $this->attachAnnotation($page, $annotation);
903    }
904
905    /**
906     * Add an underline annotation â€” visually similar to highlight but
907     * draws a line under each text span. `$quads` is one rect per span.
908     *
909     * @param list<Rectangle> $quads
910     */
911    public function addUnderlineAnnotation(Page|CorePage $page, array $quads): UnderlineAnnotation
912    {
913        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
914        $annotation = new UnderlineAnnotation($rectArr);
915        $annotation->quadPoints = $quadArr;
916        return $this->attachAnnotation($page, $annotation);
917    }
918
919    /**
920     * Add a squiggly-underline annotation â€” wavy line below each text span.
921     *
922     * @param list<Rectangle> $quads
923     */
924    public function addSquiggly(Page|CorePage $page, array $quads): SquigglyAnnotation
925    {
926        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
927        $annotation = new SquigglyAnnotation($rectArr);
928        $annotation->quadPoints = $quadArr;
929        return $this->attachAnnotation($page, $annotation);
930    }
931
932    /**
933     * Add a strikeout annotation â€” line through each text span.
934     *
935     * @param list<Rectangle> $quads
936     */
937    public function addStrikeout(Page|CorePage $page, array $quads): StrikeOutAnnotation
938    {
939        [$rectArr, $quadArr] = $this->quadsToArrays($quads);
940        $annotation = new StrikeOutAnnotation($rectArr);
941        $annotation->quadPoints = $quadArr;
942        return $this->attachAnnotation($page, $annotation);
943    }
944
945    /**
946     * Add a caret annotation â€” small upward-pointing wedge typically
947     * used to mark an insertion point.
948     */
949    public function addCaret(Page|CorePage $page, Rectangle $rect): CaretAnnotation
950    {
951        $annotation = new CaretAnnotation($this->rectToPdfArray($rect));
952        return $this->attachAnnotation($page, $annotation);
953    }
954
955    /**
956     * Add a free-form ink annotation. `$paths` is a list of strokes,
957     * each stroke a flat list of `[x0, y0, x1, y1, ...]` points.
958     *
959     * @param list<list<float>> $paths
960     */
961    public function addInk(Page|CorePage $page, array $paths): InkAnnotation
962    {
963        $minX = PHP_FLOAT_MAX;
964        $minY = PHP_FLOAT_MAX;
965        $maxX = -PHP_FLOAT_MAX;
966        $maxY = -PHP_FLOAT_MAX;
967        $inkPaths = [];
968        foreach ($paths as $path) {
969            $pdfPath = [];
970            $count = count($path);
971            for ($i = 0; $i + 1 < $count; $i += 2) {
972                $x = (float) $path[$i];
973                $y = (float) $path[$i + 1];
974                $minX = min($minX, $x);
975                $minY = min($minY, $y);
976                $maxX = max($maxX, $x);
977                $maxY = max($maxY, $y);
978                $pdfPath[] = new PdfNumber($x);
979                $pdfPath[] = new PdfNumber($y);
980            }
981            $inkPaths[] = new PdfArray($pdfPath);
982        }
983        if ($minX === PHP_FLOAT_MAX) {
984            $minX = $minY = $maxX = $maxY = 0.0;
985        }
986        $rectArr = new PdfArray([
987            new PdfNumber($minX),
988            new PdfNumber($minY),
989            new PdfNumber($maxX),
990            new PdfNumber($maxY),
991        ]);
992        $annotation = new InkAnnotation($rectArr, new PdfArray($inkPaths));
993        return $this->attachAnnotation($page, $annotation);
994    }
995
996    /**
997     * Add a line annotation between two points.
998     */
999    public function addLineAnnotation(
1000        Page|CorePage $page,
1001        float $x1,
1002        float $y1,
1003        float $x2,
1004        float $y2,
1005    ): LineAnnotation {
1006        $rectArr = new PdfArray([
1007            new PdfNumber(min($x1, $x2)),
1008            new PdfNumber(min($y1, $y2)),
1009            new PdfNumber(max($x1, $x2)),
1010            new PdfNumber(max($y1, $y2)),
1011        ]);
1012        $annotation = new LineAnnotation($rectArr);
1013        $annotation->l = new PdfArray([
1014            new PdfNumber($x1),
1015            new PdfNumber($y1),
1016            new PdfNumber($x2),
1017            new PdfNumber($y2),
1018        ]);
1019        return $this->attachAnnotation($page, $annotation);
1020    }
1021
1022    /**
1023     * Add a polygon annotation. `$points` is a list of `[x, y]` pairs;
1024     * the polygon is implicitly closed back to the first vertex.
1025     *
1026     * @param list<array{float,float}> $points
1027     */
1028    public function addPolygon(Page|CorePage $page, array $points): PolygonAnnotation
1029    {
1030        [$rectArr, $vertices] = $this->pointsToRectAndArray($points);
1031        $annotation = new PolygonAnnotation($rectArr);
1032        $annotation->vertices = $vertices;
1033        return $this->attachAnnotation($page, $annotation);
1034    }
1035
1036    /**
1037     * Add a polyline annotation â€” like a polygon but open (last point
1038     * does not connect back to the first).
1039     *
1040     * @param list<array{float,float}> $points
1041     */
1042    public function addPolyline(Page|CorePage $page, array $points): PolyLineAnnotation
1043    {
1044        [$rectArr, $vertices] = $this->pointsToRectAndArray($points);
1045        $annotation = new PolyLineAnnotation($rectArr);
1046        $annotation->vertices = $vertices;
1047        return $this->attachAnnotation($page, $annotation);
1048    }
1049
1050    /**
1051     * Add a rectangular shape annotation â€” visible as a stroked
1052     * rectangle on the page.
1053     */
1054    public function addSquare(Page|CorePage $page, Rectangle $rect): SquareAnnotation
1055    {
1056        $annotation = new SquareAnnotation($this->rectToPdfArray($rect));
1057        return $this->attachAnnotation($page, $annotation);
1058    }
1059
1060    /**
1061     * Add a circular / elliptical shape annotation â€” visible as a
1062     * stroked ellipse inscribed in the rectangle.
1063     */
1064    public function addCircleAnnotation(Page|CorePage $page, Rectangle $rect): CircleAnnotation
1065    {
1066        $annotation = new CircleAnnotation($this->rectToPdfArray($rect));
1067        return $this->attachAnnotation($page, $annotation);
1068    }
1069
1070    /**
1071     * Add a rubber-stamp annotation. `$stampName` is the standard
1072     * stamp identifier (`Approved`, `Confidential`, `Draft`, etc.).
1073     */
1074    public function addStamp(
1075        Page|CorePage $page,
1076        Rectangle $rect,
1077        string $stampName = 'Draft',
1078    ): StampAnnotation {
1079        $annotation = new StampAnnotation($this->rectToPdfArray($rect));
1080        $annotation->name = new PdfName($stampName);
1081        return $this->attachAnnotation($page, $annotation);
1082    }
1083
1084    /**
1085     * Add a watermark annotation â€” fixed page-level overlay that
1086     * doesn't print by default (PDF 1.7).
1087     */
1088    public function addWatermarkAnnotation(Page|CorePage $page, Rectangle $rect): WatermarkAnnotation
1089    {
1090        $annotation = new WatermarkAnnotation($this->rectToPdfArray($rect));
1091        return $this->attachAnnotation($page, $annotation);
1092    }
1093
1094    /**
1095     * Add a sound annotation. Caller supplies a pre-constructed
1096     * {@see \Phpdftk\Pdf\Core\Multimedia\Sound} stream (with sample
1097     * rate + bytes). Deprecated in PDF 2.0 â€” prefer Rich Media for
1098     * new documents.
1099     */
1100    public function addSoundAnnotation(
1101        Page|CorePage $page,
1102        Rectangle $rect,
1103        \Phpdftk\Pdf\Core\Multimedia\Sound $sound,
1104    ): SoundAnnotation {
1105        $this->writer->register($sound);
1106        $annotation = new SoundAnnotation($this->rectToPdfArray($rect));
1107        $annotation->sound = new PdfReference($sound->objectNumber);
1108        return $this->attachAnnotation($page, $annotation);
1109    }
1110
1111    /**
1112     * Add a movie annotation. Deprecated in PDF 2.0 in favour of
1113     * Rich Media / Screen annotations; provided for legacy workflows.
1114     */
1115    public function addMovieAnnotation(
1116        Page|CorePage $page,
1117        Rectangle $rect,
1118        \Phpdftk\Pdf\Core\Multimedia\Movie $movie,
1119    ): MovieAnnotation {
1120        $this->writer->register($movie);
1121        $annotation = new MovieAnnotation($this->rectToPdfArray($rect));
1122        $annotation->movie = new PdfReference($movie->objectNumber);
1123        return $this->attachAnnotation($page, $annotation);
1124    }
1125
1126    /**
1127     * Add a 3D annotation. Caller supplies a pre-constructed
1128     * {@see \Phpdftk\Pdf\Core\ThreeD\ThreeDStream} containing the U3D
1129     * or PRC payload.
1130     */
1131    public function add3DAnnotation(
1132        Page|CorePage $page,
1133        Rectangle $rect,
1134        \Phpdftk\Pdf\Core\ThreeD\ThreeDStream $stream,
1135    ): ThreeDAnnotation {
1136        $this->writer->register($stream);
1137        $annotation = new ThreeDAnnotation($this->rectToPdfArray($rect));
1138        $annotation->dd = new PdfReference($stream->objectNumber);
1139        return $this->attachAnnotation($page, $annotation);
1140    }
1141
1142    /**
1143     * @template T of CoreAnnotation
1144     * @param T $annotation
1145     * @return T
1146     */
1147    private function attachAnnotation(Page|CorePage $page, CoreAnnotation $annotation): CoreAnnotation
1148    {
1149        $corePage = $page instanceof Page ? $page->corePage() : $page;
1150        $this->writer->register($annotation);
1151        $corePage->annots[] = new PdfReference($annotation->objectNumber);
1152        return $annotation;
1153    }
1154
1155    private function rectToPdfArray(Rectangle $rect): PdfArray
1156    {
1157        [$llx, $lly, $urx, $ury] = $rect->toArray();
1158        return new PdfArray([
1159            new PdfNumber($llx),
1160            new PdfNumber($lly),
1161            new PdfNumber($urx),
1162            new PdfNumber($ury),
1163        ]);
1164    }
1165
1166    /**
1167     * Convert a list of Rectangles representing text-markup spans
1168     * into the bounding rect + quad-points array required by
1169     * highlight / underline / squiggly / strikeout annotations.
1170     *
1171     * @param list<Rectangle> $quads
1172     * @return array{0: PdfArray, 1: PdfArray}
1173     */
1174    private function quadsToArrays(array $quads): array
1175    {
1176        if ($quads === []) {
1177            throw new \InvalidArgumentException('At least one quad rectangle is required.');
1178        }
1179        $minX = PHP_FLOAT_MAX;
1180        $minY = PHP_FLOAT_MAX;
1181        $maxX = -PHP_FLOAT_MAX;
1182        $maxY = -PHP_FLOAT_MAX;
1183        $quadPoints = [];
1184        foreach ($quads as $q) {
1185            [$llx, $lly, $urx, $ury] = $q->toArray();
1186            $minX = min($minX, $llx);
1187            $minY = min($minY, $lly);
1188            $maxX = max($maxX, $urx);
1189            $maxY = max($maxY, $ury);
1190            // PDF QuadPoints order: ULx ULy URx URy LLx LLy LRx LRy.
1191            array_push(
1192                $quadPoints,
1193                new PdfNumber($llx),
1194                new PdfNumber($ury),
1195                new PdfNumber($urx),
1196                new PdfNumber($ury),
1197                new PdfNumber($llx),
1198                new PdfNumber($lly),
1199                new PdfNumber($urx),
1200                new PdfNumber($lly),
1201            );
1202        }
1203        $rectArr = new PdfArray([
1204            new PdfNumber($minX),
1205            new PdfNumber($minY),
1206            new PdfNumber($maxX),
1207            new PdfNumber($maxY),
1208        ]);
1209        return [$rectArr, new PdfArray($quadPoints)];
1210    }
1211
1212    /**
1213     * @param list<array{float,float}> $points
1214     * @return array{0: PdfArray, 1: PdfArray}
1215     */
1216    private function pointsToRectAndArray(array $points): array
1217    {
1218        if ($points === []) {
1219            throw new \InvalidArgumentException('At least one point is required.');
1220        }
1221        $minX = PHP_FLOAT_MAX;
1222        $minY = PHP_FLOAT_MAX;
1223        $maxX = -PHP_FLOAT_MAX;
1224        $maxY = -PHP_FLOAT_MAX;
1225        $flat = [];
1226        foreach ($points as [$x, $y]) {
1227            $minX = min($minX, $x);
1228            $minY = min($minY, $y);
1229            $maxX = max($maxX, $x);
1230            $maxY = max($maxY, $y);
1231            $flat[] = new PdfNumber($x);
1232            $flat[] = new PdfNumber($y);
1233        }
1234        $rectArr = new PdfArray([
1235            new PdfNumber($minX),
1236            new PdfNumber($minY),
1237            new PdfNumber($maxX),
1238            new PdfNumber($maxY),
1239        ]);
1240        return [$rectArr, new PdfArray($flat)];
1241    }
1242
1243    // -----------------------------------------------------------------------
1244    // Navigation: outlines, page labels, named destinations
1245    // -----------------------------------------------------------------------
1246
1247    /**
1248     * Register an Outline root and wire it to the Catalog. Returns the
1249     * Outline for further configuration (setting First/Last/Count).
1250     */
1251    public function setOutline(Outline $outline): Outline
1252    {
1253        $this->writer->register($outline);
1254        $this->writer->getCatalog()->outlines = new PdfReference($outline->objectNumber);
1255        return $outline;
1256    }
1257
1258    /**
1259     * Register an OutlineItem and return a reference to it. Callers
1260     * are responsible for linking Prev/Next/First/Last/Parent.
1261     */
1262    public function addOutlineItem(OutlineItem $item): PdfReference
1263    {
1264        return $this->writer->register($item);
1265    }
1266
1267    /**
1268     * Set a flat page-labels number tree on the Catalog. Pass an
1269     * associative array of zero-based page index => PageLabel.
1270     *
1271     * Example: [0 => $frontMatter, 4 => $mainContent]
1272     *
1273     * @param array<int, PageLabel> $labels
1274     */
1275    public function setPageLabels(array $labels): self
1276    {
1277        $nums = [];
1278        ksort($labels);
1279        foreach ($labels as $pageIndex => $label) {
1280            $this->writer->register($label);
1281            $nums[] = new PdfNumber($pageIndex);
1282            $nums[] = new PdfReference($label->objectNumber);
1283        }
1284
1285        $tree = new PdfDictionary(['Nums' => new PdfArray($nums)]);
1286        $treeStream = new PdfStream($tree, '');
1287        $this->writer->register($treeStream);
1288        $this->writer->getCatalog()->pageLabels = new PdfReference($treeStream->objectNumber);
1289        return $this;
1290    }
1291
1292    /**
1293     * Set named destinations on the document. Pass an associative
1294     * array of name => Destination.
1295     *
1296     * @param array<string, Destination> $destinations
1297     */
1298    public function setNamedDestinations(array $destinations): self
1299    {
1300        ksort($destinations);
1301        $namesArray = [];
1302        foreach ($destinations as $name => $dest) {
1303            $namesArray[] = new PdfString($name);
1304            $namesArray[] = $dest;
1305        }
1306
1307        $nameTree = new NameTree();
1308        $nameTree->names = new PdfArray($namesArray);
1309        $this->writer->register($nameTree);
1310
1311        $namesDict = new PdfDictionary(['Dests' => new PdfReference($nameTree->objectNumber)]);
1312        $namesDictObj = new PdfStream($namesDict, '');
1313        $this->writer->register($namesDictObj);
1314        $this->writer->getCatalog()->names = new PdfReference($namesDictObj->objectNumber);
1315        return $this;
1316    }
1317}