Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.35% covered (success)
92.35%
845 / 915
58.11% covered (warning)
58.11%
43 / 74
CRAP
0.00% covered (danger)
0.00%
0 / 1
Translator
92.35% covered (success)
92.35%
845 / 915
58.11% covered (warning)
58.11%
43 / 74
455.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paint
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 paintChildren
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 paintNestedSvg
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
6
 resolveViewportLength
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 nestedViewBoxTransform
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 nestedAlignRatios
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 paintElement
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
11
 resolveMaskState
98.33% covered (success)
98.33%
59 / 60
0.00% covered (danger)
0.00%
0 / 1
14
 computeMaskRegion
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 resolveClipPath
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 applyClipPath
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 inverseAffine
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 resolveClipRule
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 emitElementPath
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
9.49
 emitRectPath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 resolvePercentLength
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 currentViewport
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 parseLengthPrefixForViewport
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 emitCirclePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 emitEllipsePathFor
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
30
 emitPolylinePath
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 emitPolygonPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 emitPathPath
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 resolveOpacityState
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 needsStrokeParams
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 applyStrokeParams
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 dispatchElement
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
18
 paintSwitch
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 switchChildPasses
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 resolveSystemLanguage
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 paintUse
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
13
 paintUseReferent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 paintImage
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
14.07
 paintImageFromPath
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
13.87
 decodeDataUri
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 materialiseDataUri
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 fetchAndMaterialiseHttpHref
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
6.42
 paintTextElement
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
14
 parseTextShadow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 splitTextShadowItems
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 parseTextShadowLayer
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
11.04
 tokenizeTextShadowLayer
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 parseLengthToken
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
9.86
 paintTextPerGlyph
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
7
 emitTextMatrix
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 collectTextContent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 paintPath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 emitPathCommand
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
12.11
 emitMoveTo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 emitLineTo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 emitHorizontalLineTo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 emitVerticalLineTo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 emitCurveTo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 emitSmoothCurveTo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 emitQuadraticCurveTo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 emitSmoothQuadraticCurveTo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 emitArcTo
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 emitClosePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 emitQuadraticAsCubic
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 resolvePoint
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintRect
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 paintCircle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 paintEllipse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 paintLine
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 paintPolyline
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 paintPolygon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 emitEllipsePath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 emitPolyPath
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 applyFillAndStroke
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 applyFillPaint
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 applyStrokePaint
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 setFillColor
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
15.55
 setStrokeColor
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
15.55
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf;
6
7use Phpdftk\Color\CmykColor;
8use Phpdftk\Color\ColorInterface;
9use Phpdftk\Color\GrayColor;
10use Phpdftk\Color\RgbColor;
11use Phpdftk\Filesystem\LocalFilesystem;
12use Phpdftk\ImageMetadata\ImageParser;
13use Phpdftk\ResourceLoader\Exception\FetchFailedException;
14use Phpdftk\ResourceLoader\Exception\SsrfBlockedException;
15use Phpdftk\ResourceLoader\ResourceLoader;
16use Phpdftk\Pdf\Core\Content\ContentStream;
17use Phpdftk\Pdf\Core\Document\GroupAttributes;
18use Phpdftk\Pdf\Core\Graphics\ExtGState;
19use Phpdftk\Pdf\Core\Graphics\SoftMask;
20use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
21use Phpdftk\Pdf\Core\PdfArray;
22use Phpdftk\Pdf\Core\PdfName;
23use Phpdftk\Pdf\Core\PdfNumber;
24use Phpdftk\Pdf\Core\PdfReference;
25use Phpdftk\Pdf\Writer\Page;
26use Phpdftk\Pdf\Writer\PdfWriter;
27use Phpdftk\SvgToPdf\Gradient\GradientPainter;
28use Phpdftk\SvgToPdf\Text\FontResolver;
29use Phpdftk\Svg\ClipPath;
30use Phpdftk\Svg\Defs;
31use Phpdftk\Svg\Element;
32use Phpdftk\Svg\Image as SvgImage;
33use Phpdftk\Svg\Mask;
34use Phpdftk\Svg\Symbol;
35use Phpdftk\Svg\Use_;
36use Phpdftk\SvgToPdf\Geometry\BoundingBox;
37use Phpdftk\Svg\Path;
38use Phpdftk\Svg\Path\ArcTo;
39use Phpdftk\Svg\Path\ClosePath;
40use Phpdftk\Svg\Path\CurveTo;
41use Phpdftk\Svg\Path\HorizontalLineTo;
42use Phpdftk\Svg\Path\LineTo;
43use Phpdftk\Svg\Path\MoveTo;
44use Phpdftk\Svg\Path\PathCommand;
45use Phpdftk\Svg\Path\QuadraticCurveTo;
46use Phpdftk\Svg\Path\SmoothCurveTo;
47use Phpdftk\Svg\Path\SmoothQuadraticCurveTo;
48use Phpdftk\Svg\Path\VerticalLineTo;
49use Phpdftk\Svg\Shape\Circle;
50use Phpdftk\Svg\Shape\Ellipse;
51use Phpdftk\Svg\Shape\Line;
52use Phpdftk\Svg\Shape\Polygon;
53use Phpdftk\Svg\Shape\Polyline;
54use Phpdftk\Svg\Shape\Rect;
55use Phpdftk\Svg\SvgDocument;
56use Phpdftk\Svg\Text as TextNode;
57use Phpdftk\Svg\Text\TextElement;
58use Phpdftk\Svg\Value\Paint;
59use Phpdftk\Svg\Value\Color as SvgColor;
60use Phpdftk\Svg\Value\Paint\CurrentColor;
61use Phpdftk\Svg\Value\Paint\None_;
62use Phpdftk\Svg\Value\Paint\SolidColor;
63use Phpdftk\Svg\Value\Paint\Url;
64use Phpdftk\SvgToPdf\Path\ArcToCubic;
65use Phpdftk\SvgToPdf\Path\PathPainterState;
66
67/**
68 * Translates a parsed `Phpdftk\Svg\SvgDocument` into PDF content-stream
69 * operators. The translator is a thin recursive walk: each element is
70 * dispatched to a per-shape painter that emits the right path and
71 * `f`/`S`/`B` operator combination.
72 *
73 * Coordinate convention: SVG and PDF disagree on Y-axis direction (SVG
74 * Y-down, PDF Y-up). The translator emits SVG coordinates verbatim — the
75 * caller is responsible for setting up a PDF transformation (`cm`) that
76 * flips and translates if it wants the SVG to appear at a specific PDF
77 * position. Tests can paint directly into a fresh PDF stream because the
78 * default user space happens to put numbers in a viewable range for small
79 * SVGs.
80 *
81 * What 3K covers: basic shapes (`<rect>`, `<circle>`, `<ellipse>`,
82 * `<line>`, `<polyline>`, `<polygon>`) and the SolidColor fill / stroke
83 * paint cases. `<path>` lands in 3L, `<g>` + transforms in 3M, gradients
84 * in 3O, text in 3P, use/clip/mask/image in 3Q. Until then unrecognised
85 * elements are walked through transparently — their children paint as if
86 * the unknown container weren't there.
87 *
88 * Default paint per SVG 2 §13.2.1: black fill, no stroke. The translator
89 * applies that fallback when no explicit fill is set on the element.
90 */
91final class Translator
92{
93    /**
94     * Cubic-Bézier "magic number" approximating a unit-circle quarter
95     * arc — `(4/3) · tan(π/8) ≈ 0.5522847498`. Standard κ for
96     * `<circle>` / `<ellipse>` rendering.
97     */
98    private const float KAPPA = 0.5522847498;
99
100    /**
101     * Optional `phpdftk/resource-loader` for `http(s)://` `<image>`
102     * hrefs. When `null` (the default — preserves existing call-
103     * site behaviour), network hrefs drop silently per the SVG 2
104     * §12.6 "no image available" outcome. When supplied, the
105     * loader runs (with its SSRF guard, redirect handling, body
106     * cap, and MIME sniffing) and the embedded bytes get
107     * materialised to a temp file the same way `data:` URIs do.
108     */
109    public function __construct(
110        private readonly ?ResourceLoader $resourceLoader = null,
111    ) {}
112
113    /**
114     * Paint a parsed SVG document into the given content stream.
115     *
116     * When `$page` is supplied, the painter registers an `ExtGState`
117     * resource on that page for any element that carries `opacity`,
118     * `fill-opacity`, or `stroke-opacity` < 1 and emits the `gs`
119     * operator to invoke it. Without a `$page` reference opacity
120     * attributes are silently ignored — the painter falls back to
121     * fully-opaque rendering.
122     *
123     * When `$page` AND `$writer` are both supplied, gradient paint
124     * references (`fill="url(#id)"`) resolve through the writer's
125     * `PdfDoc` for shading registration and the page's
126     * `useGradient` for resource attachment. Without them, gradient
127     * fills fall back to no paint per SVG 2's "invalid → no paint"
128     * semantics.
129     */
130    /**
131     * @param array{w: float, h: float}|null $effectiveViewport
132     *   Override viewport for inner percentage-attribute resolution
133     *   when the document's own width/height/viewBox don't yield
134     *   useful dimensions. Set by `SvgRenderer::draw` when it
135     *   synthesises a source rect from the destination.
136     */
137    public function paint(
138        SvgDocument $document,
139        ContentStream $stream,
140        ?Page $page = null,
141        ?PdfWriter $writer = null,
142        bool $compensateTextFlip = false,
143        ?array $effectiveViewport = null,
144    ): void {
145        $this->page = $page;
146        $this->writer = $writer;
147        $this->document = $document;
148        $this->compensateTextFlip = $compensateTextFlip;
149        $this->effectiveViewport = $effectiveViewport;
150        $this->gradientPainter = $page !== null && $writer !== null
151            ? new GradientPainter($writer, $page, $document)
152            : null;
153        $this->fontResolver = $page !== null && $writer !== null
154            ? new FontResolver($writer, $page)
155            : null;
156        try {
157            $viewBox = $document->viewBox();
158            if ($viewBox !== null && ($viewBox[0] !== 0.0 || $viewBox[1] !== 0.0)) {
159                // SVG 2 §7 — the viewBox's `min-x`/`min-y` shift the
160                // origin of the local coordinate system. The proper
161                // viewBox-to-viewport mapping (with `preserveAspectRatio`)
162                // needs a caller-supplied target rectangle, so it lives
163                // in the 3R adapter layer; here we honour just the
164                // translation so the painted content stays anchored
165                // correctly relative to the viewBox.
166                $stream->saveGraphicsState();
167                $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$viewBox[0], -$viewBox[1]);
168                $this->paintChildren($document, $stream);
169                $stream->restoreGraphicsState();
170                return;
171            }
172            $this->paintChildren($document, $stream);
173        } finally {
174            $this->page = null;
175            $this->writer = null;
176            $this->document = null;
177            $this->gradientPainter = null;
178            $this->fontResolver = null;
179            $this->compensateTextFlip = false;
180            $this->effectiveViewport = null;
181        }
182    }
183
184    private ?Page $page = null;
185    private ?PdfWriter $writer = null;
186    /** @var array{w: float, h: float}|null */
187    private ?array $effectiveViewport = null;
188    private ?SvgDocument $document = null;
189    /**
190     * Stack of nested `<svg>` viewports (viewBox units), innermost last.
191     * Percentage lengths + `currentViewport()` resolve against the top.
192     *
193     * @var list<array{w: float, h: float}>
194     */
195    private array $viewportStack = [];
196    /**
197     * A `<use>`'s width/height override for the nested `<svg>` it
198     * references (SVG 2 §5.6.1), consumed by the next `paintNestedSvg`.
199     *
200     * @var array{w: float, h: float}|null
201     */
202    private ?array $pendingUseViewport = null;
203    private ?GradientPainter $gradientPainter = null;
204    private ?FontResolver $fontResolver = null;
205    private bool $compensateTextFlip = false;
206
207    private function paintChildren(Element $parent, ContentStream $stream): void
208    {
209        foreach ($parent->children as $child) {
210            if ($child instanceof Element) {
211                $this->paintElement($child, $stream);
212            }
213        }
214    }
215
216    /**
217     * SVG 2 §7.5 — a nested `<svg>` establishes a new viewport from its
218     * `x` / `y` / `width` / `height`, clips overflow to it, and (when it
219     * carries a `viewBox`) sets up a new user coordinate system via the
220     * viewBox-to-viewport scale + `preserveAspectRatio` alignment.
221     */
222    private function paintNestedSvg(\Phpdftk\Svg\NestedSvg $svg, ContentStream $stream): void
223    {
224        // A `<use>` that references this svg overrides its viewport size.
225        $override = $this->pendingUseViewport;
226        $this->pendingUseViewport = null;
227        $vp = $this->currentViewport();
228        $x = $this->resolveViewportLength($svg->getAttribute('x'), $vp['w'], 0.0);
229        $y = $this->resolveViewportLength($svg->getAttribute('y'), $vp['h'], 0.0);
230        // Width / height: a `<use>` override wins; otherwise the svg's own
231        // attributes, defaulting to 100% of the enclosing viewport.
232        $w = $override['w'] ?? $this->resolveViewportLength($svg->widthAttribute(), $vp['w'], $vp['w']);
233        $h = $override['h'] ?? $this->resolveViewportLength($svg->heightAttribute(), $vp['h'], $vp['h']);
234        if ($w <= 0.0 || $h <= 0.0) {
235            return; // A zero-sized viewport disables rendering (SVG 2 §7.5).
236        }
237
238        $stream->saveGraphicsState();
239        // Position the viewport, then clip overflow to it (the default
240        // `overflow: hidden` on a nested `<svg>`).
241        $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
242        $stream->rectangle(0.0, 0.0, $w, $h);
243        $stream->clip();
244        $stream->endPath();
245
246        $childViewport = ['w' => $w, 'h' => $h];
247        $viewBox = $svg->viewBox();
248        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
249            [$scaleX, $scaleY, $offsetX, $offsetY]
250                = $this->nestedViewBoxTransform($svg, $viewBox[2], $viewBox[3], $w, $h);
251            $stream->concatMatrix(
252                $scaleX,
253                0.0,
254                0.0,
255                $scaleY,
256                $offsetX - $viewBox[0] * $scaleX,
257                $offsetY - $viewBox[1] * $scaleY,
258            );
259            $childViewport = ['w' => $viewBox[2], 'h' => $viewBox[3]];
260        }
261
262        $this->viewportStack[] = $childViewport;
263        $this->paintChildren($svg, $stream);
264        array_pop($this->viewportStack);
265        $stream->restoreGraphicsState();
266    }
267
268    /**
269     * Resolve a nested-viewport length attribute: a `%` resolves against
270     * the given viewport dimension, a plain number is taken as-is, and an
271     * absent/empty value falls back to `$default`.
272     */
273    private function resolveViewportLength(?string $raw, float $viewport, float $default): float
274    {
275        if ($raw === null || trim($raw) === '') {
276            return $default;
277        }
278        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*%\s*$/', $raw, $m) === 1) {
279            return ((float) $m[1]) / 100.0 * $viewport;
280        }
281        $plain = self::parseLengthPrefixForViewport($raw);
282        return $plain ?? $default;
283    }
284
285    /**
286     * Nested viewBox-to-viewport mapping in SVG user space (y-down — the
287     * outer document transform applies the PDF flip). Returns
288     * `[scaleX, scaleY, offsetX, offsetY]`. Mirrors the root
289     * `applyPreserveAspectRatio` but without the PDF y-axis inversion,
290     * since a nested `<svg>` composes inside the already-flipped space.
291     *
292     * @return array{0: float, 1: float, 2: float, 3: float}
293     */
294    private function nestedViewBoxTransform(
295        \Phpdftk\Svg\NestedSvg $svg,
296        float $srcW,
297        float $srcH,
298        float $dstW,
299        float $dstH,
300    ): array {
301        $sx = $srcW > 0.0 ? $dstW / $srcW : 1.0;
302        $sy = $srcH > 0.0 ? $dstH / $srcH : 1.0;
303        $par = strtolower(trim($svg->getAttribute('preserveAspectRatio') ?? ''));
304        if ($par === 'none') {
305            return [$sx, $sy, 0.0, 0.0];
306        }
307        $tokens = preg_split('/\s+/', $par) ?: [];
308        $align = $tokens[0] ?? '';
309        $slice = ($tokens[1] ?? 'meet') === 'slice';
310        $scale = $slice ? max($sx, $sy) : min($sx, $sy);
311        [$xRatio, $yRatio] = self::nestedAlignRatios($align);
312        return [
313            $scale,
314            $scale,
315            $xRatio * ($dstW - $scale * $srcW),
316            $yRatio * ($dstH - $scale * $srcH),
317        ];
318    }
319
320    /**
321     * `preserveAspectRatio` align keyword → `[xRatio, yRatio]` leftover
322     * fractions, in SVG y-down space (`yMin` → 0, `yMid` → 0.5,
323     * `yMax` → 1). Defaults to `xMidYMid`.
324     *
325     * @return array{0: float, 1: float}
326     */
327    private static function nestedAlignRatios(string $align): array
328    {
329        $align = $align === '' ? 'xmidymid' : $align;
330        $xRatio = str_contains($align, 'xmax') ? 1.0 : (str_contains($align, 'xmin') ? 0.0 : 0.5);
331        $yRatio = str_contains($align, 'ymax') ? 1.0 : (str_contains($align, 'ymin') ? 0.0 : 0.5);
332        return [$xRatio, $yRatio];
333    }
334
335    private function paintElement(Element $element, ContentStream $stream): void
336    {
337        // Any of these scope-leaking attributes triggers a `q`/`Q` wrap
338        // so the state doesn't leak across siblings:
339        //
340        //   - transform: emits `cm`.
341        //   - opacity / fill-opacity / stroke-opacity (< 1): emits `gs`.
342        //   - stroke params (w / J / j / M / d): each emits its own op.
343        //   - clip-path: emits the clip region inside the same wrap.
344        //   - mask: emits `gs` referencing a SMask-bearing ExtGState.
345        //
346        // Painting the same shape with all defaults stays a one-shot
347        // op stream — no overhead when none of the above is set.
348        $transform = $element->transform();
349        $opacityGs = $this->resolveOpacityState($element);
350        $needsStrokeParams = $this->needsStrokeParams($element);
351        $clipPath = $this->resolveClipPath($element);
352        $maskGs = $this->resolveMaskState($element);
353        $needsWrap = $transform !== null
354            || $opacityGs !== null
355            || $needsStrokeParams
356            || $clipPath !== null
357            || $maskGs !== null;
358
359        if (!$needsWrap) {
360            $this->dispatchElement($element, $stream);
361            return;
362        }
363
364        $stream->saveGraphicsState();
365        if ($transform !== null) {
366            $matrix = $transform->toMatrix();
367            $stream->concatMatrix(
368                $matrix[0],
369                $matrix[1],
370                $matrix[2],
371                $matrix[3],
372                $matrix[4],
373                $matrix[5],
374            );
375        }
376        if ($opacityGs !== null) {
377            $stream->setGraphicsState($opacityGs);
378        }
379        if ($needsStrokeParams) {
380            $this->applyStrokeParams($element, $stream);
381        }
382        if ($clipPath !== null) {
383            $this->applyClipPath($clipPath, $element, $stream);
384        }
385        if ($maskGs !== null) {
386            $stream->setGraphicsState($maskGs);
387        }
388        $this->dispatchElement($element, $stream);
389        $stream->restoreGraphicsState();
390    }
391
392    /**
393     * Resolve `mask="url(#id)"` to a registered `ExtGState` whose
394     * `/SMask` references a Form XObject containing the mask's
395     * painted children. Returns the resource name to invoke via
396     * `gs`, or null when no mask is set / can't be resolved.
397     *
398     * Pipeline:
399     *
400     *   1. Paint the `<mask>`'s children into a new `ContentStream`
401     *      (running the full Translator pipeline so gradients, fonts,
402     *      images, etc. all register on the host page).
403     *   2. Wrap the resulting bytes in a Form XObject. `/Group /S
404     *      Transparency /CS DeviceGray` so the form's pixels become an
405     *      alpha channel via their luminance.
406     *   3. Build a `SoftMask` dict with `/S Luminosity` and `/BC [0]`
407     *      so the backdrop outside the mask region is black (hidden).
408     *   4. Drop the SMask in an `ExtGState`, register, and attach to
409     *      the page's resources under a stable name.
410     *
411     * Implemented:
412     *
413     *   - `maskContentUnits = objectBoundingBox` applies a bbox `cm`
414     *     to the mask content stream so authored coords are in [0, 1].
415     *   - SVG 2 §14.5.4 defaults are honoured: bbox-mode defaults
416     *     `(-10%, -10%, 120%, 120%)` so the mask reaches a hair
417     *     beyond the painted geometry; userspace-mode defaults to
418     *     the masked element's own bbox.
419     *   - Explicit `x` / `y` / `width` / `height` attributes on
420     *     the `<mask>` element override the defaults.
421     */
422    private function resolveMaskState(Element $element): ?string
423    {
424        if ($this->writer === null
425            || $this->page === null
426            || $this->document === null
427        ) {
428            return null;
429        }
430        $raw = $element->getAttribute('mask');
431        if ($raw === null) {
432            return null;
433        }
434        $trimmed = trim($raw);
435        if ($trimmed === 'none') {
436            return null;
437        }
438        if (preg_match('/^url\(\s*#([^)\s]+)\s*\)/i', $trimmed, $m) !== 1) {
439            return null;
440        }
441        $referent = $this->document->findById($m[1]);
442        if (!$referent instanceof Mask) {
443            return null;
444        }
445        $elementBbox = BoundingBox::compute($element);
446        if ($elementBbox === null) {
447            return null;
448        }
449        $region = self::computeMaskRegion($referent, $elementBbox);
450
451        $maskStream = new ContentStream();
452        if ($referent->maskContentUnits() === 'objectBoundingBox') {
453            // Reify mask children's [0, 1] coords against the masked
454            // element's bbox (the same reference frame the masked
455            // element's geometry inhabits — not the mask region).
456            $maskStream->concatMatrix(
457                $elementBbox['width'],
458                0.0,
459                0.0,
460                $elementBbox['height'],
461                $elementBbox['minX'],
462                $elementBbox['minY'],
463            );
464        }
465        foreach ($referent->children as $child) {
466            if ($child instanceof Element) {
467                $this->paintElement($child, $maskStream);
468            }
469        }
470        $operatorBytes = implode("\n", $maskStream->getOperators());
471        $form = new FormXObject(
472            new PdfArray([
473                new PdfNumber($region['minX']),
474                new PdfNumber($region['minY']),
475                new PdfNumber($region['minX'] + $region['width']),
476                new PdfNumber($region['minY'] + $region['height']),
477            ]),
478            $operatorBytes,
479        );
480        // Transparency group with the DeviceGray colour space so the
481        // luminance of the painted pixels becomes the mask's alpha.
482        $group = new GroupAttributes('Transparency');
483        $group->cs = new PdfName('DeviceGray');
484        $form->group = $group;
485        $this->writer->register($form);
486
487        // SVG 2 §14.5: `mask-type="alpha"` uses the painted pixels'
488        // alpha channel directly; the default `luminance` (also
489        // covering the old `mask-type` absent case) uses the
490        // luminance of the RGB pixels. PDF maps these to the SoftMask
491        // `/S /Alpha` and `/S /Luminosity` modes.
492        $maskSubtype = strtolower(trim($referent->getAttribute('mask-type') ?? ''));
493        $smask = new SoftMask(
494            $maskSubtype === 'alpha' ? 'Alpha' : 'Luminosity',
495            new PdfReference($form->objectNumber),
496        );
497        // Backdrop colour black ([0]) so anywhere the mask content
498        // doesn't paint stays hidden — matches SVG 2's "outside the
499        // mask region the alpha is 0" semantic.
500        $smask->bc = new PdfArray([new PdfNumber(0)]);
501
502        $gstate = new ExtGState();
503        $gstate->sMask = $smask;
504        $this->writer->register($gstate);
505
506        $name = 'GS_mask_' . $gstate->objectNumber;
507        $resources = $this->page->corePage()->resources;
508        if ($resources !== null) {
509            $resources->extGState[$name] = new PdfReference($gstate->objectNumber);
510        }
511        return $name;
512    }
513
514    /**
515     * Compute the rectangular region that the mask covers, in user
516     * space. SVG 2 §14.5.4 defaults:
517     *
518     *  - `maskUnits="objectBoundingBox"` (default): `x=-10%, y=-10%,
519     *    width=120%, height=120%` of the masked element's bounding
520     *    box. The 10% pad on each side is what makes the mask
521     *    naturally reach a hair beyond the painted geometry so
522     *    anti-aliased edges aren't clipped.
523     *  - `maskUnits="userSpaceOnUse"`: SVG defaults to the viewport
524     *    rect (-10% etc. of the viewport), which we don't have direct
525     *    access to here — fall back to the masked element's bbox
526     *    (matches 3R+8 behaviour for unset attributes).
527     *
528     * @param array{minX: float, minY: float, width: float, height: float} $elementBbox
529     * @return array{minX: float, minY: float, width: float, height: float}
530     */
531    private static function computeMaskRegion(Mask $mask, array $elementBbox): array
532    {
533        $bboxMode = $mask->maskUnits() === 'objectBoundingBox';
534
535        if ($bboxMode) {
536            $x = $mask->x() ?? -0.1;
537            $y = $mask->y() ?? -0.1;
538            $w = $mask->width() ?? 1.2;
539            $h = $mask->height() ?? 1.2;
540            return [
541                'minX' => $elementBbox['minX'] + $x * $elementBbox['width'],
542                'minY' => $elementBbox['minY'] + $y * $elementBbox['height'],
543                'width' => $w * $elementBbox['width'],
544                'height' => $h * $elementBbox['height'],
545            ];
546        }
547
548        return [
549            'minX' => $mask->x() ?? $elementBbox['minX'],
550            'minY' => $mask->y() ?? $elementBbox['minY'],
551            'width' => $mask->width() ?? $elementBbox['width'],
552            'height' => $mask->height() ?? $elementBbox['height'],
553        ];
554    }
555
556    /**
557     * Resolve an element's `clip-path="url(#id)"` reference to the
558     * matching `<clipPath>` element. Returns null when no clip-path is
559     * set, the reference can't be parsed, or the id doesn't resolve to
560     * a clipPath — each case falls through to the unclipped paint per
561     * SVG 2's "invalid → no clip" rule.
562     */
563    private function resolveClipPath(Element $element): ?ClipPath
564    {
565        if ($this->document === null) {
566            return null;
567        }
568        $raw = $element->getAttribute('clip-path');
569        if ($raw === null) {
570            return null;
571        }
572        $trimmed = trim($raw);
573        if ($trimmed === 'none') {
574            return null;
575        }
576        if (preg_match('/^url\(\s*#([^)\s]+)\s*\)/i', $trimmed, $m) !== 1) {
577            return null;
578        }
579        $referent = $this->document->findById($m[1]);
580        return $referent instanceof ClipPath ? $referent : null;
581    }
582
583    /**
584     * Construct the PDF clipping region from the `<clipPath>` element's
585     * geometry and emit `W`/`W*` + `n` so subsequent painting is
586     * scoped to it. `clipPathUnits="objectBoundingBox"` is honoured by
587     * sandwiching the path construction between a bbox-space `cm` and
588     * its inverse — the clip region is "frozen" by `W` in user space,
589     * so applying the inverse `cm` returns the CTM to its pre-clip
590     * state without disturbing the established region.
591     */
592    private function applyClipPath(ClipPath $clipPath, Element $element, ContentStream $stream): void
593    {
594        $useBbox = $clipPath->clipPathUnits() === 'objectBoundingBox';
595        $bbox = $useBbox ? BoundingBox::compute($element) : null;
596        if ($useBbox && $bbox === null) {
597            // bbox required for objectBoundingBox mode but unavailable
598            // (e.g. `<path>` element with no bbox helper at 3R+3) —
599            // fall back to no clip rather than emitting a broken clip.
600            return;
601        }
602
603        $bboxMatrix = $bbox === null
604            ? null
605            : [$bbox['width'], 0.0, 0.0, $bbox['height'], $bbox['minX'], $bbox['minY']];
606        $clipTransform = $clipPath->transform()?->toMatrix();
607
608        // Apply outer→inner: bbox first, then the clipPath's own
609        // `transform`. Children paint in their authored coordinate
610        // system; the cm composition reifies that into user space
611        // for the W operator to capture.
612        if ($bboxMatrix !== null) {
613            $stream->concatMatrix(...$bboxMatrix);
614        }
615        if ($clipTransform !== null) {
616            $stream->concatMatrix(...$clipTransform);
617        }
618        foreach ($clipPath->children as $child) {
619            if ($child instanceof Element) {
620                $this->emitElementPath($child, $stream);
621            }
622        }
623        // Undo the cms in reverse order so the CTM is back to the
624        // pre-clip user space. The clip region established by `W`
625        // already lives in device space, so it survives the CTM
626        // changes.
627        if ($clipTransform !== null) {
628            $stream->concatMatrix(...self::inverseAffine($clipTransform));
629        }
630        if ($bboxMatrix !== null) {
631            $stream->concatMatrix(...self::inverseAffine($bboxMatrix));
632        }
633
634        $rule = self::resolveClipRule($clipPath);
635        if ($rule === 'evenodd') {
636            $stream->clipEvenOdd();
637        } else {
638            $stream->clip();
639        }
640        $stream->endPath();
641    }
642
643    /**
644     * Inverse of a 3×2 affine matrix in PDF `[a b c d e f]` order. The
645     * full 3×3 affine has bottom row `[0 0 1]` so we only need the six
646     * SVG/PDF entries. Singular matrices (det == 0) shouldn't occur
647     * for any transform we generate; if one does, returning identity
648     * makes the worst-case behaviour "no inverse applied" rather than
649     * a divide-by-zero.
650     *
651     * @param array{float, float, float, float, float, float} $m
652     * @return array{float, float, float, float, float, float}
653     */
654    private static function inverseAffine(array $m): array
655    {
656        [$a, $b, $c, $d, $e, $f] = $m;
657        $det = $a * $d - $b * $c;
658        if (abs($det) < 1.0e-12) {
659            return [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
660        }
661        return [
662            $d / $det,
663            -$b / $det,
664            -$c / $det,
665            $a / $det,
666            ($c * $f - $d * $e) / $det,
667            ($b * $e - $a * $f) / $det,
668        ];
669    }
670
671    /**
672     * `clip-rule` per SVG 2 §14.4.4. The attribute lives on the
673     * `<clipPath>` element itself at 3R+3; per-child clip-rule support
674     * (the spec allows overriding on individual children) lands later.
675     *
676     * @return 'nonzero'|'evenodd'
677     */
678    private static function resolveClipRule(ClipPath $clipPath): string
679    {
680        $raw = $clipPath->getAttribute('clip-rule');
681        return strtolower(trim($raw ?? '')) === 'evenodd' ? 'evenodd' : 'nonzero';
682    }
683
684    /**
685     * Emit just the geometry of an element — the path operators that
686     * would have ended up before the fill/stroke op. Used by
687     * `applyClipPath` to assemble a clipping region without painting.
688     */
689    private function emitElementPath(Element $element, ContentStream $stream): void
690    {
691        match (true) {
692            $element instanceof Rect => $this->emitRectPath($element, $stream),
693            $element instanceof Circle => $this->emitCirclePath($element, $stream),
694            $element instanceof Ellipse => $this->emitEllipsePathFor($element, $stream),
695            $element instanceof Polyline => $this->emitPolylinePath($element, $stream),
696            $element instanceof Polygon => $this->emitPolygonPath($element, $stream),
697            $element instanceof Path => $this->emitPathPath($element, $stream),
698            // `<line>` and other non-area-enclosing children don't
699            // contribute to a clip region per SVG 2 §14.4.1.
700            default => null,
701        };
702    }
703
704    private function emitRectPath(Rect $rect, ContentStream $stream): void
705    {
706        $vp = $this->currentViewport();
707        $x = $this->resolvePercentLength($rect->getAttribute('x'), $vp['w'], $rect->x());
708        $y = $this->resolvePercentLength($rect->getAttribute('y'), $vp['h'], $rect->y());
709        $w = $this->resolvePercentLength($rect->getAttribute('width'), $vp['w'], $rect->width());
710        $h = $this->resolvePercentLength($rect->getAttribute('height'), $vp['h'], $rect->height());
711        if ($w > 0.0 && $h > 0.0) {
712            $stream->rectangle($x, $y, $w, $h);
713        }
714    }
715
716    /**
717     * Resolve a length attribute against the current viewport. SVG 2 §10.3:
718     * percentage values resolve against the relevant viewport dimension
719     * (`%` width → viewport width, `%` height → viewport height). Anything
720     * else falls back to `$plain` — the shape's existing unit-stripped
721     * float, which is what every shape accessor returned before this method
722     * existed.
723     */
724    private function resolvePercentLength(?string $raw, float $viewport, float $plain): float
725    {
726        if ($raw === null || trim($raw) === '') {
727            return 0.0;
728        }
729        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(\s*%)/', $raw, $m) !== 1) {
730            return $plain;
731        }
732        return ((float) $m[1]) / 100.0 * $viewport;
733    }
734
735    /**
736     * Width / height of the current viewport in viewBox units. Falls back
737     * to the root SVG width/height attributes when no `viewBox` is set, and
738     * to zero when neither is available — matching SVG 2's "no useful
739     * viewport" outcome (percentages collapse to 0). Nested `<svg>` /
740     * `<symbol>` viewports land later; this picks the document root, which
741     * is what `background-image: url(...svg)` paints into.
742     *
743     * @return array{w: float, h: float}
744     */
745    private function currentViewport(): array
746    {
747        // Innermost nested `<svg>` viewport wins for percentage resolution.
748        if ($this->viewportStack !== []) {
749            return $this->viewportStack[count($this->viewportStack) - 1];
750        }
751        if ($this->document === null) {
752            return ['w' => 0.0, 'h' => 0.0];
753        }
754        $viewBox = $this->document->viewBox();
755        if ($viewBox !== null) {
756            return ['w' => $viewBox[2], 'h' => $viewBox[3]];
757        }
758        $w = self::parseLengthPrefixForViewport($this->document->widthAttribute());
759        $h = self::parseLengthPrefixForViewport($this->document->heightAttribute());
760        if ($w !== null && $h !== null) {
761            // Mirror SvgRenderer::resolveSourceRect's near-integral
762            // snap (#143 / crbug.com/1392140): an SVG with
763            // `width="99.99999"` resolves child `width="100%"` to
764            // 100, matching the integer-snap browsers apply to
765            // <img>-embedded SVG intrinsic dimensions. Without this,
766            // the inner viewport stays at 99.99999 even after the
767            // outer rendering box rounds to 100, so a `width="100%"`
768            // child paints 0.00001 short of the box's right edge.
769            return ['w' => (float) round($w), 'h' => (float) round($h)];
770        }
771        // Fall back to the caller's effective viewport (the destination
772        // rect from `SvgRenderer::draw`) when document attributes alone
773        // can't give a useful viewport — e.g. an SVG with only a
774        // percentage `width` and no `height` or `viewBox`.
775        if ($this->effectiveViewport !== null) {
776            return $this->effectiveViewport;
777        }
778        return ['w' => $w ?? 0.0, 'h' => $h ?? 0.0];
779    }
780
781    private static function parseLengthPrefixForViewport(?string $raw): ?float
782    {
783        if ($raw === null) {
784            return null;
785        }
786        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*([%a-zA-Z]*)/', $raw, $m) !== 1) {
787            return null;
788        }
789        // Percentage attributes carry no intrinsic viewport extent — they
790        // resolve against the caller-supplied effective viewport (CSS
791        // Images 3 §5.2). Reject so the fallback path picks up the
792        // dst-derived viewport instead of mis-anchoring to the bare
793        // percentage value.
794        if ($m[2] === '%') {
795            return null;
796        }
797        return (float) $m[1];
798    }
799
800    private function emitCirclePath(Circle $circle, ContentStream $stream): void
801    {
802        if ($circle->r() > 0.0) {
803            $this->emitEllipsePath($stream, $circle->cx(), $circle->cy(), $circle->r(), $circle->r());
804        }
805    }
806
807    private function emitEllipsePathFor(Ellipse $ellipse, ContentStream $stream): void
808    {
809        $rx = $ellipse->rx();
810        $ry = $ellipse->ry();
811        if ($rx !== null && $ry !== null && $rx > 0.0 && $ry > 0.0) {
812            $this->emitEllipsePath($stream, $ellipse->cx(), $ellipse->cy(), $rx, $ry);
813        }
814    }
815
816    private function emitPolylinePath(Polyline $polyline, ContentStream $stream): void
817    {
818        $points = $polyline->points();
819        if (count($points) >= 2) {
820            $this->emitPolyPath($stream, $points, closed: false);
821        }
822    }
823
824    private function emitPolygonPath(Polygon $polygon, ContentStream $stream): void
825    {
826        $points = $polygon->points();
827        if (count($points) >= 3) {
828            $this->emitPolyPath($stream, $points, closed: true);
829        }
830    }
831
832    private function emitPathPath(Path $path, ContentStream $stream): void
833    {
834        $commands = $path->d()->commands;
835        if ($commands === []) {
836            return;
837        }
838        $state = new PathPainterState();
839        foreach ($commands as $command) {
840            $this->emitPathCommand($command, $stream, $state);
841        }
842    }
843
844    /**
845     * Register (or reuse) the `ExtGState` resource encoding this
846     * element's effective opacity. Returns the resource name or null
847     * when no `gs` op is needed (no Page reference, or every opacity
848     * channel is already ≥ 0.999).
849     */
850    private function resolveOpacityState(Element $element): ?string
851    {
852        if ($this->page === null) {
853            return null;
854        }
855        $opacity = $element->opacity() ?? 1.0;
856        $fillOpacity = ($element->fillOpacity() ?? 1.0) * $opacity;
857        $strokeOpacity = ($element->strokeOpacity() ?? 1.0) * $opacity;
858        if ($fillOpacity >= 0.999 && $strokeOpacity >= 0.999) {
859            return null;
860        }
861        return $this->page->ensureOpacityState($strokeOpacity, $fillOpacity);
862    }
863
864    /**
865     * Whether the element carries any stroke parameter that would
866     * leak past a sibling shape if emitted inline. Used to decide
867     * whether to wrap the element's painting in `q`/`Q`.
868     */
869    private function needsStrokeParams(Element $element): bool
870    {
871        if ($element->stroke() === null || $element->stroke() instanceof None_) {
872            return false;
873        }
874        if ($element->strokeWidth() !== null) {
875            return true;
876        }
877        if ($element->strokeLinecap() !== null) {
878            return true;
879        }
880        if ($element->strokeLinejoin() !== null) {
881            return true;
882        }
883        if ($element->strokeMiterlimit() !== null) {
884            return true;
885        }
886        if ($element->strokeDasharray() !== []) {
887            return true;
888        }
889        if ($element->strokeDashoffset() !== null) {
890            return true;
891        }
892        return false;
893    }
894
895    private function applyStrokeParams(Element $element, ContentStream $stream): void
896    {
897        $width = $element->strokeWidth();
898        if ($width !== null) {
899            $stream->setLineWidth($width);
900        }
901        $cap = $element->strokeLinecap();
902        if ($cap !== null) {
903            $stream->setLineCap(match ($cap) {
904                'round' => 1,
905                'square' => 2,
906                default => 0, // butt
907            });
908        }
909        $join = $element->strokeLinejoin();
910        if ($join !== null) {
911            $stream->setLineJoin(match ($join) {
912                'round' => 1,
913                'bevel' => 2,
914                default => 0, // miter / miter-clip / arcs all fall back to PDF's miter
915            });
916        }
917        $miterLimit = $element->strokeMiterlimit();
918        if ($miterLimit !== null) {
919            $stream->setMiterLimit($miterLimit);
920        }
921        $dash = $element->strokeDasharray();
922        if ($dash !== []) {
923            $offset = (int) round($element->strokeDashoffset() ?? 0.0);
924            $stream->setDashPattern($dash, $offset);
925        }
926    }
927
928    private function dispatchElement(Element $element, ContentStream $stream): void
929    {
930        match (true) {
931            $element instanceof Rect => $this->paintRect($element, $stream),
932            $element instanceof Circle => $this->paintCircle($element, $stream),
933            $element instanceof Ellipse => $this->paintEllipse($element, $stream),
934            $element instanceof Line => $this->paintLine($element, $stream),
935            $element instanceof Polyline => $this->paintPolyline($element, $stream),
936            $element instanceof Polygon => $this->paintPolygon($element, $stream),
937            $element instanceof Path => $this->paintPath($element, $stream),
938            $element instanceof TextElement => $this->paintTextElement($element, $stream),
939            $element instanceof \Phpdftk\Svg\NestedSvg => $this->paintNestedSvg($element, $stream),
940            $element instanceof Use_ => $this->paintUse($element, $stream),
941            $element instanceof SvgImage => $this->paintImage($element, $stream),
942            // `<defs>` and `<symbol>` are referenceable containers: they
943            // never paint themselves at the document level. `<use>`
944            // expands `<defs>` / `<symbol>` referents; `clip-path` on
945            // a painted element pulls in a `<clipPath>`; `mask` pulls
946            // in a `<mask>`. Skipping them here also skips their
947            // nested shape children, which is what the spec wants
948            // (SVG 2 §5.5 / §5.6 / §14.4 / §14.5).
949            $element instanceof Defs,
950            $element instanceof Symbol,
951            $element instanceof ClipPath,
952            $element instanceof Mask,
953            // SVG 2 §11.6 — `<marker>` is referenced via `marker-*`
954            // properties on shapes; it never paints at the document
955            // level. The painter pulls in a marker when it's
956            // requested at a path vertex (future deliverable).
957            $element instanceof \Phpdftk\Svg\Marker,
958            // SVG 2 §13.3 — `<pattern>` is referenced via
959            // `fill="url(#id)"` / `stroke="url(#id)"`; the painter
960            // pulls in pattern content when a shape's paint
961            // resolves to a pattern URL (future deliverable).
962            $element instanceof \Phpdftk\Svg\Pattern,
963            // SVG 2 Filter Effects §6.1 — `<filter>` is referenced
964            // via `filter="url(#id)"` and applied via SoftMask. Never
965            // paints at document level.
966            $element instanceof \Phpdftk\Svg\Filter,
967            // SVG 2 §6.3 — `<view>` is a fragment-targeted viewport
968            // definition. Activated only when callers pass a view id;
969            // never paints at document level.
970            $element instanceof \Phpdftk\Svg\View,
971            // SVG 2 §15.2 — `<script>` content is JS; never executes
972            // server-side and never paints. Explicit skip prevents
973            // any nested `<text>` etc. children from leaking into the
974            // output stream.
975            $element instanceof \Phpdftk\Svg\Script,
976            // SVG 2 §19 — animation elements never paint at the
977            // static print medium. Skip to avoid recursing into any
978            // nested `<mpath>` etc. that would otherwise fall through
979            // to the default container walk.
980            $element instanceof \Phpdftk\Svg\Animation => null,
981            // SVG 2 §6.4 — `<metadata>` carries RDF/XML or other
982            // non-render metadata. Skip explicitly so any embedded
983            // RDF text doesn't accidentally leak.
984            $element instanceof \Phpdftk\Svg\Metadata => null,
985            // SVG 2 §15.3 — `<title>` and `<desc>` are accessibility
986            // metadata that never renders directly. Skip the recursive
987            // walk so their text content doesn't leak into output.
988            $element instanceof \Phpdftk\Svg\Title,
989            $element instanceof \Phpdftk\Svg\Desc,
990            // SVG 2 §11.6 — `<foreignObject>` holds non-SVG content
991            // (HTML / MathML). Rendering that content requires a
992            // separate pipeline; the typed class lets callers detect
993            // and route it. Inside the SVG dispatch we skip the
994            // foreign tree entirely to avoid painting GenericElement
995            // children that aren't actual SVG shapes.
996            $element instanceof \Phpdftk\Svg\ForeignObject => null,
997            // SVG 2 §12.1.1 — `<a>` paints its children. The PDF link
998            // annotation (which is what makes the rendered region
999            // clickable) is a future concern that needs page-relative
1000            // bounding boxes; we paint the inner content faithfully
1001            // here so the visual stays correct.
1002            $element instanceof \Phpdftk\Svg\A_
1003                => $this->paintChildren($element, $stream),
1004            // SVG 2 §5.7 — `<switch>` picks the first child whose
1005            // conditional-processing attributes all evaluate true.
1006            $element instanceof \Phpdftk\Svg\Switch_
1007                => $this->paintSwitch($element, $stream),
1008            // `<g>` and any other container fall through here — the
1009            // recursive walk still descends into their children.
1010            default => $this->paintChildren($element, $stream),
1011        };
1012    }
1013
1014    /**
1015     * SVG 2 §5.7 — paint the first child of `<switch>` whose
1016     * conditional-processing tests all evaluate true. Empty-test
1017     * children always pass. Stops after painting the first
1018     * matching child.
1019     */
1020    private function paintSwitch(\Phpdftk\Svg\Switch_ $switch, ContentStream $stream): void
1021    {
1022        foreach ($switch->children as $child) {
1023            if (!$child instanceof Element) {
1024                continue;
1025            }
1026            if (!$this->switchChildPasses($child)) {
1027                continue;
1028            }
1029            $this->paintElement($child, $stream);
1030            return;
1031        }
1032    }
1033
1034    /**
1035     * Evaluate the conditional-processing attributes on a
1036     * `<switch>` child:
1037     *
1038     *   - `requiredFeatures` — legacy SVG 1.1 list of feature
1039     *     URIs. All listed URIs evaluate true here so the test
1040     *     never fails (matches major browser behaviour now).
1041     *   - `requiredExtensions` — author-supplied extension URIs.
1042     *     Any presence fails: print medium can't observe any
1043     *     UA-specific extensions.
1044     *   - `systemLanguage` — comma-separated BCP 47 tags. The
1045     *     test passes when at least one tag prefix-matches the
1046     *     `xml:lang` (or `lang`) ancestor chain.
1047     */
1048    private function switchChildPasses(Element $child): bool
1049    {
1050        if ($child->hasAttribute('requiredExtensions')) {
1051            $exts = trim($child->getAttribute('requiredExtensions') ?? '');
1052            if ($exts !== '') {
1053                return false;
1054            }
1055        }
1056        if ($child->hasAttribute('systemLanguage')) {
1057            $needed = preg_split(
1058                '/[\s,]+/',
1059                strtolower(trim($child->getAttribute('systemLanguage') ?? '')),
1060            ) ?: [];
1061            $needed = array_filter($needed, static fn(string $s): bool => $s !== '');
1062            $documentLang = $this->resolveSystemLanguage($child);
1063            $matched = false;
1064            foreach ($needed as $tag) {
1065                if ($tag === $documentLang || str_starts_with($documentLang, $tag . '-')) {
1066                    $matched = true;
1067                    break;
1068                }
1069            }
1070            if (!$matched) {
1071                return false;
1072            }
1073        }
1074        return true;
1075    }
1076
1077    /**
1078     * Walk ancestor `xml:lang` / `lang` attributes. Defaults to
1079     * `en` when nothing is set, matching the SVG default UA
1080     * behaviour for systemLanguage matching.
1081     */
1082    private function resolveSystemLanguage(Element $element): string
1083    {
1084        for ($n = $element; $n !== null; $n = $n->parent) {
1085            $lang = $n->getAttribute('xml:lang') ?? $n->getAttribute('lang');
1086            if ($lang !== null && $lang !== '') {
1087                return strtolower($lang);
1088            }
1089        }
1090        return 'en';
1091    }
1092
1093    private function paintUse(Use_ $use, ContentStream $stream): void
1094    {
1095        if ($this->document === null) {
1096            return;
1097        }
1098        $referent = $use->resolve($this->document);
1099        if ($referent === null) {
1100            return;
1101        }
1102        // SVG 2 §5.6 — the `<use>`'s `x` / `y` translate the referent's
1103        // coordinate system. Width / height overrides on `<symbol>`
1104        // referents resolve through viewBox-to-viewport mapping; that
1105        // mapping needs target-rectangle context we don't have until
1106        // 3R, so 3Q honours the translation only.
1107        $x = $use->x();
1108        $y = $use->y();
1109        // SVG 2 §5.6.1 — when the referent is a nested `<svg>`, the use's
1110        // width/height (if both set) become that svg's viewport.
1111        $useW = $use->width();
1112        $useH = $use->height();
1113        $override = ($referent instanceof \Phpdftk\Svg\NestedSvg
1114            && $useW !== null && $useH !== null && $useW > 0.0 && $useH > 0.0)
1115            ? ['w' => $useW, 'h' => $useH]
1116            : null;
1117        if ($x === 0.0 && $y === 0.0 && $override === null) {
1118            $this->paintUseReferent($referent, $stream);
1119            return;
1120        }
1121        $stream->saveGraphicsState();
1122        if ($x !== 0.0 || $y !== 0.0) {
1123            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
1124        }
1125        $prevOverride = $this->pendingUseViewport;
1126        $this->pendingUseViewport = $override;
1127        $this->paintUseReferent($referent, $stream);
1128        $this->pendingUseViewport = $prevOverride;
1129        $stream->restoreGraphicsState();
1130    }
1131
1132    /**
1133     * `<symbol>` referents have their children painted directly per
1134     * SVG 2 §5.5 — referencing them via `<use>` is the only way they
1135     * paint at all. Everything else routes through normal dispatch.
1136     */
1137    private function paintUseReferent(Element $referent, ContentStream $stream): void
1138    {
1139        if ($referent instanceof Symbol) {
1140            $this->paintChildren($referent, $stream);
1141            return;
1142        }
1143        $this->paintElement($referent, $stream);
1144    }
1145
1146    private function paintImage(SvgImage $image, ContentStream $stream): void
1147    {
1148        if ($this->writer === null || $this->page === null) {
1149            return;
1150        }
1151        $href = $image->href();
1152        if ($href === null) {
1153            return;
1154        }
1155        // 3Q: filesystem hrefs.
1156        // 3R+18: `data:` URIs decoded and materialised to a temp
1157        // file so the same `ImageParser` + `PdfWriter::addImage`
1158        // flow (which only accepts paths) can embed them.
1159        // 4F.1: `http(s)://` hrefs route through the optional
1160        // `ResourceLoader` injected at construction. SSRF guard,
1161        // redirect handling, content-length cap, and MIME sniffing
1162        // all live in the loader; we just hand off the URL and
1163        // materialise the bytes to a temp file like the data:
1164        // path. When no loader is configured, http(s):// drops
1165        // silently per the original 3R+18 posture so existing
1166        // callers don't change behaviour.
1167        $sourcePath = $href;
1168        $tempPath = null;
1169        if (str_starts_with($href, 'data:')) {
1170            $decoded = self::decodeDataUri($href);
1171            if ($decoded === null) {
1172                return;
1173            }
1174            $tempPath = self::materialiseDataUri($decoded);
1175            if ($tempPath === null) {
1176                return;
1177            }
1178            $sourcePath = $tempPath;
1179        } elseif (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
1180            if ($this->resourceLoader === null) {
1181                return;
1182            }
1183            $tempPath = $this->fetchAndMaterialiseHttpHref($href);
1184            if ($tempPath === null) {
1185                return;
1186            }
1187            $sourcePath = $tempPath;
1188        } elseif (str_contains($href, '://')) {
1189            return;
1190        }
1191        try {
1192            $this->paintImageFromPath($sourcePath, $image, $stream);
1193        } finally {
1194            if ($tempPath !== null) {
1195                @unlink($tempPath);
1196            }
1197        }
1198    }
1199
1200    private function paintImageFromPath(string $path, SvgImage $image, ContentStream $stream): void
1201    {
1202        // Resolve intrinsic source dimensions before registering so we
1203        // can fall back to them when the SVG omits width / height.
1204        // `ImageParser::parse` is cheap (header-only read) and
1205        // bypassing the writer keeps this self-contained.
1206        try {
1207            $info = ImageParser::parse($path);
1208        } catch (\Throwable) {
1209            return;
1210        }
1211        if ($this->writer === null || $this->page === null) {
1212            return;
1213        }
1214        try {
1215            $resourceName = $this->writer->addImage($path, $this->page);
1216        } catch (\Throwable) {
1217            // Missing file / unparseable bytes / unsupported format —
1218            // SVG 2 §12.6's "no image available" outcome is to paint
1219            // nothing.
1220            return;
1221        }
1222
1223        $x = $image->x();
1224        $y = $image->y();
1225        $w = $image->width();
1226        $h = $image->height();
1227        $intrinsicW = (float) $info->width;
1228        $intrinsicH = (float) $info->height;
1229        // SVG 2 §12.6 fallback ladder for missing width / height: when
1230        // one dimension is given the other is scaled to preserve the
1231        // intrinsic aspect ratio; when both are absent the intrinsic
1232        // dimensions are used directly. A zero-size intrinsic image
1233        // still paints nothing.
1234        if ($intrinsicW <= 0.0 || $intrinsicH <= 0.0) {
1235            return;
1236        }
1237        if ($w === null && $h === null) {
1238            $w = $intrinsicW;
1239            $h = $intrinsicH;
1240        } elseif ($w === null) {
1241            // height set, width follows the intrinsic aspect.
1242            $w = ($h ?? 0.0) * ($intrinsicW / $intrinsicH);
1243        } elseif ($h === null) {
1244            $h = $w * ($intrinsicH / $intrinsicW);
1245        }
1246        if ($w <= 0.0 || $h <= 0.0) {
1247            return;
1248        }
1249
1250        // PDF Do paints the image inside a unit square at (0, 0) → (1, 1).
1251        // The transformation matrix translates + scales it to the SVG
1252        // rectangle. Y is flipped so the image's top-left lands at
1253        // (x, y) — PDF's image space is y-down within the unit square,
1254        // SVG's image element is y-down too, so the flip cancels them
1255        // out and the image renders right-side-up at the SVG-stated
1256        // position.
1257        $stream->saveGraphicsState();
1258        $stream->concatMatrix($w, 0.0, 0.0, -$h, $x, $y + $h);
1259        $stream->doXObject($resourceName);
1260        $stream->restoreGraphicsState();
1261    }
1262
1263    /**
1264     * Decode a `data:` URI per RFC 2397.
1265     *
1266     *  - `data:image/png;base64,iVBOR…` → base64 payload
1267     *  - `data:image/svg+xml,<svg…>`     → percent-decoded payload
1268     *  - `data:,hello`                    → empty mime, percent-decoded
1269     *
1270     * Returns `null` if the URI is malformed or the base64 won't
1271     * decode. The MIME type is returned for the caller's information
1272     * but is not authoritative — `ImageParser::parse` still sniffs the
1273     * actual bytes to determine the PDF colour space + filter.
1274     *
1275     * @return array{bytes: string, mime: string}|null
1276     */
1277    private static function decodeDataUri(string $uri): ?array
1278    {
1279        if (!str_starts_with($uri, 'data:')) {
1280            return null;
1281        }
1282        $rest = substr($uri, 5);
1283        $commaPos = strpos($rest, ',');
1284        if ($commaPos === false) {
1285            return null;
1286        }
1287        $meta = substr($rest, 0, $commaPos);
1288        $data = substr($rest, $commaPos + 1);
1289        $isBase64 = false;
1290        $mime = '';
1291        if ($meta !== '') {
1292            $parts = explode(';', $meta);
1293            $mime = $parts[0];
1294            foreach (array_slice($parts, 1) as $param) {
1295                if ($param === 'base64') {
1296                    $isBase64 = true;
1297                }
1298            }
1299        }
1300        if ($isBase64) {
1301            $bytes = base64_decode($data, true);
1302            if ($bytes === false) {
1303                return null;
1304            }
1305        } else {
1306            $bytes = rawurldecode($data);
1307        }
1308        return ['bytes' => $bytes, 'mime' => $mime];
1309    }
1310
1311    /**
1312     * Write the decoded data-URI bytes to a temp file that lives only
1313     * for the duration of `paintImage`. Returns the path on success
1314     * or `null` if either the temp file couldn't be opened or the
1315     * write failed — both fall back to the SVG "no image available"
1316     * outcome.
1317     *
1318     * @param array{bytes: string, mime: string} $decoded
1319     */
1320    private static function materialiseDataUri(array $decoded): ?string
1321    {
1322        $tmpPath = tempnam(sys_get_temp_dir(), 'svg-img-');
1323        if ($tmpPath === false) {
1324            return null;
1325        }
1326        try {
1327            LocalFilesystem::writeFile($tmpPath, $decoded['bytes']);
1328            return $tmpPath;
1329        } catch (\Throwable) {
1330            @unlink($tmpPath);
1331            return null;
1332        }
1333    }
1334
1335    /**
1336     * 4F.1 — fetch an `http(s)://` `<image>` href through the
1337     * injected ResourceLoader and materialise the bytes to a temp
1338     * file so the existing `ImageParser` + `PdfWriter::addImage`
1339     * flow can register them as a PDF XObject. Returns the temp
1340     * path on success or null on any failure (SSRF policy
1341     * violation, network error, non-2xx, body cap exceeded, write
1342     * failure) — all of which surface as the SVG 2 §12.6 "no image
1343     * available" outcome.
1344     */
1345    private function fetchAndMaterialiseHttpHref(string $href): ?string
1346    {
1347        if ($this->resourceLoader === null) {
1348            return null;
1349        }
1350        try {
1351            $result = $this->resourceLoader->fetch($href);
1352        } catch (SsrfBlockedException | FetchFailedException) {
1353            return null;
1354        }
1355        $tmpPath = tempnam(sys_get_temp_dir(), 'svg-http-img-');
1356        if ($tmpPath === false) {
1357            return null;
1358        }
1359        try {
1360            LocalFilesystem::writeFile($tmpPath, $result->bytes);
1361            return $tmpPath;
1362        } catch (\Throwable) {
1363            @unlink($tmpPath);
1364            return null;
1365        }
1366    }
1367
1368    private function paintTextElement(TextElement $text, ContentStream $stream): void
1369    {
1370        // Without writer + page references the resolver can't register a
1371        // font, so the text silently drops. Matches the same standalone
1372        // posture the gradient painter uses at 3O.
1373        if ($this->fontResolver === null) {
1374            return;
1375        }
1376        $content = self::collectTextContent($text);
1377        if ($content === '') {
1378            return;
1379        }
1380
1381        // SVG 2 default fill for `<text>` is black; the existing
1382        // applyFillPaint path covers that, but we apply it *before*
1383        // entering the text object so the colour persists across the
1384        // Tj sequence (PDF text objects share the page graphics state).
1385        $fill = $text->fill();
1386        if (!($fill instanceof None_)) {
1387            $this->applyFillPaint($fill, $text, $stream);
1388        }
1389
1390        $font = $this->fontResolver->resolve(
1391            $text->fontFamily(),
1392            $text->fontWeight(),
1393            $text->fontStyle(),
1394        );
1395        $size = $text->fontSize() ?? 16.0;
1396
1397        // SVG 2 §11.6 list-valued positioning. `<text x="10 20 30">ABC</text>`
1398        // positions each glyph individually. When any list has > 1 entry
1399        // the painter walks character-by-character; otherwise it falls
1400        // through to the cheaper single-Tj path. Single-value rotate
1401        // also takes the per-glyph code path so the rotation lands in
1402        // the text matrix.
1403        $xList = $text->x();
1404        $yList = $text->y();
1405        $dxList = $text->dx();
1406        $dyList = $text->dy();
1407        $rotateList = $text->rotate();
1408        $perGlyph = count($xList) > 1
1409            || count($yList) > 1
1410            || $dxList !== []
1411            || $dyList !== []
1412            || $rotateList !== [];
1413
1414        // CSS Text Decoration 4 §6 — paint text-shadow layers BEHIND
1415        // the real text. Each layer is a sharp-offset copy of the
1416        // glyph run in the shadow colour. Blur-radius is parsed but
1417        // not currently rasterised (matches the html-to-pdf path,
1418        // which also ignores blur). The first listed shadow paints on
1419        // top of later shadows, so we reverse for back-to-front order.
1420        // Per-glyph positioning is intentionally NOT shadowed at v1 -
1421        // the WPT corpus only exercises single-position <text> with
1422        // shadows, and per-glyph shadows would compound complexity
1423        // without coverage to validate them.
1424        if (!$perGlyph) {
1425            $fillFallback = $fill instanceof SolidColor ? $fill->color : null;
1426            $shadows = self::parseTextShadow($text->textShadow(), $fillFallback);
1427            foreach (array_reverse($shadows) as $shadow) {
1428                $stream->saveGraphicsState();
1429                $this->setFillColor($stream, $shadow['color']);
1430                $stream->beginText()->setFont($font, $size);
1431                $sx = ($xList[0] ?? 0.0) + $shadow['offsetX'];
1432                $sy = ($yList[0] ?? 0.0) + $shadow['offsetY'];
1433                if ($this->compensateTextFlip) {
1434                    $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $sx, $sy);
1435                } else {
1436                    $stream->moveTextPosition($sx, $sy);
1437                }
1438                $stream->showText($content);
1439                $stream->endText();
1440                $stream->restoreGraphicsState();
1441            }
1442        }
1443
1444        $stream->beginText()->setFont($font, $size);
1445        if (!$perGlyph) {
1446            $x = $xList[0] ?? 0.0;
1447            $y = $yList[0] ?? 0.0;
1448            if ($this->compensateTextFlip) {
1449                // Under an outer Y-flip CTM (`SvgRenderer::draw` applies
1450                // one), `Td` would render glyphs upside-down. Setting Tm
1451                // with `d = -1` flips text space so the combined
1452                // `Tm · CTM` cancels the outer flip and glyphs render
1453                // upright at the SVG-stated baseline.
1454                $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $x, $y);
1455            } else {
1456                $stream->moveTextPosition($x, $y);
1457            }
1458            $stream->showText($content);
1459        } else {
1460            $this->paintTextPerGlyph($content, $xList, $yList, $dxList, $dyList, $rotateList, $stream);
1461        }
1462        $stream->endText();
1463    }
1464
1465    /**
1466     * Parse the raw CSS `text-shadow` value into renderable shadow
1467     * layers per CSS Text Decoration 4 §6.
1468     *
1469     * Grammar:
1470     *
1471     *     <text-shadow> = none | <shadow># (comma-separated layers)
1472     *     <shadow> = <length>{2,3} <color>?
1473     *
1474     * The two lengths are offset-x and offset-y; the optional third
1475     * is blur-radius (parsed and validated as non-negative, but the
1476     * painter does not currently rasterise blur — same posture as
1477     * the html-to-pdf path's `collectTextShadowLayers`). When a layer
1478     * omits the colour, it falls back to `$fillFallback` (the text's
1479     * own fill colour) and finally to opaque black.
1480     *
1481     * Returns an empty list for `null` input, `none`, or any layer
1482     * that fails to parse - SVG 2's "invalid → ignored" semantics
1483     * applied per-shadow keeps a malformed shadow from poisoning
1484     * earlier valid ones.
1485     *
1486     * @return list<array{offsetX: float, offsetY: float, color: ColorInterface}>
1487     */
1488    private static function parseTextShadow(?string $raw, ?ColorInterface $fillFallback): array
1489    {
1490        if ($raw === null) {
1491            return [];
1492        }
1493        $items = self::splitTextShadowItems($raw);
1494        $layers = [];
1495        foreach ($items as $item) {
1496            $layer = self::parseTextShadowLayer($item, $fillFallback);
1497            if ($layer !== null) {
1498                $layers[] = $layer;
1499            }
1500        }
1501        return $layers;
1502    }
1503
1504    /**
1505     * Split a `text-shadow` value on top-level commas, preserving
1506     * commas inside `rgb(...)` / `rgba(...)` / `hsl(...)` etc.
1507     *
1508     * @return list<string>
1509     */
1510    private static function splitTextShadowItems(string $raw): array
1511    {
1512        $items = [];
1513        $depth = 0;
1514        $buffer = '';
1515        $length = strlen($raw);
1516        for ($i = 0; $i < $length; $i++) {
1517            $ch = $raw[$i];
1518            if ($ch === '(') {
1519                $depth++;
1520            } elseif ($ch === ')') {
1521                $depth = max(0, $depth - 1);
1522            }
1523            if ($ch === ',' && $depth === 0) {
1524                $items[] = $buffer;
1525                $buffer = '';
1526                continue;
1527            }
1528            $buffer .= $ch;
1529        }
1530        if (trim($buffer) !== '') {
1531            $items[] = $buffer;
1532        }
1533        return array_values(array_filter(array_map('trim', $items), static fn($s) => $s !== ''));
1534    }
1535
1536    /**
1537     * Parse a single shadow layer. Returns null on any structural
1538     * malformation (fewer than two lengths, blur < 0, more than three
1539     * lengths, unparseable colour token).
1540     *
1541     * @return array{offsetX: float, offsetY: float, color: ColorInterface}|null
1542     */
1543    private static function parseTextShadowLayer(string $raw, ?ColorInterface $fillFallback): ?array
1544    {
1545        // Tokenise on whitespace, but keep `rgb(1, 2, 3)` etc. as a
1546        // single token. Lengths are simple; the colour may contain
1547        // spaces inside its parens.
1548        $tokens = self::tokenizeTextShadowLayer($raw);
1549        if ($tokens === []) {
1550            return null;
1551        }
1552        $lengths = [];
1553        $colorRaw = null;
1554        foreach ($tokens as $token) {
1555            $length = self::parseLengthToken($token);
1556            if ($length !== null) {
1557                if (count($lengths) >= 3) {
1558                    return null;
1559                }
1560                $lengths[] = $length;
1561                continue;
1562            }
1563            if ($colorRaw !== null) {
1564                return null;
1565            }
1566            $colorRaw = $token;
1567        }
1568        if (count($lengths) < 2) {
1569            return null;
1570        }
1571        if (count($lengths) === 3 && $lengths[2] < 0.0) {
1572            // CSS Text Decoration 4: blur-radius must be non-negative.
1573            return null;
1574        }
1575        $color = null;
1576        if ($colorRaw !== null) {
1577            $color = SvgColor::parse($colorRaw);
1578            if ($color === null) {
1579                return null;
1580            }
1581        } else {
1582            $color = $fillFallback ?? new RgbColor(0.0, 0.0, 0.0);
1583        }
1584        return [
1585            'offsetX' => $lengths[0],
1586            'offsetY' => $lengths[1],
1587            'color' => $color,
1588        ];
1589    }
1590
1591    /**
1592     * Whitespace-tokenise a single shadow layer keeping function
1593     * forms (`rgb(0, 128, 0)`) as one token.
1594     *
1595     * @return list<string>
1596     */
1597    private static function tokenizeTextShadowLayer(string $raw): array
1598    {
1599        $tokens = [];
1600        $buffer = '';
1601        $depth = 0;
1602        $length = strlen($raw);
1603        for ($i = 0; $i < $length; $i++) {
1604            $ch = $raw[$i];
1605            if ($ch === '(') {
1606                $depth++;
1607                $buffer .= $ch;
1608                continue;
1609            }
1610            if ($ch === ')') {
1611                $depth = max(0, $depth - 1);
1612                $buffer .= $ch;
1613                continue;
1614            }
1615            if ($depth === 0 && (ctype_space($ch) || $ch === ',')) {
1616                if ($buffer !== '') {
1617                    $tokens[] = $buffer;
1618                    $buffer = '';
1619                }
1620                continue;
1621            }
1622            $buffer .= $ch;
1623        }
1624        if ($buffer !== '') {
1625            $tokens[] = $buffer;
1626        }
1627        return $tokens;
1628    }
1629
1630    /**
1631     * Parse a CSS length token used inside `text-shadow`. Supports
1632     * unitless (`0`), `px`, `pt`, `em`, and a couple of absolute
1633     * physical units. Returns null when the token doesn't look like
1634     * a length so the caller treats it as the colour slot instead.
1635     * Unit support is intentionally narrow - text-shadow in real
1636     * documents almost always uses `px` (or `0`).
1637     */
1638    private static function parseLengthToken(string $raw): ?float
1639    {
1640        $trimmed = trim($raw);
1641        if ($trimmed === '') {
1642            return null;
1643        }
1644        if (preg_match('/^([+-]?(?:\d+\.?\d*|\.\d+))([a-zA-Z%]*)$/', $trimmed, $m) !== 1) {
1645            return null;
1646        }
1647        $value = (float) $m[1];
1648        $unit = strtolower($m[2]);
1649        return match ($unit) {
1650            '', 'px', 'pt' => $value,
1651            'in' => $value * 72.0,
1652            'cm' => $value * (72.0 / 2.54),
1653            'mm' => $value * (72.0 / 25.4),
1654            // em / rem / vw / vh / % aren't resolvable without a
1655            // containing context — reject so an unknown unit doesn't
1656            // silently land at 0.
1657            default => null,
1658        };
1659    }
1660
1661    /**
1662     * Per-glyph positioning per SVG 2 §11.6: walk content character-by-
1663     * character, emit one `Tm` per explicitly-positioned glyph, then
1664     * batch the remaining characters as a single `Tj` so their natural
1665     * advance handles the trailing positioning.
1666     *
1667     * Sticky semantics: when a glyph specifies `x[i]` but not `y[i]`
1668     * (or vice-versa), the unspecified component carries over from the
1669     * previous glyph's position. SVG 2 actually defines this as "the
1670     * previous glyph's effective position" which requires knowing the
1671     * per-glyph advance — we don't have font metrics here at 3R+5, so
1672     * we use the last explicit value instead. The result is correct
1673     * for the common case where `x` and `y` have matching lengths.
1674     *
1675     * `dx[i]` / `dy[i]` are additive deltas applied to the resolved
1676     * `(stickyX, stickyY)` — SVG 2 §11.6 specifies them as relative
1677     * offsets from the glyph's natural position. Without font metrics
1678     * "natural position" collapses to "sticky position", so we treat
1679     * dx/dy as deltas from sticky. The deltas accumulate into sticky
1680     * so subsequent glyphs without their own dx/dy inherit the shift,
1681     * matching the common renderer behaviour for stacked offsets like
1682     * super/subscript adjustments.
1683     *
1684     * @param list<float> $xList
1685     * @param list<float> $yList
1686     * @param list<float> $dxList
1687     * @param list<float> $dyList
1688     * @param list<float> $rotateList
1689     */
1690    private function paintTextPerGlyph(
1691        string $content,
1692        array $xList,
1693        array $yList,
1694        array $dxList,
1695        array $dyList,
1696        array $rotateList,
1697        ContentStream $stream,
1698    ): void {
1699        $chars = mb_str_split($content);
1700        if ($chars === []) {
1701            return;
1702        }
1703        $explicitCount = max(
1704            count($xList),
1705            count($yList),
1706            count($dxList),
1707            count($dyList),
1708            count($rotateList),
1709        );
1710        $stickyX = $xList[0] ?? 0.0;
1711        $stickyY = $yList[0] ?? 0.0;
1712        $stickyRotate = $rotateList[0] ?? 0.0;
1713
1714        $emitted = 0;
1715        foreach ($chars as $i => $char) {
1716            if ($i >= $explicitCount) {
1717                break;
1718            }
1719            $stickyX = $xList[$i] ?? $stickyX;
1720            $stickyY = $yList[$i] ?? $stickyY;
1721            $stickyRotate = $rotateList[$i] ?? $stickyRotate;
1722            if (isset($dxList[$i])) {
1723                $stickyX += $dxList[$i];
1724            }
1725            if (isset($dyList[$i])) {
1726                $stickyY += $dyList[$i];
1727            }
1728
1729            $this->emitTextMatrix($stickyX, $stickyY, $stickyRotate, $stream);
1730            $stream->showText($char);
1731            $emitted++;
1732        }
1733        if ($emitted < count($chars)) {
1734            // Remaining glyphs ride the auto-advance from the last
1735            // positioned glyph — emit them as a single `Tj` so the
1736            // PDF reader inter-glyph kerning still applies.
1737            $stream->showText(implode('', array_slice($chars, $emitted)));
1738        }
1739    }
1740
1741    /**
1742     * Set the text matrix for a single positioned glyph. Combines the
1743     * per-glyph rotation with the optional outer-flip compensation
1744     * established by `SvgRenderer::draw`. Algebra:
1745     *
1746     *  Without flip: Tm = T(x,y) · R(θ) = [cosθ sinθ -sinθ cosθ x y]
1747     *  With    flip: Tm = T(x,y) · F · R(θ) = [cosθ sinθ sinθ -cosθ x y]
1748     *
1749     * where `F = [1 0 0 -1 0 0]` is the y-axis flip.
1750     */
1751    private function emitTextMatrix(float $x, float $y, float $rotateDegrees, ContentStream $stream): void
1752    {
1753        $rad = deg2rad($rotateDegrees);
1754        $cos = cos($rad);
1755        $sin = sin($rad);
1756        if ($this->compensateTextFlip) {
1757            $stream->setTextMatrix($cos, $sin, $sin, -$cos, $x, $y);
1758        } else {
1759            $stream->setTextMatrix($cos, $sin, -$sin, $cos, $x, $y);
1760        }
1761    }
1762
1763    /**
1764     * Concatenate all `Phpdftk\Svg\Text` (data) descendants in document
1765     * order. SVG 2's whitespace handling is complex (xml:space="preserve"
1766     * vs the default collapse); 3P keeps things simple by emitting the
1767     * source bytes verbatim and leaving whitespace policy to the future
1768     * cascade-aware text painter.
1769     */
1770    private static function collectTextContent(Element $element): string
1771    {
1772        $out = '';
1773        foreach ($element->children as $child) {
1774            if ($child instanceof TextNode) {
1775                $out .= $child->data;
1776                continue;
1777            }
1778            if ($child instanceof Element) {
1779                $out .= self::collectTextContent($child);
1780            }
1781        }
1782        return $out;
1783    }
1784
1785    private function paintPath(Path $path, ContentStream $stream): void
1786    {
1787        $commands = $path->d()->commands;
1788        if ($commands === []) {
1789            return;
1790        }
1791        $state = new PathPainterState();
1792        foreach ($commands as $command) {
1793            $this->emitPathCommand($command, $stream, $state);
1794        }
1795        $this->applyFillAndStroke($path, $stream);
1796    }
1797
1798    private function emitPathCommand(
1799        PathCommand $command,
1800        ContentStream $stream,
1801        PathPainterState $state,
1802    ): void {
1803        match (true) {
1804            $command instanceof MoveTo => $this->emitMoveTo($command, $stream, $state),
1805            $command instanceof LineTo => $this->emitLineTo($command, $stream, $state),
1806            $command instanceof HorizontalLineTo => $this->emitHorizontalLineTo($command, $stream, $state),
1807            $command instanceof VerticalLineTo => $this->emitVerticalLineTo($command, $stream, $state),
1808            $command instanceof CurveTo => $this->emitCurveTo($command, $stream, $state),
1809            $command instanceof SmoothCurveTo => $this->emitSmoothCurveTo($command, $stream, $state),
1810            $command instanceof QuadraticCurveTo => $this->emitQuadraticCurveTo($command, $stream, $state),
1811            $command instanceof SmoothQuadraticCurveTo => $this->emitSmoothQuadraticCurveTo($command, $stream, $state),
1812            $command instanceof ArcTo => $this->emitArcTo($command, $stream, $state),
1813            $command instanceof ClosePath => $this->emitClosePath($stream, $state),
1814            // Sealed-via-convention: 3rd-party impls of `PathCommand` are
1815            // not part of the SVG spec, so we no-op silently rather than
1816            // throw — same posture the parser uses for unknown content.
1817            default => null,
1818        };
1819    }
1820
1821    private function emitMoveTo(MoveTo $cmd, ContentStream $stream, PathPainterState $state): void
1822    {
1823        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1824        $stream->moveTo($x, $y);
1825        $state->moveTo($x, $y);
1826    }
1827
1828    private function emitLineTo(LineTo $cmd, ContentStream $stream, PathPainterState $state): void
1829    {
1830        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1831        $stream->lineTo($x, $y);
1832        $state->lineTo($x, $y);
1833    }
1834
1835    private function emitHorizontalLineTo(
1836        HorizontalLineTo $cmd,
1837        ContentStream $stream,
1838        PathPainterState $state,
1839    ): void {
1840        $x = $cmd->absolute ? $cmd->x : $state->currentX + $cmd->x;
1841        $stream->lineTo($x, $state->currentY);
1842        $state->lineTo($x, $state->currentY);
1843    }
1844
1845    private function emitVerticalLineTo(
1846        VerticalLineTo $cmd,
1847        ContentStream $stream,
1848        PathPainterState $state,
1849    ): void {
1850        $y = $cmd->absolute ? $cmd->y : $state->currentY + $cmd->y;
1851        $stream->lineTo($state->currentX, $y);
1852        $state->lineTo($state->currentX, $y);
1853    }
1854
1855    private function emitCurveTo(CurveTo $cmd, ContentStream $stream, PathPainterState $state): void
1856    {
1857        [$x1, $y1] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state);
1858        [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state);
1859        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1860        $stream->curveTo($x1, $y1, $x2, $y2, $x, $y);
1861        $state->currentX = $x;
1862        $state->currentY = $y;
1863        $state->recordCubicControl($x2, $y2);
1864    }
1865
1866    private function emitSmoothCurveTo(
1867        SmoothCurveTo $cmd,
1868        ContentStream $stream,
1869        PathPainterState $state,
1870    ): void {
1871        [$x1, $y1] = $state->reflectedCubicControl();
1872        [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state);
1873        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1874        $stream->curveTo($x1, $y1, $x2, $y2, $x, $y);
1875        $state->currentX = $x;
1876        $state->currentY = $y;
1877        $state->recordCubicControl($x2, $y2);
1878    }
1879
1880    /**
1881     * PDF has no native quadratic curve operator. Lift to cubic via the
1882     * standard `C1 = P0 + 2/3·(P1-P0)`, `C2 = P2 + 2/3·(P1-P2)` formula —
1883     * mathematically exact, no approximation error.
1884     */
1885    private function emitQuadraticCurveTo(
1886        QuadraticCurveTo $cmd,
1887        ContentStream $stream,
1888        PathPainterState $state,
1889    ): void {
1890        [$qx, $qy] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state);
1891        [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1892        $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream);
1893        $state->currentX = $ex;
1894        $state->currentY = $ey;
1895        $state->recordQuadraticControl($qx, $qy);
1896    }
1897
1898    private function emitSmoothQuadraticCurveTo(
1899        SmoothQuadraticCurveTo $cmd,
1900        ContentStream $stream,
1901        PathPainterState $state,
1902    ): void {
1903        [$qx, $qy] = $state->reflectedQuadraticControl();
1904        [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1905        $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream);
1906        $state->currentX = $ex;
1907        $state->currentY = $ey;
1908        $state->recordQuadraticControl($qx, $qy);
1909    }
1910
1911    private function emitArcTo(ArcTo $cmd, ContentStream $stream, PathPainterState $state): void
1912    {
1913        [$endX, $endY] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
1914        $segments = ArcToCubic::convert(
1915            $state->currentX,
1916            $state->currentY,
1917            $cmd->rx,
1918            $cmd->ry,
1919            $cmd->xAxisRotation,
1920            $cmd->largeArc,
1921            $cmd->sweep,
1922            $endX,
1923            $endY,
1924        );
1925        if ($segments === []) {
1926            // Degenerate: zero-length or zero-radius. Per SVG 2 §9.5.1,
1927            // an arc with a zero radius is rendered as a straight line.
1928            if ($cmd->rx === 0.0 || $cmd->ry === 0.0) {
1929                $stream->lineTo($endX, $endY);
1930                $state->lineTo($endX, $endY);
1931            }
1932            return;
1933        }
1934        foreach ($segments as $segment) {
1935            $stream->curveTo(
1936                $segment['x1'],
1937                $segment['y1'],
1938                $segment['x2'],
1939                $segment['y2'],
1940                $segment['x'],
1941                $segment['y'],
1942            );
1943        }
1944        $state->currentX = $endX;
1945        $state->currentY = $endY;
1946        $state->clearControlPoints();
1947    }
1948
1949    private function emitClosePath(ContentStream $stream, PathPainterState $state): void
1950    {
1951        $stream->closePath();
1952        $state->closeSubpath();
1953    }
1954
1955    private function emitQuadraticAsCubic(
1956        float $p0x,
1957        float $p0y,
1958        float $p1x,
1959        float $p1y,
1960        float $p2x,
1961        float $p2y,
1962        ContentStream $stream,
1963    ): void {
1964        $twoThirds = 2.0 / 3.0;
1965        $c1x = $p0x + $twoThirds * ($p1x - $p0x);
1966        $c1y = $p0y + $twoThirds * ($p1y - $p0y);
1967        $c2x = $p2x + $twoThirds * ($p1x - $p2x);
1968        $c2y = $p2y + $twoThirds * ($p1y - $p2y);
1969        $stream->curveTo($c1x, $c1y, $c2x, $c2y, $p2x, $p2y);
1970    }
1971
1972    /**
1973     * @return array{float, float}
1974     */
1975    private function resolvePoint(float $x, float $y, bool $absolute, PathPainterState $state): array
1976    {
1977        if ($absolute) {
1978            return [$x, $y];
1979        }
1980        return [$state->currentX + $x, $state->currentY + $y];
1981    }
1982
1983    private function paintRect(Rect $rect, ContentStream $stream): void
1984    {
1985        $vp = $this->currentViewport();
1986        $x = $this->resolvePercentLength($rect->getAttribute('x'), $vp['w'], $rect->x());
1987        $y = $this->resolvePercentLength($rect->getAttribute('y'), $vp['h'], $rect->y());
1988        $w = $this->resolvePercentLength($rect->getAttribute('width'), $vp['w'], $rect->width());
1989        $h = $this->resolvePercentLength($rect->getAttribute('height'), $vp['h'], $rect->height());
1990        if ($w <= 0.0 || $h <= 0.0) {
1991            return;
1992        }
1993        $stream->rectangle($x, $y, $w, $h);
1994        $this->applyFillAndStroke($rect, $stream);
1995    }
1996
1997    private function paintCircle(Circle $circle, ContentStream $stream): void
1998    {
1999        if ($circle->r() <= 0.0) {
2000            return;
2001        }
2002        $this->emitEllipsePath($stream, $circle->cx(), $circle->cy(), $circle->r(), $circle->r());
2003        $this->applyFillAndStroke($circle, $stream);
2004    }
2005
2006    private function paintEllipse(Ellipse $ellipse, ContentStream $stream): void
2007    {
2008        $rx = $ellipse->rx();
2009        $ry = $ellipse->ry();
2010        if ($rx === null || $ry === null || $rx <= 0.0 || $ry <= 0.0) {
2011            return;
2012        }
2013        $this->emitEllipsePath($stream, $ellipse->cx(), $ellipse->cy(), $rx, $ry);
2014        $this->applyFillAndStroke($ellipse, $stream);
2015    }
2016
2017    private function paintLine(Line $line, ContentStream $stream): void
2018    {
2019        // Lines never enclose an area; only stroke is meaningful. Skip
2020        // entirely when stroke resolves to no paint — emitting a stroke
2021        // op with no colour would otherwise draw a black line by
2022        // accident.
2023        $stroke = $line->stroke();
2024        if ($stroke === null || $stroke instanceof None_) {
2025            return;
2026        }
2027        if (!$this->applyStrokePaint($stroke, $line, $stream)) {
2028            return;
2029        }
2030        $stream->moveTo($line->x1(), $line->y1())
2031            ->lineTo($line->x2(), $line->y2())
2032            ->stroke();
2033    }
2034
2035    private function paintPolyline(Polyline $polyline, ContentStream $stream): void
2036    {
2037        $points = $polyline->points();
2038        if (count($points) < 2) {
2039            return;
2040        }
2041        $this->emitPolyPath($stream, $points, closed: false);
2042        $this->applyFillAndStroke($polyline, $stream);
2043    }
2044
2045    private function paintPolygon(Polygon $polygon, ContentStream $stream): void
2046    {
2047        $points = $polygon->points();
2048        if (count($points) < 3) {
2049            return;
2050        }
2051        $this->emitPolyPath($stream, $points, closed: true);
2052        $this->applyFillAndStroke($polygon, $stream);
2053    }
2054
2055    /**
2056     * Standard 4-cubic-Bézier ellipse approximation. Maximum radial
2057     * error against the true ellipse is ~0.027 % — well below print
2058     * resolution for any reasonable PDF size.
2059     */
2060    private function emitEllipsePath(
2061        ContentStream $stream,
2062        float $cx,
2063        float $cy,
2064        float $rx,
2065        float $ry,
2066    ): void {
2067        $kx = $rx * self::KAPPA;
2068        $ky = $ry * self::KAPPA;
2069        $stream
2070            ->moveTo($cx + $rx, $cy)
2071            ->curveTo($cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry)
2072            ->curveTo($cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy)
2073            ->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry)
2074            ->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy)
2075            ->closePath();
2076    }
2077
2078    /**
2079     * @param list<array{float, float}> $points
2080     */
2081    private function emitPolyPath(ContentStream $stream, array $points, bool $closed): void
2082    {
2083        $first = $points[0];
2084        $stream->moveTo($first[0], $first[1]);
2085        for ($i = 1, $n = count($points); $i < $n; $i++) {
2086            $stream->lineTo($points[$i][0], $points[$i][1]);
2087        }
2088        if ($closed) {
2089            $stream->closePath();
2090        }
2091    }
2092
2093    /**
2094     * Resolve the element's fill and stroke and emit the right PDF
2095     * paint operator combination. Defaults follow SVG 2 §13.2.1 — black
2096     * fill, no stroke — so a bare `<rect width=… height=…/>` paints as
2097     * a filled black rectangle.
2098     */
2099    private function applyFillAndStroke(Element $element, ContentStream $stream): void
2100    {
2101        $fill = $element->fill();
2102        $stroke = $element->stroke();
2103
2104        $hasFill = $this->applyFillPaint($fill, $element, $stream);
2105        $hasStroke = $this->applyStrokePaint($stroke, $element, $stream);
2106
2107        $rule = $element->fillRule() ?? 'nonzero';
2108
2109        if ($hasFill && $hasStroke) {
2110            $rule === 'evenodd' ? $stream->fillAndStrokeEvenOdd() : $stream->fillAndStroke();
2111            return;
2112        }
2113        if ($hasFill) {
2114            $rule === 'evenodd' ? $stream->fillEvenOdd() : $stream->fill();
2115            return;
2116        }
2117        if ($hasStroke) {
2118            $stream->stroke();
2119            return;
2120        }
2121        // Path constructed but nothing wants to paint it — discard so
2122        // we don't bake a leftover current-path into the graphics state.
2123        $stream->endPath();
2124    }
2125
2126    /**
2127     * Configure the fill colour and report whether the element wants a
2128     * fill at all. Default (null paint) = SVG-spec black fill; explicit
2129     * `none` = no fill; `currentColor` resolves to black at 3K (the
2130     * cascade-resolved `color` lands later). Gradient/pattern `url(#…)`
2131     * is deferred to 3O.
2132     */
2133    private function applyFillPaint(?Paint $paint, Element $element, ContentStream $stream): bool
2134    {
2135        if ($paint instanceof None_) {
2136            return false;
2137        }
2138        if ($paint instanceof Url) {
2139            return $this->gradientPainter?->applyAsFill($paint->id, $element, $stream) ?? false;
2140        }
2141        if ($paint instanceof SolidColor) {
2142            $this->setFillColor($stream, $paint->color);
2143            return true;
2144        }
2145        // null or CurrentColor → SVG 2 §13.2.1 default of black.
2146        $stream->setFillColorRGB(0.0, 0.0, 0.0);
2147        return true;
2148    }
2149
2150    /**
2151     * Configure the stroke colour and report whether the element wants
2152     * to stroke. Default (null paint) = SVG-spec "no stroke"; explicit
2153     * `none` = no stroke.
2154     */
2155    private function applyStrokePaint(?Paint $paint, Element $element, ContentStream $stream): bool
2156    {
2157        if ($paint === null || $paint instanceof None_) {
2158            return false;
2159        }
2160        if ($paint instanceof Url) {
2161            return $this->gradientPainter?->applyAsStroke($paint->id, $element, $stream) ?? false;
2162        }
2163        if ($paint instanceof SolidColor) {
2164            $this->setStrokeColor($stream, $paint->color);
2165            return true;
2166        }
2167        // CurrentColor → black at 3K.
2168        $stream->setStrokeColorRGB(0.0, 0.0, 0.0);
2169        return true;
2170    }
2171
2172    private function setFillColor(ContentStream $stream, ColorInterface $color): void
2173    {
2174        match (true) {
2175            $color instanceof RgbColor => $stream->setFillRgbColor($color),
2176            $color instanceof CmykColor => $stream->setFillCmykColor($color),
2177            $color instanceof GrayColor => $stream->setFillGrayColor($color),
2178            default => $stream->setFillColorRGB(0.0, 0.0, 0.0),
2179        };
2180    }
2181
2182    private function setStrokeColor(ContentStream $stream, ColorInterface $color): void
2183    {
2184        match (true) {
2185            $color instanceof RgbColor => $stream->setStrokeRgbColor($color),
2186            $color instanceof CmykColor => $stream->setStrokeCmykColor($color),
2187            $color instanceof GrayColor => $stream->setStrokeGrayColor($color),
2188            default => $stream->setStrokeColorRGB(0.0, 0.0, 0.0),
2189        };
2190    }
2191}