Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.43% covered (success)
91.43%
192 / 210
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgRenderer
91.43% covered (success)
91.43%
192 / 210
54.55% covered (warning)
54.55%
6 / 11
68.74
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
 withLoader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 draw
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
5
 addToPdf
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 createTemplate
88.89% covered (warning)
88.89%
48 / 54
0.00% covered (danger)
0.00%
0 / 1
10.14
 resolveSourceRect
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 applyPreserveAspectRatio
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 alignRatios
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 parseLengthPrefix
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 extractRootBackgroundColor
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
15.95
 tryParseColor
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf;
6
7use Phpdftk\Css\Value\Color;
8use Phpdftk\Css\ValueParser;
9use Phpdftk\Pdf\Core\Content\ContentStream;
10use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
11use Phpdftk\Pdf\Writer\Alignment;
12use Phpdftk\Pdf\Writer\Page;
13use Phpdftk\Pdf\Writer\Pdf;
14use Phpdftk\Pdf\Writer\PdfDoc;
15use Phpdftk\Pdf\Writer\PdfWriter;
16use Phpdftk\ResourceLoader\ResourceLoader;
17use Phpdftk\Svg\SvgDocument;
18
19/**
20 * Top-level adapter for placing a parsed SVG document onto a PDF page.
21 *
22 * Usage:
23 *
24 *     $renderer = new SvgRenderer($page, $writer);
25 *     $renderer->draw($svg, x: 72, y: 300);          // intrinsic size
26 *     $renderer->draw($svg, x: 72, y: 200, width: 4 * 72);
27 *
28 * `(x, y)` is the **bottom-left** of the destination rectangle in PDF
29 * user space; the SVG's top edge lands at PDF y = `y + height` and its
30 * bottom edge at PDF y = `y`. The renderer wraps everything in `q` / `Q`
31 * so its graphics state never leaks past its drawing area.
32 *
33 * Coordinate alignment: the renderer flips the y-axis (SVG y-down → PDF
34 * y-up) at the `cm` level and tells the `Translator` to compensate the
35 * flip inside text objects via `Tm` so glyphs still render upright.
36 * The flip is what makes SVG content appear right-side-up; the
37 * compensation is what keeps text readable.
38 *
39 * Source-rectangle resolution:
40 *
41 *  1. SVG `viewBox` if present — `[minX, minY, w, h]`.
42 *  2. Numeric prefix of `width` / `height` attributes (`100`, `100px`,
43 *     `4in`, …) with `viewBox` `min` defaulting to 0.
44 *  3. Fall back to `1 × 1` so a missing-dimensions SVG paints in its
45 *     native coordinate scale (1 unit ≈ 1 PDF point).
46 *
47 * `preserveAspectRatio` handling (SVG 2 §7.10):
48 *
49 *  - `none` — scale axes independently to fill the destination.
50 *  - `<align> meet` — uniform scale to fit (letterbox); the smallest
51 *    axis scale wins so the whole content stays inside the
52 *    destination rectangle.
53 *  - `<align> slice` — uniform scale to fill; the largest axis scale
54 *    wins so the destination is fully covered, and overflow is
55 *    clipped to the destination rectangle.
56 *
57 * Both meet and slice honour all nine `<align>` keywords —
58 * `xMinYMin`, `xMidYMin`, `xMaxYMin`, `xMinYMid`, `xMidYMid` (default),
59 * `xMaxYMid`, `xMinYMax`, `xMidYMax`, `xMaxYMax` — controlling how the
60 * scaled content is positioned within the destination rectangle.
61 */
62final class SvgRenderer
63{
64    public function __construct(
65        private readonly Page $page,
66        private readonly PdfWriter $writer,
67        private readonly Translator $translator = new Translator(),
68        private readonly SvgCascadeProjector $cascadeProjector = new SvgCascadeProjector(),
69    ) {}
70
71    /**
72     * Ergonomic alternative to `new SvgRenderer($page, $writer,
73     * new Translator($loader))`. Use when you want network image
74     * hrefs (`<image href="https://...">`) to resolve through
75     * `phpdftk/resource-loader` without constructing a Translator
76     * manually.
77     *
78     *   $renderer = SvgRenderer::withLoader($page, $writer, $loader);
79     *   $renderer->draw($svg, x: 72, y: 600, width: 200, height: 200);
80     */
81    public static function withLoader(
82        Page $page,
83        PdfWriter $writer,
84        ResourceLoader $resourceLoader,
85    ): self {
86        return new self($page, $writer, new Translator($resourceLoader));
87    }
88
89    /**
90     * Paint `$svg` onto the renderer's page with its source coordinate
91     * space mapped to the rectangle `(x, y) … (x + width, y + height)`.
92     * Omitting `$width` / `$height` keeps the source's natural size.
93     */
94    /**
95     * @param ContentStream|null $stream Override the page's primary
96     *   content stream. When omitted, falls back to
97     *   `$this->page->contentStream()` — the legacy behaviour. Callers
98     *   that have already opened a graphics-state scope on a specific
99     *   stream (a `q ... clip ... Q` wrap from a host renderer) must
100     *   pass that stream here so the SVG draw appears inside the
101     *   wrap; otherwise the page may attach a second content stream
102     *   and the SVG paints outside the caller's clip context.
103     */
104    public function draw(
105        SvgDocument $svg,
106        float $x,
107        float $y,
108        ?float $width = null,
109        ?float $height = null,
110        ?ContentStream $stream = null,
111    ): void {
112        $stream ??= $this->page->contentStream();
113        // Project author CSS (the document's <style> blocks plus
114        // inherited cascade) into each element's `style` attribute
115        // so the painter's accessors see the cascade-resolved values
116        // through the existing `presentationOrStyle` fallback.
117        // Idempotent: a second draw() on the same doc rewrites the
118        // marker block instead of doubling up.
119        $this->cascadeProjector->project($svg);
120        [$srcMinX, $srcMinY, $srcWidth, $srcHeight, $srcSynthetic] = self::resolveSourceRect($svg, $width, $height);
121
122        $dstWidth = $width ?? $srcWidth;
123        $dstHeight = $height ?? $srcHeight;
124
125        [$scaleX, $scaleY, $offsetX, $offsetY, $needsClip] = self::applyPreserveAspectRatio(
126            $svg,
127            $srcWidth,
128            $srcHeight,
129            $dstWidth,
130            $dstHeight,
131        );
132
133        // cm sx 0 0 -sy e f flips the y-axis. Derivation: a point
134        // (svgMinX, svgMinY) (the SVG's top-left in source coords)
135        // should land at PDF (x + offsetX, y + offsetY + effectiveH)
136        // — i.e., the top of its bounding box. Solving the affine for
137        // `(e, f)` gives:
138        //   e = x + offsetX - srcMinX * sx
139        //   f = y + offsetY + effectiveH + srcMinY * sy
140        $effectiveH = $scaleY * $srcHeight;
141        $stream->saveGraphicsState();
142        if ($needsClip) {
143            // `slice` mode lets the scaled content overflow the
144            // destination rect. Clip to the dest rect first so the
145            // overflow doesn't leak into other page content.
146            $stream->rectangle($x, $y, $dstWidth, $dstHeight);
147            $stream->clip();
148            $stream->endPath();
149        }
150        // CSS Backgrounds 3 — when the SVG root carries a `background`
151        // (or `background-color`) CSS declaration, paint it as a filled
152        // rect covering the destination so a `<img src=svg>` with
153        // `style="background: green"` actually shows green. Done BEFORE
154        // the cm transform so the rect uses PDF coords directly and
155        // doesn't need the y-flip dance. SVG itself has no `background`
156        // property — this is a CSS-on-SVG-root extension browsers honour.
157        $bgColor = self::extractRootBackgroundColor($svg);
158        if ($bgColor !== null && $bgColor->a > 0.0) {
159            $stream->saveGraphicsState();
160            $stream->setFillColorRGB($bgColor->r, $bgColor->g, $bgColor->b);
161            // Width-along-PDF-x: scaleX is signed (positive); height
162            // already absorbed the y-flip via `effectiveH`. The
163            // destination rect anchors at (x+offsetX, y+offsetY).
164            $stream->rectangle(
165                $x + $offsetX,
166                $y + $offsetY,
167                $scaleX * $srcWidth,
168                $effectiveH,
169            );
170            $stream->fill();
171            $stream->restoreGraphicsState();
172        }
173        $stream->concatMatrix(
174            $scaleX,
175            0.0,
176            0.0,
177            -$scaleY,
178            $x + $offsetX - $srcMinX * $scaleX,
179            $y + $offsetY + $effectiveH + $srcMinY * $scaleY,
180        );
181        // When the source rect was synthesised from the destination
182        // (SVG had no viewBox and no parseable fixed dims), the
183        // Translator's `currentViewport` would otherwise fall back to
184        // the document's own width/height attributes — which for
185        // percentage-only SVGs return zero/garbage and collapse
186        // percentage-sized children. Pass the source rect so inner
187        // percentage attributes resolve against the area the SVG
188        // actually paints into.
189        $effectiveViewport = $srcSynthetic
190            ? ['w' => $srcWidth, 'h' => $srcHeight]
191            : null;
192        $this->translator->paint(
193            $svg,
194            $stream,
195            $this->page,
196            $this->writer,
197            compensateTextFlip: true,
198            effectiveViewport: $effectiveViewport,
199        );
200        $stream->restoreGraphicsState();
201    }
202
203    /**
204     * Drop an SVG into a top-level `Pdf` flow document, advancing the
205     * cursor below it just like `Pdf::addImage` does. The Pdf class
206     * itself doesn't depend on svg-to-pdf — it provides a generic
207     * `Pdf::addBlock` hook this method wraps.
208     *
209     * Dimension resolution mirrors `Pdf::addImage`:
210     *
211     *   - Both `$width` and `$height` set → stretch to that rect (no
212     *     aspect preservation, mirrors `addImage`).
213     *   - Only one set → the other scales to the SVG's intrinsic
214     *     aspect ratio (from the viewBox / width / height attributes).
215     *   - Neither set → use the SVG's natural dimensions in PDF
216     *     points (1 SVG unit = 1 point).
217     *
218     * Width caps at the current column width so a wide SVG doesn't
219     * paint past the column edge — matches the row-of-text behaviour
220     * the Pdf high-level API uses elsewhere.
221     */
222    public static function addToPdf(
223        Pdf $pdf,
224        SvgDocument $svg,
225        ?float $width = null,
226        ?float $height = null,
227        Alignment $align = Alignment::Left,
228        ?ResourceLoader $resourceLoader = null,
229    ): Pdf {
230        // Explicit parameter wins; otherwise fall back to the loader
231        // attached to the Pdf via `withResourceLoader`. Lets callers
232        // configure once and forget for the whole document.
233        $resourceLoader ??= $pdf->resourceLoader();
234        [$srcMinX, $srcMinY, $srcWidth, $srcHeight] = self::resolveSourceRect($svg);
235        $aspect = $srcHeight > 0.0 ? $srcWidth / $srcHeight : 1.0;
236
237        if ($width === null && $height === null) {
238            $w = $srcWidth;
239            $h = $srcHeight;
240        } elseif ($width !== null && $height === null) {
241            $w = $width;
242            $h = $aspect > 0.0 ? $width / $aspect : $width;
243        } elseif ($width === null) {
244            // Reached only when $height is non-null (both-null and
245            // width-only branches above are exhausted).
246            $h = $height;
247            $w = $height * $aspect;
248        } else {
249            $w = (float) $width;
250            $h = (float) $height;
251        }
252        unset($srcMinX, $srcMinY);
253
254        return $pdf->addBlock(
255            $w,
256            $h,
257            $align,
258            static function (Page $page, float $x, float $y, float $bw, float $bh) use ($svg, $pdf, $resourceLoader): void {
259                $translator = $resourceLoader !== null
260                    ? new Translator($resourceLoader)
261                    : new Translator();
262                (new self($page, $pdf->writer(), $translator))->draw($svg, $x, $y, $bw, $bh);
263            },
264        );
265    }
266
267    /**
268     * Build a reusable Form XObject from an SVG that can be placed on
269     * multiple pages without re-emitting the underlying operators.
270     * Pair with `Page::drawTemplate($tpl, $x, $y, ?w, ?h)` for cheap
271     * watermark / repeating-graphic use cases.
272     *
273     * The template's BBox is `[0, 0, $w, $h]` where `$w` / `$h`
274     * follow the same dimension-resolution ladder as
275     * {@see addToPdf()}:
276     *
277     *   - both set       → that rectangle (no aspect preservation)
278     *   - only width     → height scales to the intrinsic aspect
279     *   - only height    → width scales to the intrinsic aspect
280     *   - neither        → the SVG's natural width / height
281     *
282     * Resources (gradients, fonts, embedded images) are registered on
283     * `$resourceHost`. FormXObjects inherit resources from the page
284     * that places them, so callers should reuse `$resourceHost` (or a
285     * page sharing its resource pool) when invoking the template via
286     * `Page::drawTemplate`. The template itself is registered with the
287     * doc's writer immediately, so the returned handle is safe to
288     * pass between pages.
289     */
290    public static function createTemplate(
291        PdfDoc $doc,
292        Page $resourceHost,
293        SvgDocument $svg,
294        ?float $width = null,
295        ?float $height = null,
296        ?ResourceLoader $resourceLoader = null,
297    ): FormXObject {
298        [$srcMinX, $srcMinY, $srcWidth, $srcHeight] = self::resolveSourceRect($svg);
299        $aspect = $srcHeight > 0.0 ? $srcWidth / $srcHeight : 1.0;
300
301        if ($width === null && $height === null) {
302            $w = $srcWidth;
303            $h = $srcHeight;
304        } elseif ($width !== null && $height === null) {
305            $w = $width;
306            $h = $aspect > 0.0 ? $width / $aspect : $width;
307        } elseif ($width === null) {
308            // Reached only when $height is non-null (both-null and
309            // width-only branches above are exhausted).
310            $h = $height;
311            $w = $height * $aspect;
312        } else {
313            $w = (float) $width;
314            $h = (float) $height;
315        }
316        unset($srcMinX, $srcMinY);
317
318        $bbox = new \Phpdftk\Geometry\Rectangle(0.0, 0.0, $w, $h);
319        $writer = $doc->writer();
320        return $doc->createTemplate(
321            $bbox,
322            static function (ContentStream $stream) use ($svg, $resourceHost, $writer, $w, $h, $resourceLoader): void {
323                [$srcMinX2, $srcMinY2, $srcW, $srcH] = self::resolveSourceRect($svg);
324                [$scaleX, $scaleY, $offsetX, $offsetY, $needsClip] = self::applyPreserveAspectRatio(
325                    $svg,
326                    $srcW,
327                    $srcH,
328                    $w,
329                    $h,
330                );
331                $effectiveH = $scaleY * $srcH;
332                $stream->saveGraphicsState();
333                if ($needsClip) {
334                    $stream->rectangle(0.0, 0.0, $w, $h);
335                    $stream->clip();
336                    $stream->endPath();
337                }
338                // Same derivation as SvgRenderer::draw with x=y=0:
339                // map source-rect top-left to (offsetX, offsetY + effectiveH).
340                $stream->concatMatrix(
341                    $scaleX,
342                    0.0,
343                    0.0,
344                    -$scaleY,
345                    $offsetX - $srcMinX2 * $scaleX,
346                    $offsetY + $effectiveH + $srcMinY2 * $scaleY,
347                );
348                $translator = $resourceLoader !== null
349                    ? new Translator($resourceLoader)
350                    : new Translator();
351                $translator->paint(
352                    $svg,
353                    $stream,
354                    $resourceHost,
355                    $writer,
356                    compensateTextFlip: true,
357                );
358                $stream->restoreGraphicsState();
359            },
360        );
361    }
362
363    /**
364     * Resolve the SVG's source-coordinate rectangle. Returns a fifth
365     * element `synthetic` — true when the source rect was derived
366     * from the destination because the document carried at least one
367     * percentage-style dimension attribute (`width="50%"`, etc.) but
368     * no parseable fixed pair. The caller uses this to propagate the
369     * destination as the effective viewport for inner percentage
370     * attributes. When the document has no width/height/viewBox at
371     * all, the legacy unit-square fallback is preserved.
372     *
373     * @return array{0: float, 1: float, 2: float, 3: float, 4: bool}
374     *         minX, minY, width, height, synthetic
375     */
376    private static function resolveSourceRect(
377        SvgDocument $svg,
378        ?float $dstWidth = null,
379        ?float $dstHeight = null,
380    ): array {
381        $viewBox = $svg->viewBox();
382        if ($viewBox !== null) {
383            return [$viewBox[0], $viewBox[1], $viewBox[2], $viewBox[3], false];
384        }
385        $widthAttr = $svg->widthAttribute();
386        $heightAttr = $svg->heightAttribute();
387        $w = self::parseLengthPrefix($widthAttr);
388        $h = self::parseLengthPrefix($heightAttr);
389        if ($w !== null && $h !== null) {
390            // CSS Images 3 §5.2 + crbug.com/1392140 — snap near-integral
391            // intrinsic dimensions to integers so an SVG declaring
392            // `width="99.99999"` matches the painted size of one
393            // declaring `width="100"`. Without this, a fractional-of-a-
394            // pixel preserveAspectRatio gap (e.g. dst=100 / src=99.99999
395            // → scale=1 + offset=0.000005) leaks underlying content at
396            // the destination edges when one <img src=svg> stacks over
397            // another. Browsers do this for <img>-embedded SVGs and
398            // Painter::intrinsicSvgSize already rounds on the layout
399            // side; mirror it here on the rendering side so they agree.
400            // Skipped when the SVG carries a viewBox (handled above) —
401            // viewBox values are their own coordinate system, not pixel
402            // dimensions, and snapping them would change content scale.
403            return [0.0, 0.0, (float) round($w), (float) round($h), false];
404        }
405        // CSS Images 3 §5.2 — when the SVG doesn't have both a
406        // fixed width AND fixed height (and no viewBox to imply a
407        // ratio), use the caller's destination as the source
408        // viewport. This covers: partial fixed dims, percentage
409        // attributes, fully-omitted dims, and any combination of
410        // those. Inner percentage attributes resolve against the
411        // dst, matching browsers' "default object size" outcome
412        // for `background-image: url(svg)`.
413        if ($dstWidth !== null && $dstHeight !== null) {
414            return [0.0, 0.0, $dstWidth, $dstHeight, true];
415        }
416        // No dst supplied (standalone draw with no width/height) and
417        // no SVG-supplied dims either — fall back to a unit square
418        // so the caller at least produces a finite-sized render.
419        return [0.0, 0.0, $w ?? 1.0, $h ?? 1.0, false];
420    }
421
422
423    /**
424     * SVG 2 §7.10 viewport / viewBox alignment. Returns
425     * `[scaleX, scaleY, offsetX, offsetY, needsClip]` so the caller can
426     * fold them into the outer `cm` and decide whether to clip
427     * overflow.
428     *
429     *  - `none` reproduces the original independent-axes behaviour, no
430     *    offset, no clip.
431     *  - `<align> meet` (default `xMidYMid meet`) — uniform scale to
432     *    fit; the smaller axis scale wins.
433     *  - `<align> slice` — uniform scale to fill; the larger axis
434     *    scale wins. The leftover "leftover" goes negative, so the
435     *    scaled content overflows the destination rectangle on one
436     *    axis; the caller is told to add a destination-rect clip.
437     *
438     * @return array{0: float, 1: float, 2: float, 3: float, 4: bool}
439     */
440    private static function applyPreserveAspectRatio(
441        SvgDocument $svg,
442        float $srcW,
443        float $srcH,
444        float $dstW,
445        float $dstH,
446    ): array {
447        $sx = $srcW > 0.0 ? $dstW / $srcW : 1.0;
448        $sy = $srcH > 0.0 ? $dstH / $srcH : 1.0;
449        $par = strtolower(trim($svg->getAttribute('preserveAspectRatio') ?? ''));
450        if ($par === 'none') {
451            return [$sx, $sy, 0.0, 0.0, false];
452        }
453
454        $tokens = preg_split('/\s+/', $par) ?: [];
455        $align = $tokens[0] ?? '';
456        $slice = ($tokens[1] ?? 'meet') === 'slice';
457        [$xRatio, $yRatio] = self::alignRatios($align);
458
459        $scale = $slice ? max($sx, $sy) : min($sx, $sy);
460        $effectiveW = $scale * $srcW;
461        $effectiveH = $scale * $srcH;
462
463        return [
464            $scale,
465            $scale,
466            $xRatio * ($dstW - $effectiveW),
467            $yRatio * ($dstH - $effectiveH),
468            $slice,
469        ];
470    }
471
472    /**
473     * Map an SVG 2 §7.10 align keyword to a pair of ratios in the
474     * `[xRatio, yRatio]` shape used by `applyPreserveAspectRatio`:
475     *
476     *  - `xRatio` ∈ {0, 0.5, 1} — how much of the X-leftover sits on
477     *    the LEFT side of the content (`xMin` → 0, `xMid` → 0.5,
478     *    `xMax` → 1).
479     *  - `yRatio` ∈ {0, 0.5, 1} — how much of the Y-leftover sits
480     *    BELOW the content in PDF coords. Because PDF's y axis points
481     *    up and SVG's points down, "Y at the top" maps to "all
482     *    leftover at the bottom" → `yMin` → 1, `yMid` → 0.5,
483     *    `yMax` → 0.
484     *
485     * Unknown keywords default to `xMidYMid` (centre).
486     *
487     * @return array{0: float, 1: float}
488     */
489    private static function alignRatios(string $align): array
490    {
491        $alignLower = strtolower($align);
492        $xRatio = match (true) {
493            str_starts_with($alignLower, 'xmin') => 0.0,
494            str_starts_with($alignLower, 'xmax') => 1.0,
495            default => 0.5,
496        };
497        // The Y keyword sits after the X part — `xMinYMin`, `xMidYMax`, …
498        $yRatio = match (true) {
499            str_contains($alignLower, 'ymin') => 1.0,
500            str_contains($alignLower, 'ymax') => 0.0,
501            default => 0.5,
502        };
503        return [$xRatio, $yRatio];
504    }
505
506    /**
507     * Extract the leading numeric prefix from an SVG length attribute
508     * (`"100"`, `"100px"`, `"4in"`, …). Unit suffixes are ignored at
509     * 3R — proper unit resolution lands alongside CSS Lengths in a
510     * later sub-phase.
511     */
512    private static function parseLengthPrefix(?string $raw): ?float
513    {
514        if ($raw === null) {
515            return null;
516        }
517        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([%a-zA-Z]*)/', $raw, $m) !== 1) {
518            return null;
519        }
520        // Percentage values carry no intrinsic dimension — CSS Images
521        // 3 §5.2 treats `<svg width="50%">` as having no intrinsic
522        // width. Reject so the caller can fall back to the dst
523        // viewport as the source rect.
524        if ($m[2] === '%') {
525            return null;
526        }
527        $value = (float) $m[1];
528        return $value <= 0.0 ? null : $value;
529    }
530
531    /**
532     * Extract the SVG root's CSS background colour, if any.
533     *
534     * Tries `background-color` first (explicit), then the `background`
535     * shorthand. For the shorthand we look at each whitespace-delimited
536     * token and return the first one that parses as a CSS colour. This
537     * misses author intent for some pathological shorthands (e.g.
538     * `background: url(...) center / 100% red` does flow through), but
539     * covers the canonical `background: <colour>` form that the SVG
540     * background-on-root tests use.
541     *
542     * Returns null when no colour can be resolved.
543     */
544    private static function extractRootBackgroundColor(SvgDocument $svg): ?Color
545    {
546        $style = $svg->getAttribute('style');
547        if ($style === null || $style === '') {
548            return null;
549        }
550        $cleaned = preg_replace('/\/\*.*?\*\//s', ' ', $style) ?? $style;
551        $declarations = [];
552        foreach (explode(';', $cleaned) as $decl) {
553            $colon = strpos($decl, ':');
554            if ($colon === false) {
555                continue;
556            }
557            $name = strtolower(trim(substr($decl, 0, $colon)));
558            $value = trim(substr($decl, $colon + 1));
559            if ($value === '') {
560                continue;
561            }
562            $declarations[$name] = $value;
563        }
564
565        $parser = new ValueParser();
566        if (isset($declarations['background-color'])) {
567            $parsed = self::tryParseColor($parser, $declarations['background-color']);
568            if ($parsed !== null) {
569                return $parsed;
570            }
571        }
572        if (isset($declarations['background'])) {
573            // Walk tokens; first that parses as a colour wins.
574            foreach (preg_split('/\s+/', $declarations['background']) ?: [] as $token) {
575                if ($token === '') {
576                    continue;
577                }
578                $parsed = self::tryParseColor($parser, $token);
579                if ($parsed !== null) {
580                    return $parsed;
581                }
582            }
583        }
584        return null;
585    }
586
587    private static function tryParseColor(ValueParser $parser, string $css): ?Color
588    {
589        try {
590            $value = $parser->parseFromString($css);
591        } catch (\Throwable) {
592            return null;
593        }
594        return $value instanceof Color ? $value : null;
595    }
596
597}