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
155.32
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
6
 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) {
527            // Reached only when $height is non-null (the both-null and
528            // width-only branches above are exhausted).
529            $width = $natWidth * ($height / $natHeight);
530        }
531
532        $cs = $this->ensureContentStream();
533        $cs->saveGraphicsState();
534        $cs->concatMatrix($width, 0, 0, $height, $x, $y);
535        $cs->doXObject($name);
536        $cs->restoreGraphicsState();
537
538        return $this;
539    }
540
541    // -----------------------------------------------------------------------
542    // Tables
543    // -----------------------------------------------------------------------
544
545    /**
546     * Draw a {@see Table} at `(x, y)`. The top of the table sits at
547     * `y`; rows render downward.
548     *
549     * `$table->columnWidths` must be set — `Writer\Page` is the
550     * positioned API and does not know the surrounding content column.
551     * For the auto-equal-columns convenience, use `Pdf::addTable()`.
552     *
553     * Only the standard 14 fonts are supported by this signature; for
554     * custom fonts, construct a {@see TableRenderContext} manually and
555     * call {@see TableRenderer} directly.
556     */
557    public function drawTable(
558        Table $table,
559        float $x,
560        float $y,
561        Font $bodyFont,
562        ?Font $headerFont = null,
563        float $fontSize = 11.0,
564        float $lineHeight = 1.2,
565        ?TableStyle $style = null,
566    ): self {
567        if ($table->columnWidths === null) {
568            throw new \InvalidArgumentException(
569                'Writer\\Page::drawTable() requires Table::$columnWidths to be set; '
570                . 'use Pdf::addTable() for auto-equal columns.',
571            );
572        }
573
574        $style ??= new TableStyle();
575        $headerFont ??= $bodyFont;
576
577        $bodyMetrics = StandardFontMetrics::get($bodyFont->getFamily());
578        $headerMetrics = $headerFont === $bodyFont
579            ? $bodyMetrics
580            : StandardFontMetrics::get($headerFont->getFamily());
581
582        $ctx = new TableRenderContext(
583            bodyFont: $bodyFont,
584            bodyMetrics: $bodyMetrics,
585            headerFont: $headerFont,
586            headerMetrics: $headerMetrics,
587            fontSize: $fontSize,
588            lineHeight: $lineHeight,
589            style: $style,
590        );
591
592        $cs = $this->ensureContentStream();
593        $renderer = new TableRenderer();
594        $cursorY = $y;
595
596        if ($table->headerRow !== null) {
597            $hh = $renderer->drawRow(
598                $cs,
599                $x,
600                $cursorY,
601                $table->headerRow,
602                $table->columnWidths,
603                $ctx,
604                isHeader: true,
605            );
606            $cursorY -= $hh;
607        }
608
609        foreach ($table->rows as $row) {
610            $rh = $renderer->drawRow(
611                $cs,
612                $x,
613                $cursorY,
614                $row,
615                $table->columnWidths,
616                $ctx,
617                isHeader: false,
618            );
619            $cursorY -= $rh;
620        }
621
622        return $this;
623    }
624
625    // -----------------------------------------------------------------------
626    // Gradients (shading patterns)
627    // -----------------------------------------------------------------------
628
629    /**
630     * Register a {@see ShadingPattern} as a pattern resource on this
631     * page and return the resource name to use with
632     * {@see ContentStream::setFillColorSpace}() / `setFillColor()`.
633     *
634     * Typical use:
635     *   $g = $doc->addLinearGradient(new Point(0,0), new Point(200,0), [1,0,0], [0,0,1]);
636     *   $name = $page->useGradient($g);
637     *   $page->contentStream()
638     *       ->setFillColorSpace('Pattern')
639     *       ->setFillColor("/{$name}")  // tinted patterns: setFillColor('1.0 /Name scn')
640     *       ->rectangle(72, 600, 200, 80)
641     *       ->fill();
642     */
643    public function useGradient(\Phpdftk\Pdf\Core\Graphics\Pattern\ShadingPattern $pattern): string
644    {
645        $key = 'P' . $pattern->objectNumber;
646        $resources = $this->corePage->resources;
647        if ($resources === null) {
648            return $key;
649        }
650        if (!isset($resources->pattern[$key])) {
651            $resources->pattern[$key] = new PdfReference($pattern->objectNumber);
652        }
653        return $key;
654    }
655
656    // -----------------------------------------------------------------------
657    // Spot colors
658    // -----------------------------------------------------------------------
659
660    /**
661     * Attach a registered spot color to this page's resources and
662     * return the resource name to use in content-stream `cs` / `CS`
663     * operators (via {@see ContentStream::setFillColorSpace()} /
664     * {@see ContentStream::setStrokeColorSpace()}).
665     */
666    public function useSpotColor(SpotColor $spot): string
667    {
668        $key = 'CS_' . preg_replace('/[^A-Za-z0-9]+/', '_', $spot->name);
669        $resources = $this->corePage->resources;
670        if ($resources === null) {
671            return $key;
672        }
673        if (!isset($resources->colorSpace[$key])) {
674            $resources->colorSpace[$key] = $spot->separation;
675        }
676        return $key;
677    }
678
679    // -----------------------------------------------------------------------
680    // Barcodes
681    // -----------------------------------------------------------------------
682
683    /**
684     * Render a barcode at `(x, y)` (lower-left of the quiet zone).
685     * The bitmap is produced by
686     * {@see \Phpdftk\Barcode\BarcodeRenderer::render()} and drawn
687     * inline — for documents that emit the same barcode many times,
688     * prefer {@see PdfDoc::createBarcode()} + {@see drawTemplate()}.
689     */
690    public function drawBarcode(
691        \Phpdftk\Barcode\Symbology $symbology,
692        string $data,
693        float $x,
694        float $y,
695        ?\Phpdftk\Barcode\BarcodeOptions $options = null,
696    ): self {
697        $options ??= new \Phpdftk\Barcode\BarcodeOptions();
698        $bitmap = \Phpdftk\Barcode\BarcodeRenderer::render($symbology, $data, $options);
699
700        $cs = $this->ensureContentStream();
701        $cs->saveGraphicsState();
702        $cs->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
703        BarcodeRendering::renderInto($cs, $bitmap);
704        $cs->restoreGraphicsState();
705        return $this;
706    }
707
708    // -----------------------------------------------------------------------
709    // Form XObject templates
710    // -----------------------------------------------------------------------
711
712    /**
713     * Place a Form XObject template on this page at `(x, y)`. The
714     * template's intrinsic dimensions come from its BBox; pass `$w`
715     * and / or `$h` to scale it (`null` keeps the BBox dimension).
716     *
717     * The template's XObject reference is added to the page's
718     * resource dict under a stable name (`Tpl<objNum>`) so repeated
719     * draws of the same template reuse the same entry.
720     */
721    public function drawTemplate(
722        \Phpdftk\Pdf\Core\Graphics\XObject\FormXObject $template,
723        float $x,
724        float $y,
725        ?float $w = null,
726        ?float $h = null,
727    ): self {
728        $bboxItems = $template->bBox->items;
729        if (count($bboxItems) < 4) {
730            throw new \InvalidArgumentException('Template has an invalid /BBox.');
731        }
732        $llx = $bboxItems[0] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[0]->value : 0.0;
733        $lly = $bboxItems[1] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[1]->value : 0.0;
734        $urx = $bboxItems[2] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[2]->value : 0.0;
735        $ury = $bboxItems[3] instanceof \Phpdftk\Pdf\Core\PdfNumber ? (float) $bboxItems[3]->value : 0.0;
736        $tplW = $urx - $llx;
737        $tplH = $ury - $lly;
738        $sx = $w === null ? 1.0 : ($tplW > 0 ? $w / $tplW : 1.0);
739        $sy = $h === null ? 1.0 : ($tplH > 0 ? $h / $tplH : 1.0);
740
741        $name = $this->ensureTemplateResource($template);
742        $cs = $this->ensureContentStream();
743        $cs->saveGraphicsState()
744            ->concatMatrix($sx, 0.0, 0.0, $sy, $x - $llx * $sx, $y - $lly * $sy)
745            ->doXObject($name)
746            ->restoreGraphicsState();
747        return $this;
748    }
749
750    private function ensureTemplateResource(\Phpdftk\Pdf\Core\Graphics\XObject\FormXObject $template): string
751    {
752        $name = 'Tpl' . $template->objectNumber;
753        $resources = $this->corePage->resources;
754        if ($resources === null) {
755            return $name;
756        }
757        if (!isset($resources->xObject[$name])) {
758            $resources->addXObject($name, new PdfReference($template->objectNumber));
759        }
760        return $name;
761    }
762
763    // -----------------------------------------------------------------------
764    // Optional content (layers)
765    // -----------------------------------------------------------------------
766
767    /**
768     * Wrap a closure's drawing operations as marked content belonging
769     * to the given optional-content group (layer). The closure runs
770     * between `/OC /<name> BDC` and `EMC`, and the OCG reference is
771     * added to this page's `/Properties` resource under a unique name.
772     *
773     * Viewers that support optional content (Acrobat, Foxit, etc.)
774     * will toggle the wrapped drawing on / off when the layer is
775     * shown / hidden.
776     *
777     * @param \Closure(self): void $body
778     */
779    public function inLayer(\Phpdftk\Pdf\Core\Document\OCG $layer, \Closure $body): self
780    {
781        $propName = $this->ensureLayerProperty($layer);
782        $cs = $this->ensureContentStream();
783        $cs->beginMarkedContentWithProperties('OC', '/' . $propName);
784        $body($this);
785        $cs->endMarkedContent();
786        return $this;
787    }
788
789    /**
790     * Register the OCG with this page's /Properties resource, keyed by
791     * a stable name (`MC<objNum>`) so repeated calls reuse the entry.
792     */
793    private function ensureLayerProperty(\Phpdftk\Pdf\Core\Document\OCG $layer): string
794    {
795        $key = 'MC' . $layer->objectNumber;
796        $resources = $this->corePage->resources;
797        if ($resources === null) {
798            return $key;
799        }
800        if (isset($resources->properties[$key])) {
801            return $key;
802        }
803        $resources->properties[$key] = new PdfReference($layer->objectNumber);
804        return $key;
805    }
806
807    // -----------------------------------------------------------------------
808    // Graphics state transforms + opacity
809    // -----------------------------------------------------------------------
810
811    /**
812     * Concatenate a rotation onto the current transformation matrix.
813     * If `$cx` / `$cy` are given, rotation is around that point;
814     * otherwise around the origin (0, 0).
815     *
816     * Subsequent drawing inherits this rotation until the next graphics
817     * state restore. Wrap calls in `withTransform()` for scoped effects.
818     */
819    public function rotate(float $degrees, ?float $cx = null, ?float $cy = null): self
820    {
821        $rad = deg2rad($degrees);
822        $cos = cos($rad);
823        $sin = sin($rad);
824        $cs = $this->ensureContentStream();
825        if ($cx === null && $cy === null) {
826            $cs->concatMatrix($cos, $sin, -$sin, $cos, 0.0, 0.0);
827        } else {
828            $cx ??= 0.0;
829            $cy ??= 0.0;
830            $e = $cx - $cx * $cos + $cy * $sin;
831            $f = $cy - $cx * $sin - $cy * $cos;
832            $cs->concatMatrix($cos, $sin, -$sin, $cos, $e, $f);
833        }
834        return $this;
835    }
836
837    /** Concatenate a non-uniform scale onto the CTM. */
838    public function scale(float $sx, float $sy): self
839    {
840        $this->ensureContentStream()->concatMatrix($sx, 0.0, 0.0, $sy, 0.0, 0.0);
841        return $this;
842    }
843
844    /** Concatenate a translation onto the CTM. */
845    public function translate(float $tx, float $ty): self
846    {
847        $this->ensureContentStream()->concatMatrix(1.0, 0.0, 0.0, 1.0, $tx, $ty);
848        return $this;
849    }
850
851    /**
852     * Concatenate a skew transform onto the CTM. `$alphaDeg` shears
853     * along the X axis, `$betaDeg` along the Y axis.
854     */
855    public function skew(float $alphaDeg, float $betaDeg): self
856    {
857        $this->ensureContentStream()->concatMatrix(
858            1.0,
859            tan(deg2rad($betaDeg)),
860            tan(deg2rad($alphaDeg)),
861            1.0,
862            0.0,
863            0.0,
864        );
865        return $this;
866    }
867
868    /**
869     * Scope a closure's drawing within a `q ... Q` (save/restore)
870     * pair. Any transforms or graphics-state changes made inside the
871     * closure are reverted on exit.
872     *
873     * @param \Closure(self): void $body
874     */
875    public function withTransform(\Closure $body): self
876    {
877        $cs = $this->ensureContentStream();
878        $cs->saveGraphicsState();
879        $body($this);
880        $cs->restoreGraphicsState();
881        return $this;
882    }
883
884    /**
885     * Set the stroke / fill opacity for subsequent drawing. Registers
886     * a fresh ExtGState resource keyed by the alpha values so opacity
887     * can vary across the page without re-registering on every call.
888     *
889     * Stroke and fill default to the same value when only one
890     * argument is provided.
891     */
892    public function setOpacity(float $stroke, ?float $fill = null): self
893    {
894        $fill ??= $stroke;
895        $name = $this->ensureOpacityState($stroke, $fill);
896        $this->ensureContentStream()->setGraphicsState($name);
897        return $this;
898    }
899
900    /**
901     * Lazily build (or reuse) an ExtGState resource for this page that
902     * sets CA + ca and returns its resource name. The cache key is
903     * derived from the alpha values so identical opacity calls reuse
904     * the same registered ExtGState. Public so consumers writing to
905     * additional content streams (e.g. the html-to-pdf painter) can
906     * grab the resource name and emit `gs` themselves without going
907     * through `setOpacity()`'s stream side effect.
908     */
909    public function ensureOpacityState(float $stroke, float $fill): string
910    {
911        $stroke = max(0.0, min(1.0, $stroke));
912        $fill = max(0.0, min(1.0, $fill));
913        $key = sprintf('GS_op_%.3f_%.3f', $stroke, $fill);
914
915        if ($this->corePage->resources !== null && isset($this->corePage->resources->extGState[$key])) {
916            return $key;
917        }
918
919        $extGState = new \Phpdftk\Pdf\Core\Graphics\ExtGState();
920        $extGState->ca = $stroke;
921        $extGState->caLower = $fill;
922        $ref = $this->writer->register($extGState);
923        $this->corePage->resources?->addExtGState($key, $ref);
924        return $key;
925    }
926
927    // -----------------------------------------------------------------------
928    // Page geometry (rotation + box rectangles)
929    // -----------------------------------------------------------------------
930
931    /**
932     * Set the page rotation, in degrees clockwise. Only multiples of
933     * 90 are valid per ISO 32000-2 § 7.7.3.3 — anything else throws.
934     */
935    public function setRotation(int $degrees): self
936    {
937        if ($degrees % 90 !== 0) {
938            throw new \InvalidArgumentException(
939                "Page rotation must be a multiple of 90 (got {$degrees}).",
940            );
941        }
942        // Normalise to [0, 360): PDF readers accept negatives but
943        // the canonical form is non-negative.
944        $this->corePage->rotate = (($degrees % 360) + 360) % 360;
945        return $this;
946    }
947
948    /**
949     * Set /CropBox — the visible region when the page is displayed.
950     * Defaults to MediaBox if unset.
951     */
952    public function setCropBox(Rectangle $rect): self
953    {
954        $this->corePage->cropBox = $this->rectToBoxArray($rect);
955        return $this;
956    }
957
958    /**
959     * Set /BleedBox — the area to be clipped when output is produced
960     * for production presses.
961     */
962    public function setBleedBox(Rectangle $rect): self
963    {
964        $this->corePage->bleedBox = $this->rectToBoxArray($rect);
965        return $this;
966    }
967
968    /**
969     * Set /TrimBox — the intended dimensions of the finished page.
970     */
971    public function setTrimBox(Rectangle $rect): self
972    {
973        $this->corePage->trimBox = $this->rectToBoxArray($rect);
974        return $this;
975    }
976
977    /**
978     * Set /ArtBox — the page's meaningful content extent.
979     */
980    public function setArtBox(Rectangle $rect): self
981    {
982        $this->corePage->artBox = $this->rectToBoxArray($rect);
983        return $this;
984    }
985
986    private function rectToBoxArray(Rectangle $rect): PdfArray
987    {
988        [$llx, $lly, $urx, $ury] = $rect->toArray();
989        return new PdfArray([
990            new \Phpdftk\Pdf\Core\PdfNumber($llx),
991            new \Phpdftk\Pdf\Core\PdfNumber($lly),
992            new \Phpdftk\Pdf\Core\PdfNumber($urx),
993            new \Phpdftk\Pdf\Core\PdfNumber($ury),
994        ]);
995    }
996
997    // -----------------------------------------------------------------------
998    // Callout
999    // -----------------------------------------------------------------------
1000
1001    /**
1002     * Draw a callout panel at `(x, y)` with the given total `$width`.
1003     * The top of the panel sits at `$y`; body grows downward and the
1004     * returned float is the height consumed.
1005     *
1006     * The caller supplies the body and (optionally) title font handles.
1007     * Standard 14 fonts only — wrap-aware widths come from
1008     * {@see StandardFontMetrics}.
1009     */
1010    public function drawCallout(
1011        string $text,
1012        float $x,
1013        float $y,
1014        float $width,
1015        CalloutType $type,
1016        Font $bodyFont,
1017        ?Font $titleFont = null,
1018        float $size = 11.0,
1019        float $lineHeight = 1.2,
1020        ?CalloutStyle $style = null,
1021    ): float {
1022        $style ??= new CalloutStyle();
1023        $titleFont ??= $bodyFont;
1024
1025        $bodyMetrics = StandardFontMetrics::get($bodyFont->getFamily());
1026        $padding = $style->padding;
1027        $barWidth = $style->barWidth;
1028
1029        $textX = $x + $barWidth + $padding;
1030        $textWidth = max(0.0, $width - $barWidth - 2.0 * $padding);
1031
1032        $encoded = $bodyFont->getTextEncoder()?->encode($text) ?? $text;
1033        $bodyLines = TextLayout::wrap($encoded, $bodyMetrics, $size, $textWidth);
1034        $lineH = $size * $lineHeight;
1035        $bodyHeight = count($bodyLines) * $lineH;
1036
1037        $titleHeight = 0.0;
1038        $titleLabel = null;
1039        if ($style->showLabel) {
1040            $titleLabel = $style->resolveLabel($type);
1041            $titleHeight = $lineH;
1042        }
1043
1044        $totalHeight = 2.0 * $padding + $titleHeight + $bodyHeight;
1045        $bottomY = $y - $totalHeight;
1046
1047        [$br, $bg, $bb] = $style->resolveBarColor($type);
1048        [$bgR, $bgG, $bgB] = $style->resolveBgColor($type);
1049
1050        $cs = $this->ensureContentStream();
1051        $cs->saveGraphicsState();
1052
1053        $cs->setFillColorRGB($bgR, $bgG, $bgB)
1054            ->rectangle($x, $bottomY, $width, $totalHeight)
1055            ->fill();
1056
1057        $cs->setFillColorRGB($br, $bg, $bb)
1058            ->rectangle($x, $bottomY, $barWidth, $totalHeight)
1059            ->fill();
1060
1061        $textColor = $style->textColor ?? [0.0, 0.0, 0.0];
1062        $cs->setFillColorRGB($textColor[0], $textColor[1], $textColor[2]);
1063        $cursorY = $y - $padding;
1064
1065        if ($titleLabel !== null) {
1066            $encodedTitle = $titleFont->getTextEncoder()?->encode($titleLabel) ?? $titleLabel;
1067            $baseline = $cursorY - $size;
1068            $cs->beginText()
1069                ->setFont($titleFont->getResourceName(), $size)
1070                ->moveTextPosition($textX, $baseline)
1071                ->showText($encodedTitle)
1072                ->endText();
1073            $cursorY -= $lineH;
1074        }
1075
1076        foreach ($bodyLines as $line) {
1077            if ($line === '') {
1078                $cursorY -= $lineH;
1079                continue;
1080            }
1081            $baseline = $cursorY - $size;
1082            $cs->beginText()
1083                ->setFont($bodyFont->getResourceName(), $size)
1084                ->moveTextPosition($textX, $baseline)
1085                ->showText($line)
1086                ->endText();
1087            $cursorY -= $lineH;
1088        }
1089
1090        $cs->restoreGraphicsState();
1091        return $totalHeight;
1092    }
1093
1094    // -----------------------------------------------------------------------
1095    // Blockquote
1096    // -----------------------------------------------------------------------
1097
1098    /**
1099     * Draw a blockquote at `(x, y)`: indented body text with a
1100     * coloured vertical bar down the left edge. The top of the quote
1101     * sits at `$y`; text and bar grow downward.
1102     *
1103     * The caller selects the font (typically the italic variant) and
1104     * the desired bar colour. Returns the height consumed.
1105     */
1106    public function drawQuote(
1107        string $text,
1108        float $x,
1109        float $y,
1110        Font $font,
1111        float $size = 11.0,
1112        float $maxWidth = 468.0,
1113        float $lineHeight = 1.2,
1114        float $indent = 18.0,
1115        float $barWidth = 2.0,
1116        ?ColorInterface $barColor = null,
1117        ?ColorInterface $textColor = null,
1118    ): self {
1119        $metrics = StandardFontMetrics::get($font->getFamily());
1120        $encoded = $font->getTextEncoder()?->encode($text) ?? $text;
1121        $textWidth = max(0.0, $maxWidth - $indent);
1122        $lines = TextLayout::wrap($encoded, $metrics, $size, $textWidth);
1123
1124        $cs = $this->ensureContentStream();
1125        $cs->saveGraphicsState();
1126
1127        if ($textColor !== null) {
1128            $this->applyFillColor($cs, $textColor);
1129        }
1130
1131        $lineH = $size * $lineHeight;
1132        $textX = $x + $indent;
1133        $textTopY = $y;
1134        $cursorY = $y;
1135        foreach ($lines as $line) {
1136            if ($line === '') {
1137                $cursorY -= $lineH;
1138                continue;
1139            }
1140            $baselineY = $cursorY - $size;
1141            $cs->beginText()
1142                ->setFont($font->getResourceName(), $size)
1143                ->moveTextPosition($textX, $baselineY)
1144                ->showText($line)
1145                ->endText();
1146            $cursorY -= $lineH;
1147        }
1148
1149        // Left bar
1150        if ($barColor !== null) {
1151            $vals = $barColor->toArray();
1152            $cs->setStrokeColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0);
1153        } else {
1154            $cs->setStrokeColorRGB(0.7, 0.7, 0.7);
1155        }
1156        $cs->setLineWidth($barWidth);
1157        $barX = $x + $barWidth / 2.0;
1158        $cs->moveTo($barX, $textTopY - 2.0)
1159            ->lineTo($barX, $cursorY + 2.0)
1160            ->stroke();
1161
1162        $cs->restoreGraphicsState();
1163        return $this;
1164    }
1165
1166    // -----------------------------------------------------------------------
1167    // Lists
1168    // -----------------------------------------------------------------------
1169
1170    /**
1171     * Draw a {@see ListBlock} at `(x, y)`. The marker for the first
1172     * item sits at `$x`; wrapped text starts one indent further right.
1173     *
1174     * Standard 14 fonts only — pass a custom font's metrics by going
1175     * through {@see ListRenderer::drawBlock()} directly.
1176     */
1177    public function drawList(
1178        ListBlock $list,
1179        float $x,
1180        float $y,
1181        Font $font,
1182        float $fontSize = 11.0,
1183        float $maxWidth = 468.0,
1184        float $lineHeight = 1.2,
1185        ?ListStyle $style = null,
1186    ): self {
1187        $style ??= new ListStyle();
1188        $metrics = StandardFontMetrics::get($font->getFamily());
1189
1190        $renderer = new ListRenderer();
1191        $renderer->drawBlock(
1192            $this->ensureContentStream(),
1193            $x,
1194            $y,
1195            $list,
1196            $maxWidth,
1197            $font,
1198            $metrics,
1199            $fontSize,
1200            $lineHeight,
1201            $style,
1202        );
1203        return $this;
1204    }
1205
1206    // -----------------------------------------------------------------------
1207    // Raw escape
1208    // -----------------------------------------------------------------------
1209
1210    /**
1211     * Execute raw ContentStream operations via a closure.
1212     *
1213     * @param \Closure(ContentStream): void $fn
1214     */
1215    public function raw(\Closure $fn): self
1216    {
1217        $fn($this->ensureContentStream());
1218        return $this;
1219    }
1220
1221    // -----------------------------------------------------------------------
1222    // Internal helpers
1223    // -----------------------------------------------------------------------
1224
1225    private function ensureContentStream(): ContentStream
1226    {
1227        if ($this->cs === null) {
1228            $this->cs = new ContentStream();
1229            ($this->registerFn)($this->cs);
1230            $this->corePage->contents[] = new PdfReference($this->cs->objectNumber);
1231        }
1232        return $this->cs;
1233    }
1234
1235    private function applyFillColor(ContentStream $cs, ColorInterface $color): void
1236    {
1237        $vals = $color->toArray();
1238        match ($color->getColorSpace()) {
1239            'DeviceRGB' => $cs->setFillColorRGB($vals[0], $vals[1], $vals[2]),
1240            'DeviceCMYK' => $cs->setFillColorCMYK($vals[0], $vals[1], $vals[2], $vals[3]),
1241            'DeviceGray' => $cs->setFillColorGray($vals[0]),
1242            default => $cs->setFillColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0),
1243        };
1244    }
1245
1246    private function applyStrokeColor(ContentStream $cs, ColorInterface $color): void
1247    {
1248        $vals = $color->toArray();
1249        match ($color->getColorSpace()) {
1250            'DeviceRGB' => $cs->setStrokeColorRGB($vals[0], $vals[1], $vals[2]),
1251            'DeviceCMYK' => $cs->setStrokeColorCMYK($vals[0], $vals[1], $vals[2], $vals[3]),
1252            'DeviceGray' => $cs->setStrokeColorGray($vals[0]),
1253            default => $cs->setStrokeColorRGB($vals[0] ?? 0, $vals[1] ?? 0, $vals[2] ?? 0),
1254        };
1255    }
1256
1257    private function paintPath(ContentStream $cs, ?ColorInterface $fill, ?ColorInterface $stroke): void
1258    {
1259        if ($fill !== null && $stroke !== null) {
1260            $cs->fillAndStroke();
1261        } elseif ($fill !== null) {
1262            $cs->fill();
1263        } elseif ($stroke !== null) {
1264            $cs->stroke();
1265        } else {
1266            $cs->stroke(); // default to stroke if nothing specified
1267        }
1268    }
1269
1270    private function emitEllipseOps(ContentStream $cs, float $cx, float $cy, float $rx, float $ry, float $k): void
1271    {
1272        $kx = $k * $rx;
1273        $ky = $k * $ry;
1274
1275        $cs->moveTo($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        $cs->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry);
1279        $cs->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy);
1280    }
1281}