Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.52% covered (success)
92.52%
198 / 214
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgRenderer
92.52% covered (success)
92.52%
198 / 214
54.55% covered (warning)
54.55%
6 / 11
67.82
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%
52 / 52
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
89.29% covered (warning)
89.29%
50 / 56
0.00% covered (danger)
0.00%
0 / 1
10.12
 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
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
14.07
 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        $baseMatrix = [
174            $scaleX,
175            0.0,
176            0.0,
177            -$scaleY,
178            $x + $offsetX - $srcMinX * $scaleX,
179            $y + $offsetY + $effectiveH + $srcMinY * $scaleY,
180        ];
181        $stream->concatMatrix(...$baseMatrix);
182        // When the source rect was synthesised from the destination
183        // (SVG had no viewBox and no parseable fixed dims), the
184        // Translator's `currentViewport` would otherwise fall back to
185        // the document's own width/height attributes — which for
186        // percentage-only SVGs return zero/garbage and collapse
187        // percentage-sized children. Pass the source rect so inner
188        // percentage attributes resolve against the area the SVG
189        // actually paints into.
190        $effectiveViewport = $srcSynthetic
191            ? ['w' => $srcWidth, 'h' => $srcHeight]
192            : null;
193        $this->translator->paint(
194            $svg,
195            $stream,
196            $this->page,
197            $this->writer,
198            compensateTextFlip: true,
199            effectiveViewport: $effectiveViewport,
200            baseMatrix: $baseMatrix,
201        );
202        $stream->restoreGraphicsState();
203    }
204
205    /**
206     * Drop an SVG into a top-level `Pdf` flow document, advancing the
207     * cursor below it just like `Pdf::addImage` does. The Pdf class
208     * itself doesn't depend on svg-to-pdf — it provides a generic
209     * `Pdf::addBlock` hook this method wraps.
210     *
211     * Dimension resolution mirrors `Pdf::addImage`:
212     *
213     *   - Both `$width` and `$height` set → stretch to that rect (no
214     *     aspect preservation, mirrors `addImage`).
215     *   - Only one set → the other scales to the SVG's intrinsic
216     *     aspect ratio (from the viewBox / width / height attributes).
217     *   - Neither set → use the SVG's natural dimensions in PDF
218     *     points (1 SVG unit = 1 point).
219     *
220     * Width caps at the current column width so a wide SVG doesn't
221     * paint past the column edge — matches the row-of-text behaviour
222     * the Pdf high-level API uses elsewhere.
223     */
224    public static function addToPdf(
225        Pdf $pdf,
226        SvgDocument $svg,
227        ?float $width = null,
228        ?float $height = null,
229        Alignment $align = Alignment::Left,
230        ?ResourceLoader $resourceLoader = null,
231    ): Pdf {
232        // Explicit parameter wins; otherwise fall back to the loader
233        // attached to the Pdf via `withResourceLoader`. Lets callers
234        // configure once and forget for the whole document.
235        $resourceLoader ??= $pdf->resourceLoader();
236        [$srcMinX, $srcMinY, $srcWidth, $srcHeight] = self::resolveSourceRect($svg);
237        $aspect = $srcHeight > 0.0 ? $srcWidth / $srcHeight : 1.0;
238
239        if ($width === null && $height === null) {
240            $w = $srcWidth;
241            $h = $srcHeight;
242        } elseif ($width !== null && $height === null) {
243            $w = $width;
244            $h = $aspect > 0.0 ? $width / $aspect : $width;
245        } elseif ($width === null) {
246            // Reached only when $height is non-null (both-null and
247            // width-only branches above are exhausted).
248            $h = $height;
249            $w = $height * $aspect;
250        } else {
251            $w = (float) $width;
252            $h = (float) $height;
253        }
254        unset($srcMinX, $srcMinY);
255
256        return $pdf->addBlock(
257            $w,
258            $h,
259            $align,
260            static function (Page $page, float $x, float $y, float $bw, float $bh) use ($svg, $pdf, $resourceLoader): void {
261                $translator = $resourceLoader !== null
262                    ? new Translator($resourceLoader)
263                    : new Translator();
264                (new self($page, $pdf->writer(), $translator))->draw($svg, $x, $y, $bw, $bh);
265            },
266        );
267    }
268
269    /**
270     * Build a reusable Form XObject from an SVG that can be placed on
271     * multiple pages without re-emitting the underlying operators.
272     * Pair with `Page::drawTemplate($tpl, $x, $y, ?w, ?h)` for cheap
273     * watermark / repeating-graphic use cases.
274     *
275     * The template's BBox is `[0, 0, $w, $h]` where `$w` / `$h`
276     * follow the same dimension-resolution ladder as
277     * {@see addToPdf()}:
278     *
279     *   - both set       → that rectangle (no aspect preservation)
280     *   - only width     → height scales to the intrinsic aspect
281     *   - only height    → width scales to the intrinsic aspect
282     *   - neither        → the SVG's natural width / height
283     *
284     * Resources (gradients, fonts, embedded images) are registered on
285     * `$resourceHost`. FormXObjects inherit resources from the page
286     * that places them, so callers should reuse `$resourceHost` (or a
287     * page sharing its resource pool) when invoking the template via
288     * `Page::drawTemplate`. The template itself is registered with the
289     * doc's writer immediately, so the returned handle is safe to
290     * pass between pages.
291     */
292    public static function createTemplate(
293        PdfDoc $doc,
294        Page $resourceHost,
295        SvgDocument $svg,
296        ?float $width = null,
297        ?float $height = null,
298        ?ResourceLoader $resourceLoader = null,
299    ): FormXObject {
300        [$srcMinX, $srcMinY, $srcWidth, $srcHeight] = self::resolveSourceRect($svg);
301        $aspect = $srcHeight > 0.0 ? $srcWidth / $srcHeight : 1.0;
302
303        if ($width === null && $height === null) {
304            $w = $srcWidth;
305            $h = $srcHeight;
306        } elseif ($width !== null && $height === null) {
307            $w = $width;
308            $h = $aspect > 0.0 ? $width / $aspect : $width;
309        } elseif ($width === null) {
310            // Reached only when $height is non-null (both-null and
311            // width-only branches above are exhausted).
312            $h = $height;
313            $w = $height * $aspect;
314        } else {
315            $w = (float) $width;
316            $h = (float) $height;
317        }
318        unset($srcMinX, $srcMinY);
319
320        $bbox = new \Phpdftk\Geometry\Rectangle(0.0, 0.0, $w, $h);
321        $writer = $doc->writer();
322        return $doc->createTemplate(
323            $bbox,
324            static function (ContentStream $stream) use ($svg, $resourceHost, $writer, $w, $h, $resourceLoader): void {
325                [$srcMinX2, $srcMinY2, $srcW, $srcH] = self::resolveSourceRect($svg);
326                [$scaleX, $scaleY, $offsetX, $offsetY, $needsClip] = self::applyPreserveAspectRatio(
327                    $svg,
328                    $srcW,
329                    $srcH,
330                    $w,
331                    $h,
332                );
333                $effectiveH = $scaleY * $srcH;
334                $stream->saveGraphicsState();
335                if ($needsClip) {
336                    $stream->rectangle(0.0, 0.0, $w, $h);
337                    $stream->clip();
338                    $stream->endPath();
339                }
340                // Same derivation as SvgRenderer::draw with x=y=0:
341                // map source-rect top-left to (offsetX, offsetY + effectiveH).
342                $baseMatrix = [
343                    $scaleX,
344                    0.0,
345                    0.0,
346                    -$scaleY,
347                    $offsetX - $srcMinX2 * $scaleX,
348                    $offsetY + $effectiveH + $srcMinY2 * $scaleY,
349                ];
350                $stream->concatMatrix(...$baseMatrix);
351                $translator = $resourceLoader !== null
352                    ? new Translator($resourceLoader)
353                    : new Translator();
354                $translator->paint(
355                    $svg,
356                    $stream,
357                    $resourceHost,
358                    $writer,
359                    compensateTextFlip: true,
360                    baseMatrix: $baseMatrix,
361                );
362                $stream->restoreGraphicsState();
363            },
364        );
365    }
366
367    /**
368     * Resolve the SVG's source-coordinate rectangle. Returns a fifth
369     * element `synthetic` — true when the source rect was derived
370     * from the destination because the document carried at least one
371     * percentage-style dimension attribute (`width="50%"`, etc.) but
372     * no parseable fixed pair. The caller uses this to propagate the
373     * destination as the effective viewport for inner percentage
374     * attributes. When the document has no width/height/viewBox at
375     * all, the legacy unit-square fallback is preserved.
376     *
377     * @return array{0: float, 1: float, 2: float, 3: float, 4: bool}
378     *         minX, minY, width, height, synthetic
379     */
380    private static function resolveSourceRect(
381        SvgDocument $svg,
382        ?float $dstWidth = null,
383        ?float $dstHeight = null,
384    ): array {
385        $viewBox = $svg->viewBox();
386        if ($viewBox !== null) {
387            return [$viewBox[0], $viewBox[1], $viewBox[2], $viewBox[3], false];
388        }
389        $widthAttr = $svg->widthAttribute();
390        $heightAttr = $svg->heightAttribute();
391        $w = self::parseLengthPrefix($widthAttr);
392        $h = self::parseLengthPrefix($heightAttr);
393        if ($w !== null && $h !== null) {
394            // CSS Images 3 §5.2 + crbug.com/1392140 — snap near-integral
395            // intrinsic dimensions to integers so an SVG declaring
396            // `width="99.99999"` matches the painted size of one
397            // declaring `width="100"`. Without this, a fractional-of-a-
398            // pixel preserveAspectRatio gap (e.g. dst=100 / src=99.99999
399            // → scale=1 + offset=0.000005) leaks underlying content at
400            // the destination edges when one <img src=svg> stacks over
401            // another. Browsers do this for <img>-embedded SVGs and
402            // Painter::intrinsicSvgSize already rounds on the layout
403            // side; mirror it here on the rendering side so they agree.
404            // Skipped when the SVG carries a viewBox (handled above) —
405            // viewBox values are their own coordinate system, not pixel
406            // dimensions, and snapping them would change content scale.
407            return [0.0, 0.0, (float) round($w), (float) round($h), false];
408        }
409        // CSS Images 3 §5.2 — when the SVG doesn't have both a
410        // fixed width AND fixed height (and no viewBox to imply a
411        // ratio), use the caller's destination as the source
412        // viewport. This covers: partial fixed dims, percentage
413        // attributes, fully-omitted dims, and any combination of
414        // those. Inner percentage attributes resolve against the
415        // dst, matching browsers' "default object size" outcome
416        // for `background-image: url(svg)`.
417        if ($dstWidth !== null && $dstHeight !== null) {
418            return [0.0, 0.0, $dstWidth, $dstHeight, true];
419        }
420        // No dst supplied (standalone draw with no width/height) and
421        // no SVG-supplied dims either — fall back to a unit square
422        // so the caller at least produces a finite-sized render.
423        return [0.0, 0.0, $w ?? 1.0, $h ?? 1.0, false];
424    }
425
426
427    /**
428     * SVG 2 §7.10 viewport / viewBox alignment. Returns
429     * `[scaleX, scaleY, offsetX, offsetY, needsClip]` so the caller can
430     * fold them into the outer `cm` and decide whether to clip
431     * overflow.
432     *
433     *  - `none` reproduces the original independent-axes behaviour, no
434     *    offset, no clip.
435     *  - `<align> meet` (default `xMidYMid meet`) — uniform scale to
436     *    fit; the smaller axis scale wins.
437     *  - `<align> slice` — uniform scale to fill; the larger axis
438     *    scale wins. The leftover "leftover" goes negative, so the
439     *    scaled content overflows the destination rectangle on one
440     *    axis; the caller is told to add a destination-rect clip.
441     *
442     * @return array{0: float, 1: float, 2: float, 3: float, 4: bool}
443     */
444    private static function applyPreserveAspectRatio(
445        SvgDocument $svg,
446        float $srcW,
447        float $srcH,
448        float $dstW,
449        float $dstH,
450    ): array {
451        $sx = $srcW > 0.0 ? $dstW / $srcW : 1.0;
452        $sy = $srcH > 0.0 ? $dstH / $srcH : 1.0;
453        $par = strtolower(trim($svg->getAttribute('preserveAspectRatio') ?? ''));
454        if ($par === 'none') {
455            return [$sx, $sy, 0.0, 0.0, false];
456        }
457
458        $tokens = preg_split('/\s+/', $par) ?: [];
459        $align = $tokens[0] ?? '';
460        $slice = ($tokens[1] ?? 'meet') === 'slice';
461        [$xRatio, $yRatio] = self::alignRatios($align);
462
463        $scale = $slice ? max($sx, $sy) : min($sx, $sy);
464        $effectiveW = $scale * $srcW;
465        $effectiveH = $scale * $srcH;
466
467        return [
468            $scale,
469            $scale,
470            $xRatio * ($dstW - $effectiveW),
471            $yRatio * ($dstH - $effectiveH),
472            $slice,
473        ];
474    }
475
476    /**
477     * Map an SVG 2 §7.10 align keyword to a pair of ratios in the
478     * `[xRatio, yRatio]` shape used by `applyPreserveAspectRatio`:
479     *
480     *  - `xRatio` ∈ {0, 0.5, 1} — how much of the X-leftover sits on
481     *    the LEFT side of the content (`xMin` → 0, `xMid` → 0.5,
482     *    `xMax` → 1).
483     *  - `yRatio` ∈ {0, 0.5, 1} — how much of the Y-leftover sits
484     *    BELOW the content in PDF coords. Because PDF's y axis points
485     *    up and SVG's points down, "Y at the top" maps to "all
486     *    leftover at the bottom" → `yMin` → 1, `yMid` → 0.5,
487     *    `yMax` → 0.
488     *
489     * Unknown keywords default to `xMidYMid` (centre).
490     *
491     * @return array{0: float, 1: float}
492     */
493    private static function alignRatios(string $align): array
494    {
495        $alignLower = strtolower($align);
496        $xRatio = match (true) {
497            str_starts_with($alignLower, 'xmin') => 0.0,
498            str_starts_with($alignLower, 'xmax') => 1.0,
499            default => 0.5,
500        };
501        // The Y keyword sits after the X part — `xMinYMin`, `xMidYMax`, …
502        $yRatio = match (true) {
503            str_contains($alignLower, 'ymin') => 1.0,
504            str_contains($alignLower, 'ymax') => 0.0,
505            default => 0.5,
506        };
507        return [$xRatio, $yRatio];
508    }
509
510    /**
511     * Extract the leading numeric prefix from an SVG length attribute
512     * (`"100"`, `"100px"`, `"4in"`, …). Unit suffixes are ignored at
513     * 3R — proper unit resolution lands alongside CSS Lengths in a
514     * later sub-phase.
515     */
516    private static function parseLengthPrefix(?string $raw): ?float
517    {
518        if ($raw === null) {
519            return null;
520        }
521        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([%a-zA-Z]*)/', $raw, $m) !== 1) {
522            return null;
523        }
524        // Percentage values carry no intrinsic dimension — CSS Images
525        // 3 §5.2 treats `<svg width="50%">` as having no intrinsic
526        // width. Reject so the caller can fall back to the dst
527        // viewport as the source rect.
528        if ($m[2] === '%') {
529            return null;
530        }
531        $value = (float) $m[1];
532        return $value <= 0.0 ? null : $value;
533    }
534
535    /**
536     * Extract the SVG root's CSS background colour, if any.
537     *
538     * Tries `background-color` first (explicit), then the `background`
539     * shorthand. For the shorthand we look at each whitespace-delimited
540     * token and return the first one that parses as a CSS colour. This
541     * misses author intent for some pathological shorthands (e.g.
542     * `background: url(...) center / 100% red` does flow through), but
543     * covers the canonical `background: <colour>` form that the SVG
544     * background-on-root tests use.
545     *
546     * Returns null when no colour can be resolved.
547     */
548    private static function extractRootBackgroundColor(SvgDocument $svg): ?Color
549    {
550        $style = $svg->getAttribute('style');
551        if ($style === null || $style === '') {
552            return null;
553        }
554        $cleaned = preg_replace('/\/\*.*?\*\//s', ' ', $style) ?? $style;
555        $declarations = [];
556        foreach (explode(';', $cleaned) as $decl) {
557            $colon = strpos($decl, ':');
558            if ($colon === false) {
559                continue;
560            }
561            $name = strtolower(trim(substr($decl, 0, $colon)));
562            $value = trim(substr($decl, $colon + 1));
563            if ($value === '') {
564                continue;
565            }
566            $declarations[$name] = $value;
567        }
568
569        $parser = new ValueParser();
570        if (isset($declarations['background-color'])) {
571            $parsed = self::tryParseColor($parser, $declarations['background-color']);
572            if ($parsed !== null) {
573                return $parsed;
574            }
575        }
576        if (isset($declarations['background'])) {
577            // Walk tokens; first that parses as a colour wins.
578            foreach (preg_split('/\s+/', $declarations['background']) ?: [] as $token) {
579                if ($token === '') {
580                    continue;
581                }
582                $parsed = self::tryParseColor($parser, $token);
583                if ($parsed !== null) {
584                    return $parsed;
585                }
586            }
587        }
588        return null;
589    }
590
591    private static function tryParseColor(ValueParser $parser, string $css): ?Color
592    {
593        try {
594            $value = $parser->parseFromString($css);
595        } catch (\Throwable) {
596            return null;
597        }
598        return $value instanceof Color ? $value : null;
599    }
600
601}