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