Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.19% covered (warning)
89.19%
454 / 509
61.36% covered (warning)
61.36%
27 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
Page
89.19% covered (warning)
89.19%
454 / 509
61.36% covered (warning)
61.36%
27 / 44
156.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 contentStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 corePage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 drawText
67.31% covered (warning)
67.31%
35 / 52
0.00% covered (danger)
0.00%
0 / 1
24.94
 drawLine
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 drawRectangle
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 drawCircle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 drawEllipse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 drawRoundedRectangle
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 drawPolygon
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
5.16
 drawArrow
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 drawStar
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
7
 drawPath
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 drawImage
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 drawTable
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
5
 useGradient
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 useSpotColor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 drawBarcode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 drawTemplate
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 ensureTemplateResource
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 inLayer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 ensureLayerProperty
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 rotate
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
3.65
 scale
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 translate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 skew
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 withTransform
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setOpacity
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 ensureOpacityState
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 setRotation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setCropBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBleedBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTrimBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setArtBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rectToBoxArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 drawCallout
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
5
 drawQuote
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
5.04
 drawList
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 raw
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ensureContentStream
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 applyFillColor
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 applyStrokeColor
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
8.12
 paintPath
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 emitEllipseOps
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Writer;
6
7use Phpdftk\Color\ColorInterface;
8use Phpdftk\FontMetrics\StandardFontMetrics;
9use Phpdftk\Geometry\Rectangle;
10use Phpdftk\Pdf\Core\Content\ContentStream;
11use Phpdftk\Pdf\Core\Document\Page as CorePage;
12use Phpdftk\Pdf\Core\PdfArray;
13use Phpdftk\Pdf\Core\PdfReference;
14use Phpdftk\ImageMetadata\ImageParser;
15use Phpdftk\Pdf\Core\PdfDictionary;
16use Phpdftk\Pdf\Core\PdfName;
17use Phpdftk\Pdf\Core\PdfNumber;
18use Phpdftk\Pdf\Core\PdfStream;
19use Phpdftk\Pdf\Core\Graphics\ColorSpace\ICCBased;
20
21/**
22 * Level 1 Page — spatial drawing surface with explicit coordinates.
23 *
24 * Collects drawing operations and emits them to a ContentStream.
25 * Each draw method wraps its operators in a graphics state save/restore
26 * so drawings are isolated from each other.
27 *
28 * Escape hatches:
29 *   $page->contentStream()  — raw ContentStream (Level 0 operators)
30 *   $page->corePage()       — raw Core\Document\Page (Level 0 dict)
31 */
32final class Page
33{
34    private ?ContentStream $cs = null;
35
36    /** @var \Closure(PdfStream): PdfReference */
37    private \Closure $registerFn;
38
39    /** @var \Closure(string, CorePage): string */
40    private \Closure $addImageFn;
41
42    public function __construct(
43        private readonly CorePage $corePage,
44        private readonly PdfWriter $writer,
45    ) {
46        // Registration closures to avoid exposing PdfWriter internals
47        $this->registerFn = fn(PdfStream $obj): PdfReference => $this->writer->register($obj);
48        $this->addImageFn = fn(string $path, CorePage $page): string => $this->writer->addImageInternal($path, $page);
49    }
50
51    // -----------------------------------------------------------------------
52    // Escape hatches
53    // -----------------------------------------------------------------------
54
55    /**
56     * Access the raw ContentStream for Level 0 operator control.
57     */
58    public function contentStream(): ContentStream
59    {
60        return $this->ensureContentStream();
61    }
62
63    /**
64     * Access the raw Core\Document\Page for Level 0 dict manipulation.
65     */
66    public function corePage(): CorePage
67    {
68        return $this->corePage;
69    }
70
71    // -----------------------------------------------------------------------
72    // Text
73    // -----------------------------------------------------------------------
74
75    /**
76     * Draw text at a specific position.
77     */
78    public function drawText(
79        string $text,
80        float $x,
81        float $y,
82        Font $font,
83        float $size = 12,
84        ?ColorInterface $color = null,
85        bool $underline = false,
86        bool $strikethrough = false,
87    ): self {
88        if ($text === '') {
89            return $this;
90        }
91
92        $cs = $this->ensureContentStream();
93        $cs->saveGraphicsState();
94
95        if ($color !== null) {
96            $this->applyFillColor($cs, $color);
97        }
98
99        $cs->beginText()
100            ->setFont($font->getResourceName(), $size)
101            ->moveTextPosition($x, $y);
102
103        $parsedData = $font->getParsedData();
104        // For composite fonts registered via addCompositeFont, the Font
105        // handle carries a post-subset Unicode → GID map; the pre-subset
106        // map on the parsed font data points at glyphs that no longer
107        // exist in the embedded subset.
108        $unicodeToGid = $font->getUnicodeToGidMap();
109        if ($unicodeToGid === [] && $parsedData !== null) {
110            $unicodeToGid = $parsedData->fullUnicodeToGid;
111        }
112        if ($parsedData !== null && !empty($unicodeToGid)) {
113            // Unicode font — use hex encoding with optional shaping
114            if ($parsedData->ligatures !== null && $parsedData->ligatures !== []) {
115                $cs->showUnicodeTextShaped(
116                    $text,
117                    $unicodeToGid,
118                    $parsedData->ligatures,
119                    $parsedData->kernPairs ?? [],
120                    $parsedData->unitsPerEm,
121                );
122            } elseif ($parsedData->kernPairs !== null && $parsedData->kernPairs !== []) {
123                $cs->showUnicodeTextKerned(
124                    $text,
125                    $unicodeToGid,
126                    $parsedData->kernPairs,
127                    $parsedData->unitsPerEm,
128                );
129            } else {
130                $cs->showUnicodeText($text, $unicodeToGid);
131            }
132        } else {
133            // Standard font — WinAnsi encoding
134            $cs->showText($text);
135        }
136
137        $cs->endText();
138
139        if ($underline || $strikethrough) {
140            $textWidth = TextLayout::measure(
141                $text,
142                StandardFontMetrics::get($font->getFamily()),
143                $size,
144            );
145            $strokeW = max(0.5, $size * 0.05);
146            $cs->setLineWidth($strokeW);
147            if ($color !== null) {
148                $vals = $color->toArray();
149                $cs->setStrokeColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0);
150            } else {
151                $cs->setStrokeColorRGB(0.0, 0.0, 0.0);
152            }
153            if ($underline) {
154                $uy = $y - $size * 0.12;
155                $cs->moveTo($x, $uy)->lineTo($x + $textWidth, $uy)->stroke();
156            }
157            if ($strikethrough) {
158                $sy = $y + $size * 0.28;
159                $cs->moveTo($x, $sy)->lineTo($x + $textWidth, $sy)->stroke();
160            }
161        }
162
163        $cs->restoreGraphicsState();
164
165        return $this;
166    }
167
168    // -----------------------------------------------------------------------
169    // Basic shapes
170    // -----------------------------------------------------------------------
171
172    /**
173     * Draw a straight line.
174     */
175    public function drawLine(
176        float $x1,
177        float $y1,
178        float $x2,
179        float $y2,
180        ?ColorInterface $color = null,
181        float $width = 1.0,
182        ?DashPattern $dash = null,
183    ): self {
184        $cs = $this->ensureContentStream();
185        $cs->saveGraphicsState();
186
187        if ($color !== null) {
188            $this->applyStrokeColor($cs, $color);
189        }
190        $cs->setLineWidth($width);
191        if ($dash !== null && $dash->pattern !== []) {
192            $cs->setDashPattern($dash->pattern, (int) $dash->phase);
193        }
194
195        $cs->moveTo($x1, $y1)
196            ->lineTo($x2, $y2)
197            ->stroke();
198
199        $cs->restoreGraphicsState();
200        return $this;
201    }
202
203    /**
204     * Draw a rectangle.
205     */
206    public function drawRectangle(
207        float $x,
208        float $y,
209        float $width,
210        float $height,
211        ?ColorInterface $fill = null,
212        ?ColorInterface $stroke = null,
213        float $strokeWidth = 1.0,
214    ): self {
215        $cs = $this->ensureContentStream();
216        $cs->saveGraphicsState();
217
218        if ($fill !== null) {
219            $this->applyFillColor($cs, $fill);
220        }
221        if ($stroke !== null) {
222            $this->applyStrokeColor($cs, $stroke);
223            $cs->setLineWidth($strokeWidth);
224        }
225
226        $cs->rectangle($x, $y, $width, $height);
227        $this->paintPath($cs, $fill, $stroke);
228
229        $cs->restoreGraphicsState();
230        return $this;
231    }
232
233    /**
234     * Draw a circle.
235     */
236    public function drawCircle(
237        float $cx,
238        float $cy,
239        float $radius,
240        ?ColorInterface $fill = null,
241        ?ColorInterface $stroke = null,
242        float $strokeWidth = 1.0,
243    ): self {
244        return $this->drawEllipse($cx, $cy, $radius, $radius, $fill, $stroke, $strokeWidth);
245    }
246
247    /**
248     * Draw an ellipse.
249     */
250    public function drawEllipse(
251        float $cx,
252        float $cy,
253        float $rx,
254        float $ry,
255        ?ColorInterface $fill = null,
256        ?ColorInterface $stroke = null,
257        float $strokeWidth = 1.0,
258    ): self {
259        $cs = $this->ensureContentStream();
260        $cs->saveGraphicsState();
261
262        if ($fill !== null) {
263            $this->applyFillColor($cs, $fill);
264        }
265        if ($stroke !== null) {
266            $this->applyStrokeColor($cs, $stroke);
267            $cs->setLineWidth($strokeWidth);
268        }
269
270        // Bézier approximation of ellipse (4 curves)
271        $k = 0.5523; // magic constant for circle approximation
272        $this->emitEllipseOps($cs, $cx, $cy, $rx, $ry, $k);
273        $this->paintPath($cs, $fill, $stroke);
274
275        $cs->restoreGraphicsState();
276        return $this;
277    }
278
279    // -----------------------------------------------------------------------
280    // Higher-level shapes
281    // -----------------------------------------------------------------------
282
283    /**
284     * Draw a rectangle with rounded corners.
285     */
286    public function drawRoundedRectangle(
287        float $x,
288        float $y,
289        float $width,
290        float $height,
291        float $radius,
292        ?ColorInterface $fill = null,
293        ?ColorInterface $stroke = null,
294        float $strokeWidth = 1.0,
295    ): self {
296        $cs = $this->ensureContentStream();
297        $cs->saveGraphicsState();
298
299        if ($fill !== null) {
300            $this->applyFillColor($cs, $fill);
301        }
302        if ($stroke !== null) {
303            $this->applyStrokeColor($cs, $stroke);
304            $cs->setLineWidth($strokeWidth);
305        }
306
307        $r = min($radius, $width / 2, $height / 2);
308        $k = 0.5523 * $r;
309
310        // Start at top-left + radius, go clockwise
311        $cs->moveTo($x + $r, $y + $height);
312        // Top edge → top-right corner
313        $cs->lineTo($x + $width - $r, $y + $height);
314        $cs->curveTo($x + $width - $r + $k, $y + $height, $x + $width, $y + $height - $r + $k, $x + $width, $y + $height - $r);
315        // Right edge → bottom-right corner
316        $cs->lineTo($x + $width, $y + $r);
317        $cs->curveTo($x + $width, $y + $r - $k, $x + $width - $r + $k, $y, $x + $width - $r, $y);
318        // Bottom edge → bottom-left corner
319        $cs->lineTo($x + $r, $y);
320        $cs->curveTo($x + $r - $k, $y, $x, $y + $r - $k, $x, $y + $r);
321        // Left edge → top-left corner
322        $cs->lineTo($x, $y + $height - $r);
323        $cs->curveTo($x, $y + $height - $r + $k, $x + $r - $k, $y + $height, $x + $r, $y + $height);
324
325        $this->paintPath($cs, $fill, $stroke);
326        $cs->restoreGraphicsState();
327        return $this;
328    }
329
330    /**
331     * Draw a polygon from a list of points.
332     *
333     * @param array<array{0: float, 1: float}> $points [[x,y], [x,y], ...]
334     */
335    public function drawPolygon(
336        array $points,
337        ?ColorInterface $fill = null,
338        ?ColorInterface $stroke = null,
339        float $strokeWidth = 1.0,
340    ): self {
341        if (count($points) < 2) {
342            return $this;
343        }
344
345        $cs = $this->ensureContentStream();
346        $cs->saveGraphicsState();
347
348        if ($fill !== null) {
349            $this->applyFillColor($cs, $fill);
350        }
351        if ($stroke !== null) {
352            $this->applyStrokeColor($cs, $stroke);
353            $cs->setLineWidth($strokeWidth);
354        }
355
356        $cs->moveTo($points[0][0], $points[0][1]);
357        for ($i = 1; $i < count($points); $i++) {
358            $cs->lineTo($points[$i][0], $points[$i][1]);
359        }
360        $cs->closePath();
361        $this->paintPath($cs, $fill, $stroke);
362
363        $cs->restoreGraphicsState();
364        return $this;
365    }
366
367    /**
368     * Draw an arrow from (x1,y1) to (x2,y2) with a triangular arrowhead.
369     */
370    public function drawArrow(
371        float $x1,
372        float $y1,
373        float $x2,
374        float $y2,
375        float $headSize = 8,
376        ?ColorInterface $color = null,
377        float $width = 1.0,
378    ): self {
379        $cs = $this->ensureContentStream();
380        $cs->saveGraphicsState();
381
382        if ($color !== null) {
383            $this->applyStrokeColor($cs, $color);
384            $this->applyFillColor($cs, $color);
385        }
386        $cs->setLineWidth($width);
387
388        // Draw the line
389        $cs->moveTo($x1, $y1)->lineTo($x2, $y2)->stroke();
390
391        // Draw the arrowhead
392        $angle = atan2($y2 - $y1, $x2 - $x1);
393        $a1 = $angle + M_PI - M_PI / 6; // 150 degrees from line direction
394        $a2 = $angle + M_PI + M_PI / 6; // 210 degrees
395
396        $cs->moveTo($x2, $y2);
397        $cs->lineTo($x2 + $headSize * cos($a1), $y2 + $headSize * sin($a1));
398        $cs->lineTo($x2 + $headSize * cos($a2), $y2 + $headSize * sin($a2));
399        $cs->closePath();
400        $cs->fill();
401
402        $cs->restoreGraphicsState();
403        return $this;
404    }
405
406    /**
407     * Draw a star shape.
408     *
409     * @param int $points Number of points (5 = classic star)
410     */
411    public function drawStar(
412        float $cx,
413        float $cy,
414        float $outerRadius,
415        float $innerRadius,
416        int $points = 5,
417        ?ColorInterface $fill = null,
418        ?ColorInterface $stroke = null,
419        float $strokeWidth = 1.0,
420    ): self {
421        if ($points < 3) {
422            return $this;
423        }
424
425        $cs = $this->ensureContentStream();
426        $cs->saveGraphicsState();
427
428        if ($fill !== null) {
429            $this->applyFillColor($cs, $fill);
430        }
431        if ($stroke !== null) {
432            $this->applyStrokeColor($cs, $stroke);
433            $cs->setLineWidth($strokeWidth);
434        }
435
436        $totalVertices = $points * 2;
437        $angleStep = M_PI / $points;
438        $startAngle = M_PI / 2; // start at top
439
440        for ($i = 0; $i < $totalVertices; $i++) {
441            $r = $i % 2 === 0 ? $outerRadius : $innerRadius;
442            $angle = $startAngle + $i * $angleStep;
443            $vx = $cx + $r * cos($angle);
444            $vy = $cy + $r * sin($angle);
445
446            if ($i === 0) {
447                $cs->moveTo($vx, $vy);
448            } else {
449                $cs->lineTo($vx, $vy);
450            }
451        }
452        $cs->closePath();
453        $this->paintPath($cs, $fill, $stroke);
454
455        $cs->restoreGraphicsState();
456        return $this;
457    }
458
459    // -----------------------------------------------------------------------
460    // Path builder
461    // -----------------------------------------------------------------------
462
463    /**
464     * Draw a custom path using a PathBuilder closure.
465     *
466     * @param \Closure(PathBuilder): void $builder
467     */
468    public function drawPath(
469        \Closure $builder,
470        ?ColorInterface $fill = null,
471        ?ColorInterface $stroke = null,
472        float $strokeWidth = 1.0,
473    ): self {
474        $cs = $this->ensureContentStream();
475        $cs->saveGraphicsState();
476
477        if ($fill !== null) {
478            $this->applyFillColor($cs, $fill);
479        }
480        if ($stroke !== null) {
481            $this->applyStrokeColor($cs, $stroke);
482            $cs->setLineWidth($strokeWidth);
483        }
484
485        $path = new PathBuilder();
486        $builder($path);
487        $path->replayTo($cs);
488        $this->paintPath($cs, $fill, $stroke);
489
490        $cs->restoreGraphicsState();
491        return $this;
492    }
493
494    // -----------------------------------------------------------------------
495    // Images
496    // -----------------------------------------------------------------------
497
498    /**
499     * Draw an image at a specific position.
500     *
501     * @param string $path File path to the image
502     * @param float $x Left edge x coordinate
503     * @param float $y Bottom edge y coordinate
504     * @param float|null $width Display width (null = natural size in points at 72 DPI)
505     * @param float|null $height Display height (null = proportional to width)
506     */
507    public function drawImage(
508        string $path,
509        float $x,
510        float $y,
511        ?float $width = null,
512        ?float $height = null,
513    ): self {
514        $info = ImageParser::parse($path);
515        $name = ($this->addImageFn)($path, $this->corePage);
516
517        // Compute display dimensions
518        $natWidth = (float) $info->width;
519        $natHeight = (float) $info->height;
520
521        if ($width === null && $height === null) {
522            $width = $natWidth;
523            $height = $natHeight;
524        } elseif ($width !== null && $height === null) {
525            $height = $natHeight * ($width / $natWidth);
526        } elseif ($width === null && $height !== null) {
527            $width = $natWidth * ($height / $natHeight);
528        }
529
530        $cs = $this->ensureContentStream();
531        $cs->saveGraphicsState();
532        $cs->concatMatrix($width, 0, 0, $height, $x, $y);
533        $cs->doXObject($name);
534        $cs->restoreGraphicsState();
535
536        return $this;
537    }
538
539    // -----------------------------------------------------------------------
540    // Tables
541    // -----------------------------------------------------------------------
542
543    /**
544     * Draw a {@see Table} at `(x, y)`. The top of the table sits at
545     * `y`; rows render downward.
546     *
547     * `$table->columnWidths` must be set — `Writer\Page` is the
548     * positioned API and does not know the surrounding content column.
549     * For the auto-equal-columns convenience, use `Pdf::addTable()`.
550     *
551     * Only the standard 14 fonts are supported by this signature; for
552     * custom fonts, construct a {@see TableRenderContext} manually and
553     * call {@see TableRenderer} directly.
554     */
555    public function drawTable(
556        Table $table,
557        float $x,
558        float $y,
559        Font $bodyFont,
560        ?Font $headerFont = null,
561        float $fontSize = 11.0,
562        float $lineHeight = 1.2,
563        ?TableStyle $style = null,
564    ): self {
565        if ($table->columnWidths === null) {
566            throw new \InvalidArgumentException(
567                'Writer\\Page::drawTable() requires Table::$columnWidths to be set; '
568                . 'use Pdf::addTable() for auto-equal columns.',
569            );
570        }
571
572        $style ??= new TableStyle();
573        $headerFont ??= $bodyFont;
574
575        $bodyMetrics = StandardFontMetrics::get($bodyFont->getFamily());
576        $headerMetrics = $headerFont === $bodyFont
577            ? $bodyMetrics
578            : StandardFontMetrics::get($headerFont->getFamily());
579
580        $ctx = new TableRenderContext(
581            bodyFont: $bodyFont,
582            bodyMetrics: $bodyMetrics,
583            headerFont: $headerFont,
584            headerMetrics: $headerMetrics,
585            fontSize: $fontSize,
586            lineHeight: $lineHeight,
587            style: $style,
588        );
589
590        $cs = $this->ensureContentStream();
591        $renderer = new TableRenderer();
592        $cursorY = $y;
593
594        if ($table->headerRow !== null) {
595            $hh = $renderer->drawRow(
596                $cs,
597                $x,
598                $cursorY,
599                $table->headerRow,
600                $table->columnWidths,
601                $ctx,
602                isHeader: true,
603            );
604            $cursorY -= $hh;
605        }
606
607        foreach ($table->rows as $row) {
608            $rh = $renderer->drawRow(
609                $cs,
610                $x,
611                $cursorY,
612                $row,
613                $table->columnWidths,
614                $ctx,
615                isHeader: false,
616            );
617            $cursorY -= $rh;
618        }
619
620        return $this;
621    }
622
623    // -----------------------------------------------------------------------
624    // Gradients (shading patterns)
625    // -----------------------------------------------------------------------
626
627    /**
628     * Register a {@see ShadingPattern} as a pattern resource on this
629     * page and return the resource name to use with
630     * {@see ContentStream::setFillColorSpace}() / `setFillColor()`.
631     *
632     * Typical use:
633     *   $g = $doc->addLinearGradient(new Point(0,0), new Point(200,0), [1,0,0], [0,0,1]);
634     *   $name = $page->useGradient($g);
635     *   $page->contentStream()
636     *       ->setFillColorSpace('Pattern')
637     *       ->setFillColor("/{$name}")  // tinted patterns: setFillColor('1.0 /Name scn')
638     *       ->rectangle(72, 600, 200, 80)
639     *       ->fill();
640     */
641    public function useGradient(\Phpdftk\Pdf\Core\Graphics\Pattern\ShadingPattern $pattern): string
642    {
643        $key = 'P' . $pattern->objectNumber;
644        $resources = $this->corePage->resources;
645        if ($resources === null) {
646            return $key;
647        }
648        if (!isset($resources->pattern[$key])) {
649            $resources->pattern[$key] = new PdfReference($pattern->objectNumber);
650        }
651        return $key;
652    }
653
654    // -----------------------------------------------------------------------
655    // Spot colors
656    // -----------------------------------------------------------------------
657
658    /**
659     * Attach a registered spot color to this page's resources and
660     * return the resource name to use in content-stream `cs` / `CS`
661     * operators (via {@see ContentStream::setFillColorSpace()} /
662     * {@see ContentStream::setStrokeColorSpace()}).
663     */
664    public function useSpotColor(SpotColor $spot): string
665    {
666        $key = 'CS_' . preg_replace('/[^A-Za-z0-9]+/', '_', $spot->name);
667        $resources = $this->corePage->resources;
668        if ($resources === null) {
669            return $key;
670        }
671        if (!isset($resources->colorSpace[$key])) {
672            $resources->colorSpace[$key] = $spot->separation;
673        }
674        return $key;
675    }
676
677    // -----------------------------------------------------------------------
678    // Barcodes
679    // -----------------------------------------------------------------------
680
681    /**
682     * Render a barcode at `(x, y)` (lower-left of the quiet zone).
683     * The bitmap is produced by
684     * {@see \Phpdftk\Barcode\BarcodeRenderer::render()} and drawn
685     * inline — for documents that emit the same barcode many times,
686     * prefer {@see PdfDoc::createBarcode()} + {@see drawTemplate()}.
687     */
688    public function drawBarcode(
689        \Phpdftk\Barcode\Symbology $symbology,
690        string $data,
691        float $x,
692        float $y,
693        ?\Phpdftk\Barcode\BarcodeOptions $options = null,
694    ): self {
695        $options ??= new \Phpdftk\Barcode\BarcodeOptions();
696        $bitmap = \Phpdftk\Barcode\BarcodeRenderer::render($symbology, $data, $options);
697
698        $cs = $this->ensureContentStream();
699        $cs->saveGraphicsState();
700        $cs->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
701        BarcodeRendering::renderInto($cs, $bitmap);
702        $cs->restoreGraphicsState();
703        return $this;
704    }
705
706    // -----------------------------------------------------------------------
707    // Form XObject templates
708    // -----------------------------------------------------------------------
709
710    /**
711     * Place a Form XObject template on this page at `(x, y)`. The
712     * template's intrinsic dimensions come from its BBox; pass `$w`
713     * and / or `$h` to scale it (`null` keeps the BBox dimension).
714     *
715     * The template's XObject reference is added to the page's
716     * resource dict under a stable name (`Tpl<objNum>`) so repeated
717     * draws of the same template reuse the same entry.
718     */
719    public function drawTemplate(
720        \Phpdftk\Pdf\Core\Graphics\XObject\FormXObject $template,
721        float $x,
722        float $y,
723        ?float $w = null,
724        ?float $h = null,
725    ): self {
726        $bboxItems = $template->bBox->items;
727        if (count($bboxItems) < 4) {
728            throw new \InvalidArgumentException('Template has an invalid /BBox.');
729        }
730        $llx = $bboxItems[0] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[0]->value : 0.0;
731        $lly = $bboxItems[1] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[1]->value : 0.0;
732        $urx = $bboxItems[2] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[2]->value : 0.0;
733        $ury = $bboxItems[3] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[3]->value : 0.0;
734        $tplW = $urx - $llx;
735        $tplH = $ury - $lly;
736        $sx = $w === null ? 1.0 : ($tplW > 0 ? $w / $tplW : 1.0);
737        $sy = $h === null ? 1.0 : ($tplH > 0 ? $h / $tplH : 1.0);
738
739        $name = $this->ensureTemplateResource($template);
740        $cs = $this->ensureContentStream();
741        $cs->saveGraphicsState()
742            ->concatMatrix($sx, 0.0, 0.0, $sy, $x - $llx * $sx, $y - $lly * $sy)
743            ->doXObject($name)
744            ->restoreGraphicsState();
745        return $this;
746    }
747
748    private function ensureTemplateResource(\Phpdftk\Pdf\Core\Graphics\XObject\FormXObject $template): string
749    {
750        $name = 'Tpl' . $template->objectNumber;
751        $resources = $this->corePage->resources;
752        if ($resources === null) {
753            return $name;
754        }
755        if (!isset($resources->xObject[$name])) {
756            $resources->addXObject($name, new PdfReference($template->objectNumber));
757        }
758        return $name;
759    }
760
761    // -----------------------------------------------------------------------
762    // Optional content (layers)
763    // -----------------------------------------------------------------------
764
765    /**
766     * Wrap a closure's drawing operations as marked content belonging
767     * to the given optional-content group (layer). The closure runs
768     * between `/OC /<name> BDC` and `EMC`, and the OCG reference is
769     * added to this page's `/Properties` resource under a unique name.
770     *
771     * Viewers that support optional content (Acrobat, Foxit, etc.)
772     * will toggle the wrapped drawing on / off when the layer is
773     * shown / hidden.
774     *
775     * @param \Closure(self): void $body
776     */
777    public function inLayer(\Phpdftk\Pdf\Core\Document\OCG $layer, \Closure $body): self
778    {
779        $propName = $this->ensureLayerProperty($layer);
780        $cs = $this->ensureContentStream();
781        $cs->beginMarkedContentWithProperties('OC', '/' . $propName);
782        $body($this);
783        $cs->endMarkedContent();
784        return $this;
785    }
786
787    /**
788     * Register the OCG with this page's /Properties resource, keyed by
789     * a stable name (`MC<objNum>`) so repeated calls reuse the entry.
790     */
791    private function ensureLayerProperty(\Phpdftk\Pdf\Core\Document\OCG $layer): string
792    {
793        $key = 'MC' . $layer->objectNumber;
794        $resources = $this->corePage->resources;
795        if ($resources === null) {
796            return $key;
797        }
798        if (isset($resources->properties[$key])) {
799            return $key;
800        }
801        $resources->properties[$key] = new PdfReference($layer->objectNumber);
802        return $key;
803    }
804
805    // -----------------------------------------------------------------------
806    // Graphics state transforms + opacity
807    // -----------------------------------------------------------------------
808
809    /**
810     * Concatenate a rotation onto the current transformation matrix.
811     * If `$cx` / `$cy` are given, rotation is around that point;
812     * otherwise around the origin (0, 0).
813     *
814     * Subsequent drawing inherits this rotation until the next graphics
815     * state restore. Wrap calls in `withTransform()` for scoped effects.
816     */
817    public function rotate(float $degrees, ?float $cx = null, ?float $cy = null): self
818    {
819        $rad = deg2rad($degrees);
820        $cos = cos($rad);
821        $sin = sin($rad);
822        $cs = $this->ensureContentStream();
823        if ($cx === null && $cy === null) {
824            $cs->concatMatrix($cos, $sin, -$sin, $cos, 0.0, 0.0);
825        } else {
826            $cx ??= 0.0;
827            $cy ??= 0.0;
828            $e = $cx - $cx * $cos + $cy * $sin;
829            $f = $cy - $cx * $sin - $cy * $cos;
830            $cs->concatMatrix($cos, $sin, -$sin, $cos, $e, $f);
831        }
832        return $this;
833    }
834
835    /** Concatenate a non-uniform scale onto the CTM. */
836    public function scale(float $sx, float $sy): self
837    {
838        $this->ensureContentStream()->concatMatrix($sx, 0.0, 0.0, $sy, 0.0, 0.0);
839        return $this;
840    }
841
842    /** Concatenate a translation onto the CTM. */
843    public function translate(float $tx, float $ty): self
844    {
845        $this->ensureContentStream()->concatMatrix(1.0, 0.0, 0.0, 1.0, $tx, $ty);
846        return $this;
847    }
848
849    /**
850     * Concatenate a skew transform onto the CTM. `$alphaDeg` shears
851     * along the X axis, `$betaDeg` along the Y axis.
852     */
853    public function skew(float $alphaDeg, float $betaDeg): self
854    {
855        $this->ensureContentStream()->concatMatrix(
856            1.0,
857            tan(deg2rad($betaDeg)),
858            tan(deg2rad($alphaDeg)),
859            1.0,
860            0.0,
861            0.0,
862        );
863        return $this;
864    }
865
866    /**
867     * Scope a closure's drawing within a `q ... Q` (save/restore)
868     * pair. Any transforms or graphics-state changes made inside the
869     * closure are reverted on exit.
870     *
871     * @param \Closure(self): void $body
872     */
873    public function withTransform(\Closure $body): self
874    {
875        $cs = $this->ensureContentStream();
876        $cs->saveGraphicsState();
877        $body($this);
878        $cs->restoreGraphicsState();
879        return $this;
880    }
881
882    /**
883     * Set the stroke / fill opacity for subsequent drawing. Registers
884     * a fresh ExtGState resource keyed by the alpha values so opacity
885     * can vary across the page without re-registering on every call.
886     *
887     * Stroke and fill default to the same value when only one
888     * argument is provided.
889     */
890    public function setOpacity(float $stroke, ?float $fill = null): self
891    {
892        $fill ??= $stroke;
893        $name = $this->ensureOpacityState($stroke, $fill);
894        $this->ensureContentStream()->setGraphicsState($name);
895        return $this;
896    }
897
898    /**
899     * Lazily build (or reuse) an ExtGState resource for this page that
900     * sets CA + ca and returns its resource name. The cache key is
901     * derived from the alpha values so identical opacity calls reuse
902     * the same registered ExtGState. Public so consumers writing to
903     * additional content streams (e.g. the html-to-pdf painter) can
904     * grab the resource name and emit `gs` themselves without going
905     * through `setOpacity()`'s stream side effect.
906     */
907    public function ensureOpacityState(float $stroke, float $fill): string
908    {
909        $stroke = max(0.0, min(1.0, $stroke));
910        $fill = max(0.0, min(1.0, $fill));
911        $key = sprintf('GS_op_%.3f_%.3f', $stroke, $fill);
912
913        if ($this->corePage->resources !== null && isset($this->corePage->resources->extGState[$key])) {
914            return $key;
915        }
916
917        $extGState = new \Phpdftk\Pdf\Core\Graphics\ExtGState();
918        $extGState->ca = $stroke;
919        $extGState->caLower = $fill;
920        $ref = $this->writer->register($extGState);
921        $this->corePage->resources?->addExtGState($key, $ref);
922        return $key;
923    }
924
925    // -----------------------------------------------------------------------
926    // Page geometry (rotation + box rectangles)
927    // -----------------------------------------------------------------------
928
929    /**
930     * Set the page rotation, in degrees clockwise. Only multiples of
931     * 90 are valid per ISO 32000-2 § 7.7.3.3 — anything else throws.
932     */
933    public function setRotation(int $degrees): self
934    {
935        if ($degrees % 90 !== 0) {
936            throw new \InvalidArgumentException(
937                "Page rotation must be a multiple of 90 (got {$degrees}).",
938            );
939        }
940        // Normalise to [0, 360): PDF readers accept negatives but
941        // the canonical form is non-negative.
942        $this->corePage->rotate = (($degrees % 360) + 360) % 360;
943        return $this;
944    }
945
946    /**
947     * Set /CropBox — the visible region when the page is displayed.
948     * Defaults to MediaBox if unset.
949     */
950    public function setCropBox(Rectangle $rect): self
951    {
952        $this->corePage->cropBox = $this->rectToBoxArray($rect);
953        return $this;
954    }
955
956    /**
957     * Set /BleedBox — the area to be clipped when output is produced
958     * for production presses.
959     */
960    public function setBleedBox(Rectangle $rect): self
961    {
962        $this->corePage->bleedBox = $this->rectToBoxArray($rect);
963        return $this;
964    }
965
966    /**
967     * Set /TrimBox — the intended dimensions of the finished page.
968     */
969    public function setTrimBox(Rectangle $rect): self
970    {
971        $this->corePage->trimBox = $this->rectToBoxArray($rect);
972        return $this;
973    }
974
975    /**
976     * Set /ArtBox — the page's meaningful content extent.
977     */
978    public function setArtBox(Rectangle $rect): self
979    {
980        $this->corePage->artBox = $this->rectToBoxArray($rect);
981        return $this;
982    }
983
984    private function rectToBoxArray(Rectangle $rect): PdfArray
985    {
986        [$llx, $lly, $urx, $ury] = $rect->toArray();
987        return new PdfArray([
988            new \Phpdftk\Pdf\Core\PdfNumber($llx),
989            new \Phpdftk\Pdf\Core\PdfNumber($lly),
990            new \Phpdftk\Pdf\Core\PdfNumber($urx),
991            new \Phpdftk\Pdf\Core\PdfNumber($ury),
992        ]);
993    }
994
995    // -----------------------------------------------------------------------
996    // Callout
997    // -----------------------------------------------------------------------
998
999    /**
1000     * Draw a callout panel at `(x, y)` with the given total `$width`.
1001     * The top of the panel sits at `$y`; body grows downward and the
1002     * returned float is the height consumed.
1003     *
1004     * The caller supplies the body and (optionally) title font handles.
1005     * Standard 14 fonts only — wrap-aware widths come from
1006     * {@see StandardFontMetrics}.
1007     */
1008    public function drawCallout(
1009        string $text,
1010        float $x,
1011        float $y,
1012        float $width,
1013        CalloutType $type,
1014        Font $bodyFont,
1015        ?Font $titleFont = null,
1016        float $size = 11.0,
1017        float $lineHeight = 1.2,
1018        ?CalloutStyle $style = null,
1019    ): float {
1020        $style ??= new CalloutStyle();
1021        $titleFont ??= $bodyFont;
1022
1023        $bodyMetrics = StandardFontMetrics::get($bodyFont->getFamily());
1024        $padding = $style->padding;
1025        $barWidth = $style->barWidth;
1026
1027        $textX = $x + $barWidth + $padding;
1028        $textWidth = max(0.0, $width - $barWidth - 2.0 * $padding);
1029
1030        $encoded = $bodyFont->getTextEncoder()?->encode($text) ?? $text;
1031        $bodyLines = TextLayout::wrap($encoded, $bodyMetrics, $size, $textWidth);
1032        $lineH = $size * $lineHeight;
1033        $bodyHeight = count($bodyLines) * $lineH;
1034
1035        $titleHeight = 0.0;
1036        $titleLabel = null;
1037        if ($style->showLabel) {
1038            $titleLabel = $style->resolveLabel($type);
1039            $titleHeight = $lineH;
1040        }
1041
1042        $totalHeight = 2.0 * $padding + $titleHeight + $bodyHeight;
1043        $bottomY = $y - $totalHeight;
1044
1045        [$br, $bg, $bb] = $style->resolveBarColor($type);
1046        [$bgR, $bgG, $bgB] = $style->resolveBgColor($type);
1047
1048        $cs = $this->ensureContentStream();
1049        $cs->saveGraphicsState();
1050
1051        $cs->setFillColorRGB($bgR, $bgG, $bgB)
1052            ->rectangle($x, $bottomY, $width, $totalHeight)
1053            ->fill();
1054
1055        $cs->setFillColorRGB($br, $bg, $bb)
1056            ->rectangle($x, $bottomY, $barWidth, $totalHeight)
1057            ->fill();
1058
1059        $textColor = $style->textColor ?? [0.0, 0.0, 0.0];
1060        $cs->setFillColorRGB($textColor[0], $textColor[1], $textColor[2]);
1061        $cursorY = $y - $padding;
1062
1063        if ($titleLabel !== null) {
1064            $encodedTitle = $titleFont->getTextEncoder()?->encode($titleLabel) ?? $titleLabel;
1065            $baseline = $cursorY - $size;
1066            $cs->beginText()
1067                ->setFont($titleFont->getResourceName(), $size)
1068                ->moveTextPosition($textX, $baseline)
1069                ->showText($encodedTitle)
1070                ->endText();
1071            $cursorY -= $lineH;
1072        }
1073
1074        foreach ($bodyLines as $line) {
1075            if ($line === '') {
1076                $cursorY -= $lineH;
1077                continue;
1078            }
1079            $baseline = $cursorY - $size;
1080            $cs->beginText()
1081                ->setFont($bodyFont->getResourceName(), $size)
1082                ->moveTextPosition($textX, $baseline)
1083                ->showText($line)
1084                ->endText();
1085            $cursorY -= $lineH;
1086        }
1087
1088        $cs->restoreGraphicsState();
1089        return $totalHeight;
1090    }
1091
1092    // -----------------------------------------------------------------------
1093    // Blockquote
1094    // -----------------------------------------------------------------------
1095
1096    /**
1097     * Draw a blockquote at `(x, y)`: indented body text with a
1098     * coloured vertical bar down the left edge. The top of the quote
1099     * sits at `$y`; text and bar grow downward.
1100     *
1101     * The caller selects the font (typically the italic variant) and
1102     * the desired bar colour. Returns the height consumed.
1103     */
1104    public function drawQuote(
1105        string $text,
1106        float $x,
1107        float $y,
1108        Font $font,
1109        float $size = 11.0,
1110        float $maxWidth = 468.0,
1111        float $lineHeight = 1.2,
1112        float $indent = 18.0,
1113        float $barWidth = 2.0,
1114        ?ColorInterface $barColor = null,
1115        ?ColorInterface $textColor = null,
1116    ): self {
1117        $metrics = StandardFontMetrics::get($font->getFamily());
1118        $encoded = $font->getTextEncoder()?->encode($text) ?? $text;
1119        $textWidth = max(0.0, $maxWidth - $indent);
1120        $lines = TextLayout::wrap($encoded, $metrics, $size, $textWidth);
1121
1122        $cs = $this->ensureContentStream();
1123        $cs->saveGraphicsState();
1124
1125        if ($textColor !== null) {
1126            $this->applyFillColor($cs, $textColor);
1127        }
1128
1129        $lineH = $size * $lineHeight;
1130        $textX = $x + $indent;
1131        $textTopY = $y;
1132        $cursorY = $y;
1133        foreach ($lines as $line) {
1134            if ($line === '') {
1135                $cursorY -= $lineH;
1136                continue;
1137            }
1138            $baselineY = $cursorY - $size;
1139            $cs->beginText()
1140                ->setFont($font->getResourceName(), $size)
1141                ->moveTextPosition($textX, $baselineY)
1142                ->showText($line)
1143                ->endText();
1144            $cursorY -= $lineH;
1145        }
1146
1147        // Left bar
1148        if ($barColor !== null) {
1149            $vals = $barColor->toArray();
1150            $cs->setStrokeColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0);
1151        } else {
1152            $cs->setStrokeColorRGB(0.7, 0.7, 0.7);
1153        }
1154        $cs->setLineWidth($barWidth);
1155        $barX = $x + $barWidth / 2.0;
1156        $cs->moveTo($barX, $textTopY - 2.0)
1157            ->lineTo($barX, $cursorY + 2.0)
1158            ->stroke();
1159
1160        $cs->restoreGraphicsState();
1161        return $this;
1162    }
1163
1164    // -----------------------------------------------------------------------
1165    // Lists
1166    // -----------------------------------------------------------------------
1167
1168    /**
1169     * Draw a {@see ListBlock} at `(x, y)`. The marker for the first
1170     * item sits at `$x`; wrapped text starts one indent further right.
1171     *
1172     * Standard 14 fonts only — pass a custom font's metrics by going
1173     * through {@see ListRenderer::drawBlock()} directly.
1174     */
1175    public function drawList(
1176        ListBlock $list,
1177        float $x,
1178        float $y,
1179        Font $font,
1180        float $fontSize = 11.0,
1181        float $maxWidth = 468.0,
1182        float $lineHeight = 1.2,
1183        ?ListStyle $style = null,
1184    ): self {
1185        $style ??= new ListStyle();
1186        $metrics = StandardFontMetrics::get($font->getFamily());
1187
1188        $renderer = new ListRenderer();
1189        $renderer->drawBlock(
1190            $this->ensureContentStream(),
1191            $x,
1192            $y,
1193            $list,
1194            $maxWidth,
1195            $font,
1196            $metrics,
1197            $fontSize,
1198            $lineHeight,
1199            $style,
1200        );
1201        return $this;
1202    }
1203
1204    // -----------------------------------------------------------------------
1205    // Raw escape
1206    // -----------------------------------------------------------------------
1207
1208    /**
1209     * Execute raw ContentStream operations via a closure.
1210     *
1211     * @param \Closure(ContentStream): void $fn
1212     */
1213    public function raw(\Closure $fn): self
1214    {
1215        $fn($this->ensureContentStream());
1216        return $this;
1217    }
1218
1219    // -----------------------------------------------------------------------
1220    // Internal helpers
1221    // -----------------------------------------------------------------------
1222
1223    private function ensureContentStream(): ContentStream
1224    {
1225        if ($this->cs === null) {
1226            $this->cs = new ContentStream();
1227            ($this->registerFn)($this->cs);
1228            $this->corePage->contents[] = new PdfReference($this->cs->objectNumber);
1229        }
1230        return $this->cs;
1231    }
1232
1233    private function applyFillColor(ContentStream $cs, ColorInterface $color): void
1234    {
1235        $vals = $color->toArray();
1236        match ($color->getColorSpace()) {
1237            'DeviceRGB' => $cs->setFillColorRGB($vals[0], $vals[1], $vals[2]),
1238            'DeviceCMYK' => $cs->setFillColorCMYK($vals[0], $vals[1], $vals[2], $vals[3]),
1239            'DeviceGray' => $cs->setFillColorGray($vals[0]),
1240            default => $cs->setFillColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0),
1241        };
1242    }
1243
1244    private function applyStrokeColor(ContentStream $cs, ColorInterface $color): void
1245    {
1246        $vals = $color->toArray();
1247        match ($color->getColorSpace()) {
1248            'DeviceRGB' => $cs->setStrokeColorRGB($vals[0], $vals[1], $vals[2]),
1249            'DeviceCMYK' => $cs->setStrokeColorCMYK($vals[0], $vals[1], $vals[2], $vals[3]),
1250            'DeviceGray' => $cs->setStrokeColorGray($vals[0]),
1251            default => $cs->setStrokeColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0),
1252        };
1253    }
1254
1255    private function paintPath(ContentStream $cs, ?ColorInterface $fill, ?ColorInterface $stroke): void
1256    {
1257        if ($fill !== null && $stroke !== null) {
1258            $cs->fillAndStroke();
1259        } elseif ($fill !== null) {
1260            $cs->fill();
1261        } elseif ($stroke !== null) {
1262            $cs->stroke();
1263        } else {
1264            $cs->stroke(); // default to stroke if nothing specified
1265        }
1266    }
1267
1268    private function emitEllipseOps(ContentStream $cs, float $cx, float $cy, float $rx, float $ry, float $k): void
1269    {
1270        $kx = $k * $rx;
1271        $ky = $k * $ry;
1272
1273        $cs->moveTo($cx + $rx, $cy);
1274        $cs->curveTo($cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry);
1275        $cs->curveTo($cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy);
1276        $cs->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry);
1277        $cs->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy);
1278    }
1279}