Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.82% covered (success)
90.82%
1622 / 1786
59.20% covered (warning)
59.20%
74 / 125
CRAP
0.00% covered (danger)
0.00%
0 / 1
Translator
90.82% covered (success)
90.82%
1622 / 1786
59.20% covered (warning)
59.20%
74 / 125
1163.99
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%
53 / 53
100.00% covered (success)
100.00%
1 / 1
12
 paintChildren
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 paintNestedSvg
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paintViewportInstance
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
7
 viewportClips
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveViewportLength
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 nestedViewBoxTransform
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 viewBoxTransform
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 transformMatrixFor
19.05% covered (danger)
19.05%
4 / 21
0.00% covered (danger)
0.00%
0 / 1
32.99
 referenceBox
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 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%
45 / 45
100.00% covered (success)
100.00%
1 / 1
17
 resolveMaskState
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 registerMaskState
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 buildMaskState
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
11
 computeMaskRegion
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 resolveShapeClipPath
72.55% covered (warning)
72.55%
37 / 51
0.00% covered (danger)
0.00%
0 / 1
26.47
 clipReferenceBox
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 emitShapeClip
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
7.10
 resolveClipPath
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 applyClipPath
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 emitClipPathRegion
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 emitClipRegion
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
12
 currentMatrix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pushMatrix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 popMatrix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 multiplyAffine
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 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
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
8
 emitRectPath
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 rectCornerRadii
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 emitRoundedRectPath
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 evaluateMathFunction
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 relativeLengthBasis
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 emitEllipsePathFor
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 geometryLength
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 normalizedDiagonal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 paintsItsOwnGeometry
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 usedStrokeWidth
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 nonScalingStrokeScale
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 needsStrokeParams
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
10.16
 applyStrokeParams
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
13
 pathLengthScale
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 geometricPathLength
54.84% covered (warning)
54.84%
17 / 31
0.00% covered (danger)
0.00%
0 / 1
52.25
 ellipsePerimeter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 polylineLength
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 dispatchElement
100.00% covered (success)
100.00%
31 / 31
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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 resolveSystemLanguage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 paintUse
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 paintUseResolved
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
13
 materialisedUseInstance
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 paintUseReferent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 paintImage
85.37% covered (warning)
85.37%
35 / 41
0.00% covered (danger)
0.00%
0 / 1
20.13
 splitHrefFragment
38.46% covered (danger)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
22.91
 looksLikeSvg
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 paintEmbeddedSvg
97.10% covered (success)
97.10%
67 / 69
0.00% covered (danger)
0.00%
0 / 1
28
 embeddedSvgIntrinsicSize
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
14.91
 containWithin
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 resolveImageExtent
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
10.37
 paintImageFromPath
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
13.87
 decodeDataUri
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
12.46
 materialiseBytes
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 fetchHttpHref
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 paintTextElement
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
14
 showTextRun
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 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%
8 / 8
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintCircle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintEllipse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintLine
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 paintPolyline
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 paintPolygon
100.00% covered (success)
100.00%
6 / 6
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%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 paintMarkers
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
16
 resolveMarker
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 paintMarkerInstance
98.36% covered (success)
98.36%
60 / 61
0.00% covered (danger)
0.00%
0 / 1
11
 resolveFillPattern
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 resolvePatternTemplate
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
15
 paintPatternFill
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 hasElementChildren
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolvePatternTile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 emitPatternTiles
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
10
 patternContentMatrix
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 invertAffine
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 transformBoundingBox
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 strokeAfterPattern
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 applyFillPaint
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 currentColorOf
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 applyStrokePaint
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
11.82
 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\Css\Shape\BasicShapePath;
12use Phpdftk\Css\Value\BasicShape;
13use Phpdftk\Css\Value\Keyword as CssKeyword;
14use Phpdftk\Css\Value\ValueList;
15use Phpdftk\Css\Cascade\CalcEvaluator;
16use Phpdftk\Css\Cascade\LengthContext;
17use Phpdftk\Css\Value\Calc;
18use Phpdftk\Css\ValueParser;
19use Phpdftk\Filesystem\LocalFilesystem;
20use Phpdftk\ImageMetadata\ImageParser;
21use Phpdftk\ResourceLoader\Exception\FetchFailedException;
22use Phpdftk\ResourceLoader\Exception\SsrfBlockedException;
23use Phpdftk\ResourceLoader\ResourceLoader;
24use Phpdftk\Pdf\Core\Content\ContentStream;
25use Phpdftk\Pdf\Core\Document\GroupAttributes;
26use Phpdftk\Pdf\Core\Graphics\ExtGState;
27use Phpdftk\Pdf\Core\Graphics\SoftMask;
28use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
29use Phpdftk\Pdf\Core\PdfArray;
30use Phpdftk\Pdf\Core\PdfName;
31use Phpdftk\Pdf\Core\PdfNumber;
32use Phpdftk\Pdf\Core\PdfReference;
33use Phpdftk\Pdf\Writer\Page;
34use Phpdftk\Pdf\Writer\PdfWriter;
35use Phpdftk\SvgToPdf\Gradient\GradientPainter;
36use Phpdftk\SvgToPdf\Text\DocumentFont;
37use Phpdftk\SvgToPdf\Text\DocumentFontProvider;
38use Phpdftk\SvgToPdf\Text\FontResolver;
39use Phpdftk\Svg\ClipPath;
40use Phpdftk\Svg\Defs;
41use Phpdftk\Svg\Element;
42use Phpdftk\Svg\Image as SvgImage;
43use Phpdftk\Svg\Marker;
44use Phpdftk\Svg\Mask;
45use Phpdftk\Svg\Symbol;
46use Phpdftk\Svg\Use_;
47use Phpdftk\SvgToPdf\Geometry\BoundingBox;
48use Phpdftk\Svg\Value\Paint\ContextPaint;
49use Phpdftk\SvgToPdf\Path\MarkerVertex;
50use Phpdftk\SvgToPdf\Path\MarkerVertices;
51use Phpdftk\SvgToPdf\Path\PathLengthMeasure;
52use Phpdftk\Svg\Path;
53use Phpdftk\Svg\Pattern;
54use Phpdftk\Svg\Path\ArcTo;
55use Phpdftk\Svg\Path\ClosePath;
56use Phpdftk\Svg\Path\CurveTo;
57use Phpdftk\Svg\Path\HorizontalLineTo;
58use Phpdftk\Svg\Path\LineTo;
59use Phpdftk\Svg\Path\MoveTo;
60use Phpdftk\Svg\Path\PathCommand;
61use Phpdftk\Svg\Path\QuadraticCurveTo;
62use Phpdftk\Svg\Path\SmoothCurveTo;
63use Phpdftk\Svg\Path\SmoothQuadraticCurveTo;
64use Phpdftk\Svg\Path\VerticalLineTo;
65use Phpdftk\Svg\Shape\Circle;
66use Phpdftk\Svg\Shape\Ellipse;
67use Phpdftk\Svg\Shape\Line;
68use Phpdftk\Svg\Shape\Polygon;
69use Phpdftk\Svg\Shape\Polyline;
70use Phpdftk\Svg\Shape\Rect;
71use Phpdftk\Svg\Parser as SvgDocumentParser;
72use Phpdftk\Svg\SvgDocument;
73use Phpdftk\Svg\View as SvgView;
74use Phpdftk\Svg\Text as TextNode;
75use Phpdftk\Text\TextTransform;
76use Phpdftk\Svg\Text\TextElement;
77use Phpdftk\Svg\Value\Transform;
78use Phpdftk\Svg\Value\TransformOrigin;
79use Phpdftk\Svg\Value\Paint;
80use Phpdftk\Svg\Value\Color as SvgColor;
81use Phpdftk\Svg\Value\Paint\CurrentColor;
82use Phpdftk\Svg\Value\Paint\None_;
83use Phpdftk\Svg\Value\Paint\SolidColor;
84use Phpdftk\Svg\Value\Paint\Url;
85use Phpdftk\SvgToPdf\Path\ArcToCubic;
86use Phpdftk\SvgToPdf\Path\PathPainterState;
87
88/**
89 * Translates a parsed `Phpdftk\Svg\SvgDocument` into PDF content-stream
90 * operators. The translator is a thin recursive walk: each element is
91 * dispatched to a per-shape painter that emits the right path and
92 * `f`/`S`/`B` operator combination.
93 *
94 * Coordinate convention: SVG and PDF disagree on Y-axis direction (SVG
95 * Y-down, PDF Y-up). The translator emits SVG coordinates verbatim â€” the
96 * caller is responsible for setting up a PDF transformation (`cm`) that
97 * flips and translates if it wants the SVG to appear at a specific PDF
98 * position. Tests can paint directly into a fresh PDF stream because the
99 * default user space happens to put numbers in a viewable range for small
100 * SVGs.
101 *
102 * What 3K covers: basic shapes (`<rect>`, `<circle>`, `<ellipse>`,
103 * `<line>`, `<polyline>`, `<polygon>`) and the SolidColor fill / stroke
104 * paint cases. `<path>` lands in 3L, `<g>` + transforms in 3M, gradients
105 * in 3O, text in 3P, use/clip/mask/image in 3Q. Until then unrecognised
106 * elements are walked through transparently â€” their children paint as if
107 * the unknown container weren't there.
108 *
109 * Default paint per SVG 2 Â§13.2.1: black fill, no stroke. The translator
110 * applies that fallback when no explicit fill is set on the element.
111 */
112final class Translator
113{
114    /**
115     * Cubic-Bézier "magic number" approximating a unit-circle quarter
116     * arc â€” `(4/3) Â· tan(Ï€/8) â‰ˆ 0.5522847498`. Standard Îº for
117     * `<circle>` / `<ellipse>` rendering.
118     */
119    private const float KAPPA = 0.5522847498;
120
121    /**
122     * Longest `<pattern href>` template chain the translator follows
123     * before giving up. SVG 2 places no explicit cap; this one exists
124     * purely so a pathological document can't make resolution quadratic.
125     */
126    private const int MAX_PATTERN_TEMPLATE_DEPTH = 16;
127
128    /**
129     * How deep `<image href="…svg">` references may nest before the
130     * painter stops descending. An SVG resource can reference another
131     * SVG resource that references it back; SVG 2 Â§8.6 forbids
132     * rendering such a cycle, and a hard cap is the cheapest way to
133     * guarantee termination when the cycle runs through `data:` URIs
134     * that are never identity-equal.
135     */
136    private const int MAX_EMBEDDED_SVG_DEPTH = 8;
137
138    /**
139     * SVG 2 Â§13.3 â€” the `<pattern>` attributes a referencing pattern
140     * inherits from its `href` template when it does not specify them
141     * itself. `href` / `xlink:href` are deliberately absent: the chain
142     * is already walked, and copying them onto the merged result would
143     * make it self-referential.
144     *
145     * @var list<string>
146     */
147    private const array PATTERN_INHERITED_ATTRIBUTES = [
148        'x',
149        'y',
150        'width',
151        'height',
152        'patternUnits',
153        'patternContentUnits',
154        'patternTransform',
155        'viewBox',
156        'preserveAspectRatio',
157    ];
158
159    /**
160     * Optional `phpdftk/resource-loader` for `http(s)://` `<image>`
161     * hrefs. When `null` (the default â€” preserves existing call-
162     * site behaviour), network hrefs drop silently per the SVG 2
163     * Â§12.6 "no image available" outcome. When supplied, the
164     * loader runs (with its SSRF guard, redirect handling, body
165     * cap, and MIME sniffing) and the embedded bytes get
166     * materialised to a temp file the same way `data:` URIs do.
167     */
168    public function __construct(
169        private readonly ?ResourceLoader $resourceLoader = null,
170        /**
171         * Optional seam through which an embedding document (HTML, at
172         * present) supplies its own font matching + PDF font handles.
173         * When null - or when it declines a particular request - SVG
174         * text falls back to the built-in standard-14
175         * {@see FontResolver}.
176         */
177        private readonly ?DocumentFontProvider $documentFontProvider = null,
178        /**
179         * Projects a referenced document's `<style>` cascade into its
180         * elements' `style` attributes before an embedded SVG is
181         * painted â€” the referenced resource never went through
182         * {@see SvgRenderer::draw}, which is where the host document
183         * gets the same treatment.
184         */
185        private readonly SvgCascadeProjector $cascadeProjector = new SvgCascadeProjector(),
186    ) {
187        $this->useExpansionsInProgress = new \SplObjectStorage();
188    }
189
190    /**
191     * How many `<image href="…svg">` hops led to this translator.
192     * Zero for the host document; incremented for each embedded
193     * resource so {@see MAX_EMBEDDED_SVG_DEPTH} can terminate cycles.
194     */
195    private int $embeddedSvgDepth = 0;
196
197    /**
198     * Paint a parsed SVG document into the given content stream.
199     *
200     * When `$page` is supplied, the painter registers an `ExtGState`
201     * resource on that page for any element that carries `opacity`,
202     * `fill-opacity`, or `stroke-opacity` < 1 and emits the `gs`
203     * operator to invoke it. Without a `$page` reference opacity
204     * attributes are silently ignored â€” the painter falls back to
205     * fully-opaque rendering.
206     *
207     * When `$page` AND `$writer` are both supplied, gradient paint
208     * references (`fill="url(#id)"`) resolve through the writer's
209     * `PdfDoc` for shading registration and the page's
210     * `useGradient` for resource attachment. Without them, gradient
211     * fills fall back to no paint per SVG 2's "invalid â†’ no paint"
212     * semantics.
213     */
214    /**
215     * @param array{w: float, h: float}|null $effectiveViewport
216     *   Override viewport for inner percentage-attribute resolution
217     *   when the document's own width/height/viewBox don't yield
218     *   useful dimensions. Set by `SvgRenderer::draw` when it
219     *   synthesises a source rect from the destination.
220     * @param array{float, float, float, float, float, float}|null $baseMatrix
221     *   The SVG→page base transform the caller applied to `$stream`
222     *   (viewport scale + y-flip + placement); seeds the cumulative
223     *   matrix stack so gradient pattern `/Matrix` entries track the CTM.
224     *   Null (direct-render tests) seeds the identity.
225     */
226    public function paint(
227        SvgDocument $document,
228        ContentStream $stream,
229        ?Page $page = null,
230        ?PdfWriter $writer = null,
231        bool $compensateTextFlip = false,
232        ?array $effectiveViewport = null,
233        ?array $baseMatrix = null,
234    ): void {
235        $this->page = $page;
236        $this->writer = $writer;
237        $this->document = $document;
238        $this->compensateTextFlip = $compensateTextFlip;
239        $this->effectiveViewport = $effectiveViewport;
240        // CSS Values 4 Â§6.1 â€” `vw` / `vh` / `vmin` / `vmax` resolve
241        // against the INITIAL containing block, which for an SVG
242        // document is its outermost viewport. Capture it here, with
243        // the nested-viewport stack empty, so a `<svg>` nested inside
244        // the document can't shadow it.
245        $this->viewportStack = [];
246        $this->rootViewport = $this->currentViewport();
247        // Seed the cumulative-transform stack with the renderer's base matrix
248        // (viewport scale + y-flip + page placement) so gradient patterns can
249        // reconstruct the SVG→page mapping the caller applied to the stream.
250        $this->matrixStack = [$baseMatrix ?? [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
251        $this->gradientPainter = $page !== null && $writer !== null
252            ? new GradientPainter($writer, $page, $document)
253            : null;
254        $this->fontResolver = $page !== null && $writer !== null
255            ? new FontResolver($writer, $page)
256            : null;
257        // SVG 2 Â§8.4 â€” `transform` applies to the OUTERMOST `<svg>` as
258        // well, and transforms everything inside it. The root never
259        // goes through `paintElement()`, so its own transform has to be
260        // concatenated here. It composes BEFORE the viewBox origin
261        // shift: the viewBox coordinate system is what the root
262        // transform acts on.
263        //
264        // Resolved before the `try` so the `finally` that unwinds it
265        // can never see an unassigned variable.
266        $rootTransform = $document->transform();
267        $rootMatrix = $rootTransform === null
268            ? null
269            : $this->transformMatrixFor($document, $rootTransform);
270        try {
271            if ($rootMatrix !== null) {
272                $stream->saveGraphicsState();
273                $stream->concatMatrix(
274                    $rootMatrix[0],
275                    $rootMatrix[1],
276                    $rootMatrix[2],
277                    $rootMatrix[3],
278                    $rootMatrix[4],
279                    $rootMatrix[5],
280                );
281                $this->pushMatrix($rootMatrix);
282            }
283            $viewBox = $document->viewBox();
284            if (
285                $baseMatrix === null
286                && $viewBox !== null
287                && ($viewBox[0] !== 0.0 || $viewBox[1] !== 0.0)
288            ) {
289                // SVG 2 Â§7.7 â€” the viewBox's `min-x`/`min-y` shift the
290                // origin of the local coordinate system. The proper
291                // viewBox-to-viewport mapping (with `preserveAspectRatio`)
292                // needs a caller-supplied target rectangle, so it lives
293                // in the 3R adapter layer; here we honour just the
294                // translation so the painted content stays anchored
295                // correctly relative to the viewBox.
296                //
297                // ONLY when the caller did not hand us a `$baseMatrix`.
298                // `SvgRenderer::draw` already folds the shift into the
299                // matrix it concatenates (`e = x + offsetX - minX * sx`),
300                // so doing it again here translated the content a second
301                // time â€” and UNSCALED, which pushed every document with a
302                // non-zero viewBox min-x clean off the page and rendered
303                // it blank.
304                $stream->saveGraphicsState();
305                $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$viewBox[0], -$viewBox[1]);
306                $this->pushMatrix([1.0, 0.0, 0.0, 1.0, -$viewBox[0], -$viewBox[1]]);
307                $this->paintChildren($document, $stream);
308                $this->popMatrix();
309                $stream->restoreGraphicsState();
310                return;
311            }
312            $this->paintChildren($document, $stream);
313        } finally {
314            if ($rootMatrix !== null) {
315                $this->popMatrix();
316                $stream->restoreGraphicsState();
317            }
318            $this->page = null;
319            $this->writer = null;
320            $this->document = null;
321            $this->gradientPainter = null;
322            $this->fontResolver = null;
323            $this->activeFont = null;
324            $this->compensateTextFlip = false;
325            $this->effectiveViewport = null;
326            $this->rootViewport = null;
327        }
328    }
329
330    private ?Page $page = null;
331    private ?PdfWriter $writer = null;
332    /** @var array{w: float, h: float}|null */
333    private ?array $effectiveViewport = null;
334    private ?SvgDocument $document = null;
335    /**
336     * Stack of nested `<svg>` viewports (viewBox units), innermost last.
337     * Percentage lengths + `currentViewport()` resolve against the top.
338     *
339     * @var list<array{w: float, h: float}>
340     */
341    private array $viewportStack = [];
342
343    /**
344     * `<clipPath>` elements currently being reified into a clip region,
345     * innermost last. Guards {@see emitClipRegion} against `clip-path`
346     * reference cycles between clipPath elements.
347     *
348     * @var list<ClipPath>
349     */
350    private array $clipPathStack = [];
351    /**
352     * The document's OUTERMOST viewport â€” the initial containing block
353     * the `vw` / `vh` / `vmin` / `vmax` units resolve against (CSS
354     * Values 4 Â§6.1). Captured once per {@see paint()} so a nested
355     * `<svg>` viewport can't shadow it the way it shadows `%`.
356     *
357     * @var array{w: float, h: float}|null
358     */
359    private ?array $rootViewport = null;
360    /**
361     * A `<use>`'s width/height override for the viewport element it
362     * references (SVG 2 Â§5.6.2), consumed by the next
363     * `paintViewportInstance`.
364     *
365     * Each axis is independent: `<use width="90">` on a 10x10
366     * `<symbol>` overrides only the width, so the instance is 90x10.
367     * Treating the override as all-or-nothing silently dropped the
368     * single-axis form.
369     *
370     * @var array{w: float|null, h: float|null}|null
371     */
372    private ?array $pendingUseViewport = null;
373
374    /**
375     * SVG 2 Â§13.2.1 â€” the paint `context-fill` / `context-stroke`
376     * inside the subtree currently being painted defer to, or null
377     * when that subtree was not reached through a reference.
378     */
379    private ?ContextElementPaint $contextPaint = null;
380    /**
381     * Referenced elements whose `<use>` expansion is currently on the
382     * stack, keyed by identity.
383     *
384     * SVG 2 Â§5.6.2 â€” a circular `<use>` reference must not be rendered.
385     * The ancestor test in `paintUse` catches the direct form
386     * (`<g id="a"><use href="#a"/></g>`), but two `<use>` elements can
387     * also reference each other's containers, where neither referent is
388     * an ancestor of its own `<use>`. This re-entrancy set closes that:
389     * a referent already being painted is not painted again.
390     *
391     * Keyed on the REFERENT, not the `<use>`, and cleared on the way
392     * out, so the same target referenced twice in sequence still expands
393     * both times.
394     *
395     * @var \SplObjectStorage<Element, bool>
396     */
397    private \SplObjectStorage $useExpansionsInProgress;
398    private ?GradientPainter $gradientPainter = null;
399    private ?FontResolver $fontResolver = null;
400    /**
401     * Font selected for the `<text>` element currently being painted.
402     * Set by {@see paintTextElement} before any `Tj` is emitted and read
403     * by {@see showTextRun} so the shadow, single-run and per-glyph
404     * paths all agree on how to encode the string.
405     */
406    private ?DocumentFont $activeFont = null;
407    private bool $compensateTextFlip = false;
408    /**
409     * Stack of cumulative affine transforms (outer→inner) mapping the CURRENT
410     * SVG user space to PDF page space â€” seeded in {@see paint()} with the
411     * renderer's base matrix (viewport scale + y-flip + placement) and grown
412     * by each `<svg>` viewport, element `transform`, and `<use>` translate as
413     * painting descends. Its top is threaded into gradient patterns whose
414     * `/Matrix` PDF resolves against DEFAULT page space (not the fill-time
415     * CTM), so without this a transformed or vertically-oriented gradient
416     * paints in the wrong place / orientation.
417     *
418     * @var non-empty-list<array{float, float, float, float, float, float}>
419     */
420    private array $matrixStack = [[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
421
422    private function paintChildren(Element $parent, ContentStream $stream): void
423    {
424        foreach ($parent->children as $child) {
425            if ($child instanceof Element) {
426                $this->paintElement($child, $stream);
427            }
428        }
429    }
430
431    /**
432     * SVG 2 Â§7.5 â€” a nested `<svg>` establishes a new viewport from its
433     * `x` / `y` / `width` / `height`, clips overflow to it, and (when it
434     * carries a `viewBox`) sets up a new user coordinate system via the
435     * viewBox-to-viewport scale + `preserveAspectRatio` alignment.
436     */
437    private function paintNestedSvg(\Phpdftk\Svg\NestedSvg $svg, ContentStream $stream): void
438    {
439        $this->paintViewportInstance($svg, $stream);
440    }
441
442    /**
443     * Paint a viewport-establishing element â€” a nested `<svg>`, or the
444     * instance a `<use>` generates for a `<symbol>` (SVG 2 Â§5.5,
445     * Â§5.6.2). Both map their content into a rectangle and clip to it;
446     * the only difference is that a `<symbol>` never paints unless a
447     * `<use>` brings it here.
448     */
449    private function paintViewportInstance(\Phpdftk\Svg\ViewportElement $svg, ContentStream $stream): void
450    {
451        // A `<use>` that references this element overrides its viewport size.
452        $override = $this->pendingUseViewport;
453        $this->pendingUseViewport = null;
454        $vp = $this->currentViewport();
455        $x = $this->resolveViewportLength($svg->getAttribute('x'), $vp['w'], 0.0);
456        $y = $this->resolveViewportLength($svg->getAttribute('y'), $vp['h'], 0.0);
457        // Width / height: a `<use>` override wins PER AXIS; otherwise the
458        // element's own attributes, defaulting to 100% of the enclosing
459        // viewport.
460        $w = $override['w'] ?? $this->resolveViewportLength($svg->widthAttribute(), $vp['w'], $vp['w']);
461        $h = $override['h'] ?? $this->resolveViewportLength($svg->heightAttribute(), $vp['h'], $vp['h']);
462        if ($w <= 0.0 || $h <= 0.0) {
463            return; // A zero-sized viewport disables rendering (SVG 2 Â§7.5).
464        }
465
466        $stream->saveGraphicsState();
467        // Position the viewport, then clip overflow to it â€” SVG 2 Â§8.2
468        // puts `overflow: hidden` on viewport elements in the UA
469        // stylesheet, which the author can turn off.
470        $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
471        if (self::viewportClips($svg)) {
472            $stream->rectangle(0.0, 0.0, $w, $h);
473            $stream->clip();
474            $stream->endPath();
475        }
476
477        $childViewport = ['w' => $w, 'h' => $h];
478        $viewBox = $svg->viewBox();
479        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
480            [$scaleX, $scaleY, $offsetX, $offsetY]
481                = $this->nestedViewBoxTransform($svg, $viewBox[2], $viewBox[3], $w, $h);
482            $stream->concatMatrix(
483                $scaleX,
484                0.0,
485                0.0,
486                $scaleY,
487                $offsetX - $viewBox[0] * $scaleX,
488                $offsetY - $viewBox[1] * $scaleY,
489            );
490            $childViewport = ['w' => $viewBox[2], 'h' => $viewBox[3]];
491        }
492
493        $this->viewportStack[] = $childViewport;
494        $this->paintChildren($svg, $stream);
495        array_pop($this->viewportStack);
496        $stream->restoreGraphicsState();
497    }
498
499    /**
500     * Whether a viewport element clips its content to the viewport.
501     *
502     * SVG 2 Â§8.2 â€” the UA stylesheet sets `overflow: hidden` on `svg`,
503     * `symbol`, `image`, `marker` and `pattern`, so the DEFAULT here is
504     * to clip even though CSS's own initial value is `visible`. Only an
505     * author `overflow: visible` turns the clip off.
506     *
507     * `auto` CLIPS. SVG 1.1 used to define it as "the content is not
508     * clipped", but w3c/svgwg#1072 settled on CSS's meaning â€” there is
509     * nothing to scroll in an SVG viewport, so `auto` degenerates to
510     * `hidden`. WPT's `painting/inner-outer-svg-overflow-auto` asserts
511     * exactly that, on both the inner and the outer viewport.
512     */
513    private static function viewportClips(Element $element): bool
514    {
515        return $element->overflowValue() !== 'visible';
516    }
517
518    /**
519     * Resolve a nested-viewport length attribute: a `%` resolves against
520     * the given viewport dimension, a plain number is taken as-is, and an
521     * absent/empty value falls back to `$default`.
522     */
523    private function resolveViewportLength(?string $raw, float $viewport, float $default): float
524    {
525        if ($raw === null || trim($raw) === '') {
526            return $default;
527        }
528        if (preg_match(
529            '/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(%|vw|vh|vmin|vmax)\s*$/i',
530            $raw,
531            $m,
532        ) === 1) {
533            return ((float) $m[1]) / 100.0 * $this->relativeLengthBasis(strtolower($m[2]), $viewport);
534        }
535        $plain = self::parseLengthPrefixForViewport($raw);
536        return $plain ?? $default;
537    }
538
539    /**
540     * Nested viewBox-to-viewport mapping in SVG user space (y-down â€” the
541     * outer document transform applies the PDF flip). Returns
542     * `[scaleX, scaleY, offsetX, offsetY]`. Mirrors the root
543     * `applyPreserveAspectRatio` but without the PDF y-axis inversion,
544     * since a nested `<svg>` composes inside the already-flipped space.
545     *
546     * @return array{0: float, 1: float, 2: float, 3: float}
547     */
548    private function nestedViewBoxTransform(
549        Element $svg,
550        float $srcW,
551        float $srcH,
552        float $dstW,
553        float $dstH,
554    ): array {
555        return self::viewBoxTransform(
556            $svg->getAttribute('preserveAspectRatio') ?? '',
557            $srcW,
558            $srcH,
559            $dstW,
560            $dstH,
561        );
562    }
563
564    /**
565     * viewBox-to-viewport mapping for a given `preserveAspectRatio`
566     * string, in SVG user space (y-down â€” the outer document transform
567     * applies the PDF flip). Returns `[scaleX, scaleY, offsetX,
568     * offsetY]`.
569     *
570     * @return array{0: float, 1: float, 2: float, 3: float}
571     */
572    private static function viewBoxTransform(
573        string $preserveAspectRatio,
574        float $srcW,
575        float $srcH,
576        float $dstW,
577        float $dstH,
578    ): array {
579        $sx = $srcW > 0.0 ? $dstW / $srcW : 1.0;
580        $sy = $srcH > 0.0 ? $dstH / $srcH : 1.0;
581        $par = strtolower(trim($preserveAspectRatio));
582        if ($par === 'none') {
583            return [$sx, $sy, 0.0, 0.0];
584        }
585        $tokens = preg_split('/\s+/', $par) ?: [];
586        $align = $tokens[0] ?? '';
587        $slice = ($tokens[1] ?? 'meet') === 'slice';
588        $scale = $slice ? max($sx, $sy) : min($sx, $sy);
589        [$xRatio, $yRatio] = self::nestedAlignRatios($align);
590        return [
591            $scale,
592            $scale,
593            $xRatio * ($dstW - $scale * $srcW),
594            $yRatio * ($dstH - $scale * $srcH),
595        ];
596    }
597
598    /**
599     * The affine matrix for an element's `transform`, pivoted about its
600     * `transform-origin` (CSS Transforms 1 Â§6).
601     *
602     * SVG elements have no associated CSS layout box, so the initial
603     * `transform-origin` is `0 0` â€” the plain `transform` matrix â€” and
604     * only an explicit value costs a bounding-box computation.
605     *
606     * @return array{float, float, float, float, float, float}
607     */
608    private function transformMatrixFor(Element $element, Transform $transform): array
609    {
610        $raw = $element->transformOrigin();
611        $boxKeyword = $element->transformBox();
612        if ($raw === null && $boxKeyword === null) {
613            return $transform->toMatrix();
614        }
615        $box = $this->referenceBox($element, $boxKeyword);
616        if ($box === null) {
617            return $transform->toMatrix();
618        }
619        if ($raw === null) {
620            // An explicit `transform-box` with no `transform-origin`
621            // pivots on the reference box's own origin: SVG elements
622            // have no CSS layout box, so their used initial
623            // `transform-origin` is `0 0` rather than `50% 50%`.
624            return $transform->toMatrixAbout($box[0], $box[1]);
625        }
626        $origin = TransformOrigin::parse($raw);
627        if ($origin === null) {
628            return $boxKeyword === null
629                ? $transform->toMatrix()
630                : $transform->toMatrixAbout($box[0], $box[1]);
631        }
632        [$ox, $oy] = $origin->resolve(
633            $box[0],
634            $box[1],
635            $box[2],
636            $box[3],
637        );
638        return $transform->toMatrixAbout($ox, $oy);
639    }
640
641    /**
642     * The reference box `transform-origin` resolves against
643     * (CSS Transforms 1 Â§7), as `[x, y, width, height]` in user units.
644     *
645     * `view-box` is the nearest SVG viewport; `fill-box` is the object
646     * bounding box; `stroke-box` is that box grown by half the stroke
647     * on each side. For SVG elements â€” which have no CSS layout box â€”
648     * `content-box` behaves as `fill-box` and `border-box` as
649     * `stroke-box`.
650     *
651     * A null `$keyword` means no `transform-box` was specified, which
652     * takes the initial value, `view-box`.
653     *
654     * @return array{float, float, float, float}|null
655     */
656    private function referenceBox(Element $element, ?string $keyword): ?array
657    {
658        if ($keyword === null || $keyword === 'view-box') {
659            $viewport = $this->currentViewport();
660            return [0.0, 0.0, $viewport['w'], $viewport['h']];
661        }
662        $bbox = BoundingBox::compute($element, viewport: $this->currentViewport());
663        if ($bbox === null) {
664            return null;
665        }
666        $box = [$bbox['minX'], $bbox['minY'], $bbox['width'], $bbox['height']];
667        if ($keyword === 'stroke-box' || $keyword === 'border-box') {
668            $half = ($element->strokeWidth() ?? 1.0) / 2.0;
669            $box = [
670                $box[0] - $half,
671                $box[1] - $half,
672                $box[2] + $half * 2.0,
673                $box[3] + $half * 2.0,
674            ];
675        }
676        return $box;
677    }
678
679    /**
680     * `preserveAspectRatio` align keyword â†’ `[xRatio, yRatio]` leftover
681     * fractions, in SVG y-down space (`yMin` â†’ 0, `yMid` â†’ 0.5,
682     * `yMax` â†’ 1). Defaults to `xMidYMid`.
683     *
684     * @return array{0: float, 1: float}
685     */
686    private static function nestedAlignRatios(string $align): array
687    {
688        $align = $align === '' ? 'xmidymid' : $align;
689        $xRatio = str_contains($align, 'xmax') ? 1.0 : (str_contains($align, 'xmin') ? 0.0 : 0.5);
690        $yRatio = str_contains($align, 'ymax') ? 1.0 : (str_contains($align, 'ymin') ? 0.0 : 0.5);
691        return [$xRatio, $yRatio];
692    }
693
694    private function paintElement(Element $element, ContentStream $stream): void
695    {
696        // SVG 2 Â§8.9 â€” `display: none` removes the element AND its
697        // descendants from the rendering tree. Unlike `visibility`, a
698        // descendant cannot opt back in, so this is a hard return
699        // rather than a paint-time flag.
700        if ($element->displayValue() === 'none') {
701            return;
702        }
703        // SVG 2 Â§5.8 â€” conditional processing gates EVERY direct
704        // rendering element, not only `<switch>` branches. An element
705        // whose conditions evaluate false is not rendered, and neither
706        // are its children.
707        if (!$this->switchChildPasses($element)) {
708            return;
709        }
710        // Any of these scope-leaking attributes triggers a `q`/`Q` wrap
711        // so the state doesn't leak across siblings:
712        //
713        //   - transform: emits `cm`.
714        //   - opacity / fill-opacity / stroke-opacity (< 1): emits `gs`.
715        //   - stroke params (w / J / j / M / d): each emits its own op.
716        //   - clip-path: emits the clip region inside the same wrap.
717        //   - mask: emits `gs` referencing a SMask-bearing ExtGState.
718        //
719        // Painting the same shape with all defaults stays a one-shot
720        // op stream â€” no overhead when none of the above is set.
721        $transform = $element->transform();
722        $opacityGs = $this->resolveOpacityState($element);
723        $needsStrokeParams = $this->needsStrokeParams($element);
724        $clipPath = $this->resolveClipPath($element);
725        $shapeClip = $clipPath === null ? $this->resolveShapeClipPath($element) : null;
726        $maskGs = $this->resolveMaskState($element);
727        $needsWrap = $transform !== null
728            || $opacityGs !== null
729            || $needsStrokeParams
730            || $clipPath !== null
731            || $shapeClip !== null
732            || $maskGs !== null;
733
734        if (!$needsWrap) {
735            $this->dispatchElement($element, $stream);
736            return;
737        }
738
739        $stream->saveGraphicsState();
740        if ($transform !== null) {
741            $matrix = $this->transformMatrixFor($element, $transform);
742            $stream->concatMatrix(
743                $matrix[0],
744                $matrix[1],
745                $matrix[2],
746                $matrix[3],
747                $matrix[4],
748                $matrix[5],
749            );
750            // Track the transform so gradient fills inside this element (and
751            // its children) anchor their pattern to the same CTM the geometry
752            // is painted under.
753            $this->pushMatrix($matrix);
754        }
755        if ($opacityGs !== null) {
756            $stream->setGraphicsState($opacityGs);
757        }
758        if ($needsStrokeParams) {
759            $this->applyStrokeParams($element, $stream);
760        }
761        if ($clipPath !== null) {
762            $this->applyClipPath($clipPath, $element, $stream);
763        }
764        if ($shapeClip !== null) {
765            self::emitShapeClip($shapeClip, $stream);
766        }
767        if ($maskGs !== null) {
768            $stream->setGraphicsState($maskGs);
769        }
770        $this->dispatchElement($element, $stream);
771        if ($transform !== null) {
772            $this->popMatrix();
773        }
774        $stream->restoreGraphicsState();
775    }
776
777    /**
778     * Resolve `mask="url(#id)"` to a registered `ExtGState` whose
779     * `/SMask` references a Form XObject containing the mask's
780     * painted children. Returns the resource name to invoke via
781     * `gs`, or null when no mask is set / can't be resolved.
782     *
783     * Pipeline:
784     *
785     *   1. Paint the `<mask>`'s children into a new `ContentStream`
786     *      (running the full Translator pipeline so gradients, fonts,
787     *      images, etc. all register on the host page).
788     *   2. Wrap the resulting bytes in a Form XObject. `/Group /S
789     *      Transparency /CS DeviceGray` so the form's pixels become an
790     *      alpha channel via their luminance.
791     *   3. Build a `SoftMask` dict with `/S Luminosity` and `/BC [0]`
792     *      so the backdrop outside the mask region is black (hidden).
793     *   4. Drop the SMask in an `ExtGState`, register, and attach to
794     *      the page's resources under a stable name.
795     *
796     * Implemented:
797     *
798     *   - `maskContentUnits = objectBoundingBox` applies a bbox `cm`
799     *     to the mask content stream so authored coords are in [0, 1].
800     *   - SVG 2 Â§14.5.4 defaults are honoured: bbox-mode defaults
801     *     `(-10%, -10%, 120%, 120%)` so the mask reaches a hair
802     *     beyond the painted geometry; userspace-mode defaults to
803     *     the masked element's own bbox.
804     *   - Explicit `x` / `y` / `width` / `height` attributes on
805     *     the `<mask>` element override the defaults.
806     */
807    private function resolveMaskState(Element $element): ?string
808    {
809        if ($this->writer === null
810            || $this->page === null
811            || $this->document === null
812        ) {
813            return null;
814        }
815        $raw = $element->maskValue();
816        if ($raw === null) {
817            return null;
818        }
819        $trimmed = trim($raw);
820        if ($trimmed === 'none') {
821            return null;
822        }
823        if (preg_match('/^url\(\s*[\x22\x27]?#([^)\s\x22\x27]+)[\x22\x27]?\s*\)/i', $trimmed, $m) !== 1) {
824            return null;
825        }
826        $referent = $this->document->findByFragment($m[1]);
827        if (!$referent instanceof Mask) {
828            return null;
829        }
830        $elementBbox = BoundingBox::compute($element, viewport: $this->currentViewport());
831        if ($elementBbox === null) {
832            return null;
833        }
834        return $this->buildMaskState($referent, $elementBbox, null, null);
835    }
836
837    /**
838     * CSS Masking 1 Â§4 â€” build the soft-mask `ExtGState` for a `<mask>`
839     * element referenced from OUTSIDE the SVG pipeline (an HTML element's
840     * `mask-image: url(#id)`), and return its resource name.
841     *
842     * `$elementBbox` is the masked element's bounding box in the mask's
843     * own user space â€” for an HTML element that space has its origin at
844     * the top-left of the border box with y running down, so the bbox is
845     * `(0, 0, w, h)`. `$baseMatrix` maps that space onto the page and is
846     * prepended to the mask's content stream (the soft mask is evaluated
847     * under the CTM in force when its `gs` runs, and the HTML painter
848     * paints under the identity CTM). `$subtypeOverride` carries CSS
849     * `mask-mode`, which wins over the element's `mask-type`.
850     *
851     * @param array{minX: float, minY: float, width: float, height: float} $elementBbox
852     * @param array{float, float, float, float, float, float}|null $baseMatrix
853     */
854    public function registerMaskState(
855        SvgDocument $document,
856        Mask $mask,
857        Page $page,
858        PdfWriter $writer,
859        array $elementBbox,
860        ?array $baseMatrix = null,
861        ?string $subtypeOverride = null,
862    ): ?string {
863        $previous = [$this->document, $this->page, $this->writer, $this->gradientPainter, $this->fontResolver, $this->matrixStack];
864        $this->document = $document;
865        $this->page = $page;
866        $this->writer = $writer;
867        $this->gradientPainter = new GradientPainter($writer, $page, $document);
868        $this->fontResolver = new FontResolver($writer, $page);
869        $this->matrixStack = [$baseMatrix ?? [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
870        try {
871            return $this->buildMaskState($mask, $elementBbox, $baseMatrix, $subtypeOverride);
872        } finally {
873            [$this->document, $this->page, $this->writer, $this->gradientPainter, $this->fontResolver, $this->matrixStack] = $previous;
874        }
875    }
876
877    /**
878     * Shared body of {@see resolveMaskState} / {@see registerMaskState}.
879     *
880     * @param array{minX: float, minY: float, width: float, height: float} $elementBbox
881     * @param array{float, float, float, float, float, float}|null $baseMatrix
882     */
883    private function buildMaskState(
884        Mask $referent,
885        array $elementBbox,
886        ?array $baseMatrix,
887        ?string $subtypeOverride,
888    ): ?string {
889        if ($this->writer === null || $this->page === null) {
890            return null;
891        }
892        $region = self::computeMaskRegion($referent, $elementBbox);
893
894        $maskStream = new ContentStream();
895        if ($baseMatrix !== null) {
896            $maskStream->concatMatrix(...$baseMatrix);
897        }
898        if ($referent->maskContentUnits() === 'objectBoundingBox') {
899            // Reify mask children's [0, 1] coords against the masked
900            // element's bbox (the same reference frame the masked
901            // element's geometry inhabits â€” not the mask region).
902            $maskStream->concatMatrix(
903                $elementBbox['width'],
904                0.0,
905                0.0,
906                $elementBbox['height'],
907                $elementBbox['minX'],
908                $elementBbox['minY'],
909            );
910        }
911        foreach ($referent->children as $child) {
912            if ($child instanceof Element) {
913                $this->paintElement($child, $maskStream);
914            }
915        }
916        $operatorBytes = implode("\n", $maskStream->getOperators());
917        // The BBox lives in the form's OWN space, i.e. before the content
918        // stream's own `cm`. Map the user-space region through the base
919        // matrix so an externally-supplied mapping is accounted for.
920        $corners = [
921            [$region['minX'], $region['minY']],
922            [$region['minX'] + $region['width'], $region['minY'] + $region['height']],
923        ];
924        if ($baseMatrix !== null) {
925            [$a, $b, $c, $d, $e, $f] = $baseMatrix;
926            foreach ($corners as $i => [$cx, $cy]) {
927                $corners[$i] = [$a * $cx + $c * $cy + $e, $b * $cx + $d * $cy + $f];
928            }
929        }
930        $form = new FormXObject(
931            new PdfArray([
932                new PdfNumber(min($corners[0][0], $corners[1][0])),
933                new PdfNumber(min($corners[0][1], $corners[1][1])),
934                new PdfNumber(max($corners[0][0], $corners[1][0])),
935                new PdfNumber(max($corners[0][1], $corners[1][1])),
936            ]),
937            $operatorBytes,
938        );
939        // Transparency group with the DeviceGray colour space so the
940        // luminance of the painted pixels becomes the mask's alpha.
941        $group = new GroupAttributes('Transparency');
942        $group->cs = new PdfName('DeviceGray');
943        $form->group = $group;
944        $this->writer->register($form);
945
946        // SVG 2 Â§14.5: `mask-type="alpha"` uses the painted pixels'
947        // alpha channel directly; the default `luminance` (also
948        // covering the old `mask-type` absent case) uses the
949        // luminance of the RGB pixels. PDF maps these to the SoftMask
950        // `/S /Alpha` and `/S /Luminosity` modes.
951        $maskSubtype = strtolower(trim($referent->getAttribute('mask-type') ?? ''));
952        $smask = new SoftMask(
953            $subtypeOverride ?? ($maskSubtype === 'alpha' ? 'Alpha' : 'Luminosity'),
954            new PdfReference($form->objectNumber),
955        );
956        // Backdrop colour black ([0]) so anywhere the mask content
957        // doesn't paint stays hidden â€” matches SVG 2's "outside the
958        // mask region the alpha is 0" semantic.
959        $smask->bc = new PdfArray([new PdfNumber(0)]);
960
961        $gstate = new ExtGState();
962        $gstate->sMask = $smask;
963        $this->writer->register($gstate);
964
965        $name = 'GS_mask_' . $gstate->objectNumber;
966        $resources = $this->page->corePage()->resources;
967        if ($resources !== null) {
968            $resources->extGState[$name] = new PdfReference($gstate->objectNumber);
969        }
970        return $name;
971    }
972
973    /**
974     * Compute the rectangular region that the mask covers, in user
975     * space. SVG 2 Â§14.5.4 defaults:
976     *
977     *  - `maskUnits="objectBoundingBox"` (default): `x=-10%, y=-10%,
978     *    width=120%, height=120%` of the masked element's bounding
979     *    box. The 10% pad on each side is what makes the mask
980     *    naturally reach a hair beyond the painted geometry so
981     *    anti-aliased edges aren't clipped.
982     *  - `maskUnits="userSpaceOnUse"`: SVG defaults to the viewport
983     *    rect (-10% etc. of the viewport), which we don't have direct
984     *    access to here â€” fall back to the masked element's bbox
985     *    (matches 3R+8 behaviour for unset attributes).
986     *
987     * @param array{minX: float, minY: float, width: float, height: float} $elementBbox
988     * @return array{minX: float, minY: float, width: float, height: float}
989     */
990    private static function computeMaskRegion(Mask $mask, array $elementBbox): array
991    {
992        $bboxMode = $mask->maskUnits() === 'objectBoundingBox';
993
994        if ($bboxMode) {
995            $x = $mask->x() ?? -0.1;
996            $y = $mask->y() ?? -0.1;
997            $w = $mask->width() ?? 1.2;
998            $h = $mask->height() ?? 1.2;
999            return [
1000                'minX' => $elementBbox['minX'] + $x * $elementBbox['width'],
1001                'minY' => $elementBbox['minY'] + $y * $elementBbox['height'],
1002                'width' => $w * $elementBbox['width'],
1003                'height' => $h * $elementBbox['height'],
1004            ];
1005        }
1006
1007        return [
1008            'minX' => $mask->x() ?? $elementBbox['minX'],
1009            'minY' => $mask->y() ?? $elementBbox['minY'],
1010            'width' => $mask->width() ?? $elementBbox['width'],
1011            'height' => $mask->height() ?? $elementBbox['height'],
1012        ];
1013    }
1014
1015    /**
1016     * CSS Masking 1 Â§6 â€” `clip-path: <basic-shape> || <geometry-box>` on
1017     * an SVG graphics element. Returns the resolved outline (in the
1018     * element's user space, y down) or null when the element has no such
1019     * clip-path, the shape is one we don't model, or the reference box
1020     * can't be computed.
1021     *
1022     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}|null
1023     */
1024    private function resolveShapeClipPath(Element $element): ?array
1025    {
1026        $raw = $element->clipPathValue();
1027        if ($raw === null) {
1028            return null;
1029        }
1030        $trimmed = trim($raw);
1031        if ($trimmed === '' || $trimmed === 'none' || str_starts_with(strtolower($trimmed), 'url(')) {
1032            return null;
1033        }
1034        try {
1035            $value = (new ValueParser())->parseFromString($trimmed);
1036        } catch (\Throwable) {
1037            return null;
1038        }
1039        // `<basic-shape> || <geometry-box>` parses as a ValueList in either
1040        // order; a bare shape parses as itself. A bare `<geometry-box>`
1041        // clips to that box's edge.
1042        $shape = null;
1043        $refBox = 'border-box';
1044        $geometryBoxes = [
1045            'content-box', 'padding-box', 'border-box', 'margin-box',
1046            'fill-box', 'stroke-box', 'view-box',
1047        ];
1048        if ($value instanceof ValueList) {
1049            foreach ($value->values as $part) {
1050                if ($part instanceof BasicShape) {
1051                    $shape = $part;
1052                } elseif ($part instanceof CssKeyword
1053                    && in_array(strtolower($part->name), $geometryBoxes, true)
1054                ) {
1055                    $refBox = strtolower($part->name);
1056                }
1057            }
1058            if ($shape === null) {
1059                return null;
1060            }
1061        } elseif ($value instanceof BasicShape) {
1062            $shape = $value;
1063        } elseif ($value instanceof CssKeyword
1064            && in_array(strtolower($value->name), $geometryBoxes, true)
1065        ) {
1066            $refBox = strtolower($value->name);
1067        } else {
1068            // Not a shape, not a geometry box â€” an unparseable or unknown
1069            // value. CSS Masking 1 Â§6.1 leaves the element unclipped.
1070            return null;
1071        }
1072        $box = $this->clipReferenceBox($element, $refBox);
1073        if ($box === null || $box['width'] <= 0.0 || $box['height'] <= 0.0) {
1074            return null;
1075        }
1076        if ($shape === null) {
1077            // Bare `<geometry-box>`: the box's own edge is the clip.
1078            return [
1079                'fillRule' => 'nonzero',
1080                'commands' => [
1081                    ['M', $box['minX'], $box['minY']],
1082                    ['L', $box['minX'] + $box['width'], $box['minY']],
1083                    ['L', $box['minX'] + $box['width'], $box['minY'] + $box['height']],
1084                    ['L', $box['minX'], $box['minY'] + $box['height']],
1085                    ['Z'],
1086                ],
1087            ];
1088        }
1089        return BasicShapePath::build(
1090            $shape,
1091            $box['minX'],
1092            $box['minY'],
1093            $box['width'],
1094            $box['height'],
1095        );
1096    }
1097
1098    /**
1099     * CSS Masking 1 Â§6 / CSS Box 3 â€” the `<geometry-box>` an SVG element's
1100     * basic shape is measured against, in the element's user space.
1101     *
1102     * An SVG graphics element has no CSS layout box, so `content-box`,
1103     * `padding-box`, `border-box` and `margin-box` all reduce to
1104     * `fill-box` â€” its object bounding box. `stroke-box` grows that by
1105     * half the stroke width on every side, and `view-box` is the nearest
1106     * SVG viewport, anchored at the user-space origin.
1107     *
1108     * @return array{minX: float, minY: float, width: float, height: float}|null
1109     */
1110    private function clipReferenceBox(Element $element, string $refBox): ?array
1111    {
1112        if ($refBox === 'view-box') {
1113            $viewport = $this->currentViewport();
1114            if ($viewport['w'] <= 0.0 || $viewport['h'] <= 0.0) {
1115                return null;
1116            }
1117            return [
1118                'minX' => 0.0,
1119                'minY' => 0.0,
1120                'width' => $viewport['w'],
1121                'height' => $viewport['h'],
1122            ];
1123        }
1124        return BoundingBox::compute(
1125            $element,
1126            includeStroke: $refBox === 'stroke-box',
1127            viewport: $this->currentViewport(),
1128        );
1129    }
1130
1131    /**
1132     * Emit a resolved basic-shape outline as a PDF clipping path. The
1133     * commands are already in the element's user space, which is the space
1134     * the stream is painting in, so they go through verbatim.
1135     *
1136     * @param array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>} $outline
1137     */
1138    private static function emitShapeClip(array $outline, ContentStream $stream): void
1139    {
1140        foreach ($outline['commands'] as $command) {
1141            switch ($command[0]) {
1142                case 'M':
1143                    $stream->moveTo((float) $command[1], (float) $command[2]);
1144                    break;
1145                case 'L':
1146                    $stream->lineTo((float) $command[1], (float) $command[2]);
1147                    break;
1148                case 'C':
1149                    $stream->curveTo(
1150                        (float) $command[1],
1151                        (float) $command[2],
1152                        (float) $command[3],
1153                        (float) $command[4],
1154                        (float) $command[5],
1155                        (float) $command[6],
1156                    );
1157                    break;
1158                default:
1159                    $stream->closePath();
1160                    break;
1161            }
1162        }
1163        if ($outline['fillRule'] === 'evenodd') {
1164            $stream->clipEvenOdd();
1165        } else {
1166            $stream->clip();
1167        }
1168        $stream->endPath();
1169    }
1170
1171    /**
1172     * Resolve an element's `clip-path="url(#id)"` reference to the
1173     * matching `<clipPath>` element. Returns null when no clip-path is
1174     * set, the reference can't be parsed, or the id doesn't resolve to
1175     * a clipPath â€” each case falls through to the unclipped paint per
1176     * SVG 2's "invalid â†’ no clip" rule.
1177     */
1178    private function resolveClipPath(Element $element): ?ClipPath
1179    {
1180        if ($this->document === null) {
1181            return null;
1182        }
1183        $raw = $element->clipPathValue();
1184        if ($raw === null) {
1185            return null;
1186        }
1187        $trimmed = trim($raw);
1188        if ($trimmed === 'none') {
1189            return null;
1190        }
1191        if (preg_match('/^url\(\s*[\x22\x27]?#([^)\s\x22\x27]+)[\x22\x27]?\s*\)/i', $trimmed, $m) !== 1) {
1192            return null;
1193        }
1194        $referent = $this->document->findByFragment($m[1]);
1195        return $referent instanceof ClipPath ? $referent : null;
1196    }
1197
1198    /**
1199     * Construct the PDF clipping region from the `<clipPath>` element's
1200     * geometry and emit `W`/`W*` + `n` so subsequent painting is
1201     * scoped to it. `clipPathUnits="objectBoundingBox"` is honoured by
1202     * sandwiching the path construction between a bbox-space `cm` and
1203     * its inverse â€” the clip region is "frozen" by `W` in user space,
1204     * so applying the inverse `cm` returns the CTM to its pre-clip
1205     * state without disturbing the established region.
1206     */
1207    private function applyClipPath(ClipPath $clipPath, Element $element, ContentStream $stream): void
1208    {
1209        $useBbox = $clipPath->clipPathUnits() === 'objectBoundingBox';
1210        $bbox = $useBbox
1211            ? BoundingBox::compute($element, viewport: $this->currentViewport())
1212            : null;
1213        if ($useBbox && $bbox === null) {
1214            // bbox required for objectBoundingBox mode but unavailable
1215            // (e.g. `<path>` element with no bbox helper at 3R+3) â€”
1216            // fall back to no clip rather than emitting a broken clip.
1217            return;
1218        }
1219
1220        $this->emitClipRegion(
1221            $clipPath,
1222            $stream,
1223            $bbox === null
1224                ? null
1225                : [$bbox['width'], 0.0, 0.0, $bbox['height'], $bbox['minX'], $bbox['minY']],
1226        );
1227    }
1228
1229    /**
1230     * CSS Masking 1 Â§6.1 â€” emit the clipping region a `<clipPath>`
1231     * describes into `$stream` for a caller OUTSIDE the SVG pipeline
1232     * (the HTML painter's `clip-path: url(#id)`).
1233     *
1234     * The caller owns the coordinate system and the surrounding
1235     * `q` / `Q`: it must already have concatenated the matrix that maps
1236     * the clipPath's user space onto the page. On return the CTM is
1237     * unchanged and the clip region is established, so the caller can
1238     * keep painting in its own space.
1239     *
1240     * `$objectBoundingBox` is the referencing element's bounding box
1241     * `[x, y, width, height]` **in that user space**, used only when the
1242     * clipPath declares `clipPathUnits="objectBoundingBox"`; pass null
1243     * to force `userSpaceOnUse`. `$viewport` resolves percentage lengths
1244     * on the clipPath's children (SVG 2 Â§10.3) â€” for an HTML element
1245     * that is the initial viewport, not any SVG viewport.
1246     *
1247     * @param array{w: float, h: float} $viewport
1248     * @param array{0: float, 1: float, 2: float, 3: float}|null $objectBoundingBox
1249     */
1250    public function emitClipPathRegion(
1251        SvgDocument $document,
1252        ClipPath $clipPath,
1253        ContentStream $stream,
1254        array $viewport,
1255        ?array $objectBoundingBox = null,
1256    ): void {
1257        $useBbox = $clipPath->clipPathUnits() === 'objectBoundingBox';
1258        if ($useBbox && $objectBoundingBox === null) {
1259            return;
1260        }
1261        $previousDocument = $this->document;
1262        $this->document = $document;
1263        $this->viewportStack[] = $viewport;
1264        try {
1265            $this->emitClipRegion(
1266                $clipPath,
1267                $stream,
1268                $useBbox && $objectBoundingBox !== null
1269                    ? [
1270                        $objectBoundingBox[2],
1271                        0.0,
1272                        0.0,
1273                        $objectBoundingBox[3],
1274                        $objectBoundingBox[0],
1275                        $objectBoundingBox[1],
1276                    ]
1277                    : null,
1278            );
1279        } finally {
1280            array_pop($this->viewportStack);
1281            $this->document = $previousDocument;
1282        }
1283    }
1284
1285    /**
1286     * Shared body of {@see applyClipPath} / {@see emitClipPathRegion}:
1287     * build the clipPath children's geometry under `$bboxMatrix` (the
1288     * `objectBoundingBox` reification, null for `userSpaceOnUse`) plus
1289     * the clipPath's own `transform`, then freeze it with `W`/`W*` + `n`.
1290     *
1291     * @param array{float, float, float, float, float, float}|null $bboxMatrix
1292     */
1293    private function emitClipRegion(
1294        ClipPath $clipPath,
1295        ContentStream $stream,
1296        ?array $bboxMatrix,
1297    ): void {
1298        // CSS Masking 1 Â§6.1 / SVG 2 Â§14.4 â€” `clip-path` ON the
1299        // `<clipPath>` element itself further restricts the region it
1300        // describes: the effective clip is the INTERSECTION of this
1301        // clipPath's child geometry with the region its own reference
1302        // describes. PDF clipping is already intersective (successive
1303        // `W`/`n` pairs narrow the region), so emitting the referenced
1304        // region first and this clipPath's geometry second composes
1305        // correctly without any explicit path intersection.
1306        //
1307        // The nested region is emitted BEFORE this clipPath's bbox /
1308        // transform `cm`s, so it resolves in the user space of the
1309        // element that referenced us â€” which is where a nested
1310        // clipPath's own coordinates live. `$bboxMatrix` is threaded
1311        // through unchanged so an `objectBoundingBox` nested clipPath
1312        // reifies against the same referencing element's bbox.
1313        //
1314        // `$clipPathStack` breaks reference cycles (a clipPath that
1315        // reaches itself); per SVG 2's "invalid â†’ no clip" rule the
1316        // self-reference is dropped rather than failing the element.
1317        $nested = $this->resolveClipPath($clipPath);
1318        if ($nested !== null && $nested !== $clipPath && !in_array($nested, $this->clipPathStack, true)) {
1319            $this->clipPathStack[] = $clipPath;
1320            try {
1321                $this->emitClipRegion($nested, $stream, $bboxMatrix);
1322            } finally {
1323                array_pop($this->clipPathStack);
1324            }
1325        }
1326
1327        $clipTransform = $clipPath->transform()?->toMatrix();
1328
1329        // Apply outer→inner: bbox first, then the clipPath's own
1330        // `transform`. Children paint in their authored coordinate
1331        // system; the cm composition reifies that into user space
1332        // for the W operator to capture.
1333        if ($bboxMatrix !== null) {
1334            $stream->concatMatrix(...$bboxMatrix);
1335        }
1336        if ($clipTransform !== null) {
1337            $stream->concatMatrix(...$clipTransform);
1338        }
1339        $before = count($stream->getOperators());
1340        foreach ($clipPath->children as $child) {
1341            if ($child instanceof Element) {
1342                $this->emitElementPath($child, $stream);
1343            }
1344        }
1345        if (count($stream->getOperators()) === $before) {
1346            // SVG 2 Â§14.4.1 / CSS Masking 1 Â§6.1 â€” a `<clipPath>` whose
1347            // children contribute no geometry (it is empty, or holds only
1348            // non-area-enclosing elements) clips EVERYTHING away: the
1349            // referencing element disappears. `W` with no current path is
1350            // undefined in PDF and consumers treat it as a no-op, which
1351            // would leave the element fully visible instead â€” so emit a
1352            // zero-extent rectangle to make the empty region explicit.
1353            $stream->rectangle(0.0, 0.0, 0.0, 0.0);
1354        }
1355        $rule = self::resolveClipRule($clipPath);
1356        if ($rule === 'evenodd') {
1357            $stream->clipEvenOdd();
1358        } else {
1359            $stream->clip();
1360        }
1361        $stream->endPath();
1362        // Undo the cms in reverse order so the CTM is back to the
1363        // pre-clip user space. This has to happen AFTER `W`/`n`: ISO
1364        // 32000-2 Â§8.2 admits only path-construction operators inside a
1365        // path object, so a `cm` between the path and its painting
1366        // operator makes the content stream malformed and consumers drop
1367        // the clip (an `objectBoundingBox` clipPath then either vanished
1368        // or failed to clip at all). Reordering is safe â€” the region
1369        // `W` establishes already lives in device space, so it survives
1370        // the CTM changes that follow.
1371        if ($clipTransform !== null) {
1372            $stream->concatMatrix(...self::inverseAffine($clipTransform));
1373        }
1374        if ($bboxMatrix !== null) {
1375            $stream->concatMatrix(...self::inverseAffine($bboxMatrix));
1376        }
1377    }
1378
1379    /**
1380     * Inverse of a 3×2 affine matrix in PDF `[a b c d e f]` order. The
1381     * full 3×3 affine has bottom row `[0 0 1]` so we only need the six
1382     * SVG/PDF entries. Singular matrices (det == 0) shouldn't occur
1383     * for any transform we generate; if one does, returning identity
1384     * makes the worst-case behaviour "no inverse applied" rather than
1385     * a divide-by-zero.
1386     *
1387     * The top of {@see matrixStack} â€” the cumulative SVG→page transform in
1388     * effect for the element currently being painted.
1389     *
1390     * @return array{float, float, float, float, float, float}
1391     */
1392    private function currentMatrix(): array
1393    {
1394        return $this->matrixStack[count($this->matrixStack) - 1];
1395    }
1396
1397    /**
1398     * Concatenate `$m` onto the current cumulative matrix (outer Ã— inner, the
1399     * same order a `cm` operator composes onto the CTM) and push the result.
1400     * Callers must {@see popMatrix} when the corresponding graphics-state save
1401     * is restored.
1402     *
1403     * @param array{float, float, float, float, float, float} $m
1404     */
1405    private function pushMatrix(array $m): void
1406    {
1407        $this->matrixStack[] = self::multiplyAffine($this->currentMatrix(), $m);
1408    }
1409
1410    private function popMatrix(): void
1411    {
1412        if (count($this->matrixStack) > 1) {
1413            array_pop($this->matrixStack);
1414        }
1415    }
1416
1417    /**
1418     * Affine composition `m1 Ã— m2` (m1 applied after m2), matching the PDF
1419     * `cm` convention and {@see \Phpdftk\Svg\Value\Transform}.
1420     *
1421     * @param array{float, float, float, float, float, float} $m1
1422     * @param array{float, float, float, float, float, float} $m2
1423     * @return array{float, float, float, float, float, float}
1424     */
1425    private static function multiplyAffine(array $m1, array $m2): array
1426    {
1427        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
1428        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
1429
1430        return [
1431            $a1 * $a2 + $c1 * $b2,
1432            $b1 * $a2 + $d1 * $b2,
1433            $a1 * $c2 + $c1 * $d2,
1434            $b1 * $c2 + $d1 * $d2,
1435            $a1 * $e2 + $c1 * $f2 + $e1,
1436            $b1 * $e2 + $d1 * $f2 + $f1,
1437        ];
1438    }
1439
1440    /**
1441     * @param array{float, float, float, float, float, float} $m
1442     * @return array{float, float, float, float, float, float}
1443     */
1444    private static function inverseAffine(array $m): array
1445    {
1446        [$a, $b, $c, $d, $e, $f] = $m;
1447        $det = $a * $d - $b * $c;
1448        if (abs($det) < 1.0e-12) {
1449            return [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1450        }
1451        return [
1452            $d / $det,
1453            -$b / $det,
1454            -$c / $det,
1455            $a / $det,
1456            ($c * $f - $d * $e) / $det,
1457            ($b * $e - $a * $f) / $det,
1458        ];
1459    }
1460
1461    /**
1462     * `clip-rule` per SVG 2 Â§14.4.4. The attribute lives on the
1463     * `<clipPath>` element itself at 3R+3; per-child clip-rule support
1464     * (the spec allows overriding on individual children) lands later.
1465     *
1466     * @return 'nonzero'|'evenodd'
1467     */
1468    private static function resolveClipRule(ClipPath $clipPath): string
1469    {
1470        $raw = $clipPath->getAttribute('clip-rule');
1471        return strtolower(trim($raw ?? '')) === 'evenodd' ? 'evenodd' : 'nonzero';
1472    }
1473
1474    /**
1475     * Emit just the geometry of an element â€” the path operators that
1476     * would have ended up before the fill/stroke op. Used by
1477     * `applyClipPath` to assemble a clipping region without painting.
1478     */
1479    private function emitElementPath(Element $element, ContentStream $stream): void
1480    {
1481        match (true) {
1482            $element instanceof Rect => $this->emitRectPath($element, $stream),
1483            $element instanceof Circle => $this->emitCirclePath($element, $stream),
1484            $element instanceof Ellipse => $this->emitEllipsePathFor($element, $stream),
1485            $element instanceof Polyline => $this->emitPolylinePath($element, $stream),
1486            $element instanceof Polygon => $this->emitPolygonPath($element, $stream),
1487            $element instanceof Path => $this->emitPathPath($element, $stream),
1488            // `<line>` and other non-area-enclosing children don't
1489            // contribute to a clip region per SVG 2 Â§14.4.1.
1490            default => null,
1491        };
1492    }
1493
1494    /** @return bool whether any geometry was emitted. */
1495    private function emitRectPath(Rect $rect, ContentStream $stream): bool
1496    {
1497        $vp = $this->currentViewport();
1498        $x = $this->geometryLength($rect, 'x', $vp['w']) ?? 0.0;
1499        $y = $this->geometryLength($rect, 'y', $vp['h']) ?? 0.0;
1500        // A negative `width` / `height` is invalid, so the declaration
1501        // is ignored and the initial value 0 stands â€” nothing paints,
1502        // which the `> 0.0` guard already expresses.
1503        $w = $this->geometryLength($rect, 'width', $vp['w']) ?? 0.0;
1504        $h = $this->geometryLength($rect, 'height', $vp['h']) ?? 0.0;
1505        if ($w <= 0.0 || $h <= 0.0) {
1506            return false;
1507        }
1508        [$rx, $ry] = $this->rectCornerRadii($rect, $w, $h, $vp);
1509        if ($rx <= 0.0 || $ry <= 0.0) {
1510            $stream->rectangle($x, $y, $w, $h);
1511            return true;
1512        }
1513        self::emitRoundedRectPath($stream, $x, $y, $w, $h, $rx, $ry);
1514        return true;
1515    }
1516
1517    /**
1518     * SVG 2 Â§10.4 â€” the used `rx` / `ry` of a `<rect>`.
1519     *
1520     * Both radii are `auto` by default, and an `auto` radius mirrors
1521     * the other one, so `rx="8"` alone rounds both axes by 8. A
1522     * NEGATIVE radius is invalid: the declaration is ignored, which
1523     * leaves `auto` in force. Percentages are Â§10.1 geometry
1524     * percentages â€” `rx` against the viewport WIDTH, `ry` against its
1525     * HEIGHT, never against the rectangle's own box. Finally each
1526     * radius is clamped to half its side, so an over-large radius
1527     * degenerates into a stadium instead of self-intersecting.
1528     *
1529     * @param array{w: float, h: float} $vp
1530     * @return array{float, float}
1531     */
1532    private function rectCornerRadii(Rect $rect, float $w, float $h, array $vp): array
1533    {
1534        $rx = $this->geometryLength($rect, 'rx', $vp['w']);
1535        $ry = $this->geometryLength($rect, 'ry', $vp['h']);
1536        if ($rx !== null && $rx < 0.0) {
1537            $rx = null;
1538        }
1539        if ($ry !== null && $ry < 0.0) {
1540            $ry = null;
1541        }
1542        $rx ??= $ry;
1543        $ry ??= $rx;
1544        if ($rx === null || $ry === null) {
1545            return [0.0, 0.0];
1546        }
1547        return [min($rx, $w / 2.0), min($ry, $h / 2.0)];
1548    }
1549
1550    /**
1551     * A rounded rectangle as four straight edges joined by four
1552     * quarter-ellipse corners.
1553     *
1554     * PDF's `re` operator only draws square corners, so this is the
1555     * lowering. Corner arcs reuse the same `KAPPA` cubic approximation
1556     * `emitEllipsePath()` uses, which keeps a fully-rounded rect
1557     * (`rx = w/2`, `ry = h/2`) pixel-consistent with the `<ellipse>`
1558     * that describes the same shape.
1559     */
1560    private static function emitRoundedRectPath(
1561        ContentStream $stream,
1562        float $x,
1563        float $y,
1564        float $w,
1565        float $h,
1566        float $rx,
1567        float $ry,
1568    ): void {
1569        $kx = $rx * self::KAPPA;
1570        $ky = $ry * self::KAPPA;
1571        $right = $x + $w;
1572        $top = $y + $h;
1573        $stream
1574            ->moveTo($x + $rx, $y)
1575            ->lineTo($right - $rx, $y)
1576            ->curveTo($right - $rx + $kx, $y, $right, $y + $ry - $ky, $right, $y + $ry)
1577            ->lineTo($right, $top - $ry)
1578            ->curveTo($right, $top - $ry + $ky, $right - $rx + $kx, $top, $right - $rx, $top)
1579            ->lineTo($x + $rx, $top)
1580            ->curveTo($x + $rx - $kx, $top, $x, $top - $ry + $ky, $x, $top - $ry)
1581            ->lineTo($x, $y + $ry)
1582            ->curveTo($x, $y + $ry - $ky, $x + $rx - $kx, $y, $x + $rx, $y)
1583            ->closePath();
1584    }
1585
1586    /**
1587     * The 1%-basis for a relative length unit: the supplied viewport
1588     * extent for `%`, and the document's OWN outermost viewport for the
1589     * `v*` units. An embedded resource's outermost viewport is the
1590     * `<image>` box it was given, which is exactly what makes `50vw`
1591     * inside an `<image href="…svg">` mean half that box.
1592     */
1593    /**
1594     * Evaluate a CSS math function (`calc()`, `min()`, `max()`,
1595     * `clamp()`, â€¦) that appeared in a geometry attribute, in CSS px.
1596     *
1597     * Percentages resolve against `$basis`, which is the SAME per-axis
1598     * basis the plain-length path uses â€” the viewport width for `x` /
1599     * `width`, its height for `y` / `height`, and the normalized
1600     * diagonal for a circle's `r` (§10.1). `em` resolves against the
1601     * element's own computed `font-size`, which the cascade projection
1602     * has already put in reach.
1603     *
1604     * Returns null when the expression can't be reduced to a number â€”
1605     * an unsupported function, or a percentage with no basis. That
1606     * reads as an invalid declaration, so the caller falls back to the
1607     * property's initial value rather than to a half-evaluated one.
1608     */
1609    private function evaluateMathFunction(string $raw, Element $element, float $basis): ?float
1610    {
1611        $parsed = (new ValueParser())->parseFromString($raw);
1612        if (!$parsed instanceof Calc) {
1613            return null;
1614        }
1615        $root = $this->rootViewport ?? $this->currentViewport();
1616        $fontSize = $element->fontSize() ?? 16.0;
1617        $pixels = CalcEvaluator::evaluate($parsed, new LengthContext(
1618            parentFontSize: $fontSize,
1619            currentFontSize: $fontSize,
1620            viewportWidth: $root['w'],
1621            viewportHeight: $root['h'],
1622            percentageBasis: $basis,
1623        ));
1624        return is_nan($pixels) || is_infinite($pixels) ? null : $pixels;
1625    }
1626
1627    private function relativeLengthBasis(string $unit, float $percentBasis): float
1628    {
1629        if ($unit === '%') {
1630            return $percentBasis;
1631        }
1632        $root = $this->rootViewport ?? $this->currentViewport();
1633        return match ($unit) {
1634            'vw' => $root['w'],
1635            'vh' => $root['h'],
1636            'vmin' => min($root['w'], $root['h']),
1637            'vmax' => max($root['w'], $root['h']),
1638            default => $percentBasis,
1639        };
1640    }
1641
1642    /**
1643     * Width / height of the current viewport in viewBox units. Falls back
1644     * to the root SVG width/height attributes when no `viewBox` is set, and
1645     * to zero when neither is available â€” matching SVG 2's "no useful
1646     * viewport" outcome (percentages collapse to 0). Nested `<svg>` /
1647     * `<symbol>` viewports land later; this picks the document root, which
1648     * is what `background-image: url(...svg)` paints into.
1649     *
1650     * @return array{w: float, h: float}
1651     */
1652    private function currentViewport(): array
1653    {
1654        // Innermost nested `<svg>` viewport wins for percentage resolution.
1655        if ($this->viewportStack !== []) {
1656            return $this->viewportStack[count($this->viewportStack) - 1];
1657        }
1658        if ($this->document === null) {
1659            return ['w' => 0.0, 'h' => 0.0];
1660        }
1661        $viewBox = $this->document->viewBox();
1662        if ($viewBox !== null) {
1663            return ['w' => $viewBox[2], 'h' => $viewBox[3]];
1664        }
1665        $w = self::parseLengthPrefixForViewport($this->document->widthAttribute());
1666        $h = self::parseLengthPrefixForViewport($this->document->heightAttribute());
1667        if ($w !== null && $h !== null) {
1668            // Mirror SvgRenderer::resolveSourceRect's near-integral
1669            // snap (#143 / crbug.com/1392140): an SVG with
1670            // `width="99.99999"` resolves child `width="100%"` to
1671            // 100, matching the integer-snap browsers apply to
1672            // <img>-embedded SVG intrinsic dimensions. Without this,
1673            // the inner viewport stays at 99.99999 even after the
1674            // outer rendering box rounds to 100, so a `width="100%"`
1675            // child paints 0.00001 short of the box's right edge.
1676            return ['w' => (float) round($w), 'h' => (float) round($h)];
1677        }
1678        // Fall back to the caller's effective viewport (the destination
1679        // rect from `SvgRenderer::draw`) when document attributes alone
1680        // can't give a useful viewport â€” e.g. an SVG with only a
1681        // percentage `width` and no `height` or `viewBox`.
1682        if ($this->effectiveViewport !== null) {
1683            return $this->effectiveViewport;
1684        }
1685        return ['w' => $w ?? 0.0, 'h' => $h ?? 0.0];
1686    }
1687
1688    private static function parseLengthPrefixForViewport(?string $raw): ?float
1689    {
1690        if ($raw === null) {
1691            return null;
1692        }
1693        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*([%a-zA-Z]*)/', $raw, $m) !== 1) {
1694            return null;
1695        }
1696        // Percentage attributes carry no intrinsic viewport extent â€” they
1697        // resolve against the caller-supplied effective viewport (CSS
1698        // Images 3 Â§5.2). Reject so the fallback path picks up the
1699        // dst-derived viewport instead of mis-anchoring to the bare
1700        // percentage value.
1701        if ($m[2] === '%') {
1702            return null;
1703        }
1704        return (float) $m[1];
1705    }
1706
1707    /** @return bool whether any geometry was emitted. */
1708    private function emitCirclePath(Circle $circle, ContentStream $stream): bool
1709    {
1710        $vp = $this->currentViewport();
1711        $cx = $this->geometryLength($circle, 'cx', $vp['w']) ?? 0.0;
1712        $cy = $this->geometryLength($circle, 'cy', $vp['h']) ?? 0.0;
1713        // SVG 2 Â§10.1: a percentage `r` resolves against the NORMALIZED
1714        // DIAGONAL, not either axis. A negative `r` is invalid â€” the
1715        // declaration is ignored and the initial 0 stands, so nothing
1716        // paints.
1717        $r = $this->geometryLength($circle, 'r', self::normalizedDiagonal($vp)) ?? 0.0;
1718        if ($r <= 0.0) {
1719            return false;
1720        }
1721        $this->emitEllipsePath($stream, $cx, $cy, $r, $r);
1722        return true;
1723    }
1724
1725    /** @return bool whether any geometry was emitted. */
1726    private function emitEllipsePathFor(Ellipse $ellipse, ContentStream $stream): bool
1727    {
1728        $vp = $this->currentViewport();
1729        $cx = $this->geometryLength($ellipse, 'cx', $vp['w']) ?? 0.0;
1730        $cy = $this->geometryLength($ellipse, 'cy', $vp['h']) ?? 0.0;
1731        $rx = $this->geometryLength($ellipse, 'rx', $vp['w']);
1732        $ry = $this->geometryLength($ellipse, 'ry', $vp['h']);
1733        // SVG 2 Â§10.1 â€” `rx` / `ry` initial value is `auto`, and a
1734        // NEGATIVE radius is invalid: the declaration is ignored, which
1735        // leaves `auto` in force rather than collapsing the shape. Each
1736        // `auto` radius then mirrors the other, so
1737        // `<ellipse rx="-65" ry="65">` paints a circle of r=65.
1738        if ($rx !== null && $rx < 0.0) {
1739            $rx = null;
1740        }
1741        if ($ry !== null && $ry < 0.0) {
1742            $ry = null;
1743        }
1744        $rx ??= $ry;
1745        $ry ??= $rx;
1746        if ($rx === null || $ry === null || $rx <= 0.0 || $ry <= 0.0) {
1747            return false;
1748        }
1749        $this->emitEllipsePath($stream, $cx, $cy, $rx, $ry);
1750        return true;
1751    }
1752
1753    /**
1754     * SVG 2 Â§10.1 â€” resolve one geometry property for `$element`.
1755     *
1756     * The value may be written as a presentation attribute or come from
1757     * the cascade (which {@see SvgCascadeProjector} projects into
1758     * `style`); {@see Element::geometryValue()} reads both. `$basis` is
1759     * the 1%-basis for the property's axis: viewport width for
1760     * `x`/`width`/`cx`/`rx`, height for `y`/`height`/`cy`/`ry`, and the
1761     * normalized diagonal for `r`.
1762     *
1763     * Returns null when the property is absent or is not a length (an
1764     * `auto` keyword, a `calc()` we don't evaluate here), so callers can
1765     * apply that property's own initial-value rule instead of guessing.
1766     */
1767    private function geometryLength(Element $element, string $property, float $basis): ?float
1768    {
1769        $raw = $element->geometryValue($property);
1770        if ($raw === null || trim($raw) === '') {
1771            return null;
1772        }
1773        // SVG 2 Â§6.7 â€” a geometry presentation attribute is parsed as a
1774        // CSS value, so the CSS math functions are legal in it. They
1775        // can't match the numeric fast path below (they don't start
1776        // with a digit), so route them through the CSS evaluator first.
1777        if (preg_match('/^\s*(?:calc|min|max|clamp|abs|sign|hypot)\s*\(/i', $raw) === 1) {
1778            return $this->evaluateMathFunction($raw, $element, $basis);
1779        }
1780        // Prefix match, not anchored: mirrors the leniency the shape
1781        // accessors have always had for trailing junk (`"10 20"`).
1782        if (preg_match(
1783            '/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)/',
1784            $raw,
1785            $m,
1786        ) !== 1) {
1787            return null;
1788        }
1789        $value = (float) $m[1];
1790        $unit = strtolower($m[2]);
1791        if ($unit === '') {
1792            return $value;
1793        }
1794        if ($unit === '%' || $unit === 'vw' || $unit === 'vh' || $unit === 'vmin' || $unit === 'vmax') {
1795            return $value / 100.0 * $this->relativeLengthBasis($unit, $basis);
1796        }
1797        return $value * Element::absoluteUnitScale($unit);
1798    }
1799
1800    /**
1801     * SVG 2 Â§10.1's "normalized diagonal" â€” `sqrt(w² + h²) / sqrt(2)`,
1802     * the 1%-basis for properties that are not tied to one axis (`r`).
1803     *
1804     * @param array{w: float, h: float} $viewport
1805     */
1806    private static function normalizedDiagonal(array $viewport): float
1807    {
1808        return sqrt($viewport['w'] ** 2 + $viewport['h'] ** 2) / M_SQRT2;
1809    }
1810
1811    private function emitPolylinePath(Polyline $polyline, ContentStream $stream): void
1812    {
1813        $points = $polyline->points();
1814        if (count($points) >= 2) {
1815            $this->emitPolyPath($stream, $points, closed: false);
1816        }
1817    }
1818
1819    private function emitPolygonPath(Polygon $polygon, ContentStream $stream): void
1820    {
1821        $points = $polygon->points();
1822        if (count($points) >= 3) {
1823            $this->emitPolyPath($stream, $points, closed: true);
1824        }
1825    }
1826
1827    private function emitPathPath(Path $path, ContentStream $stream): void
1828    {
1829        $commands = $path->d()->commands;
1830        if ($commands === []) {
1831            return;
1832        }
1833        $state = new PathPainterState();
1834        foreach ($commands as $command) {
1835            $this->emitPathCommand($command, $stream, $state);
1836        }
1837    }
1838
1839    /**
1840     * Register (or reuse) the `ExtGState` resource encoding this
1841     * element's effective opacity. Returns the resource name or null
1842     * when no `gs` op is needed (no Page reference, or every opacity
1843     * channel is already â‰¥ 0.999).
1844     */
1845    private function resolveOpacityState(Element $element): ?string
1846    {
1847        if ($this->page === null) {
1848            return null;
1849        }
1850        $opacity = $element->opacity() ?? 1.0;
1851        // SVG 2 Â§13.2 vs Â§15.2 â€” `fill-opacity` / `stroke-opacity`
1852        // modulate the element's OWN paint operations and reach
1853        // descendants by INHERITANCE, which the cascade projection
1854        // already carries. `opacity` is the group property that
1855        // composites a subtree as a unit.
1856        //
1857        // Folding all three into one ExtGState made a container's
1858        // fill-opacity stick to every descendant, and a child
1859        // declaring `fill-opacity: 1` had nothing to emit (its value
1860        // matched the default) so it could not escape. A container
1861        // therefore contributes only its group opacity here.
1862        $paints = self::paintsItsOwnGeometry($element);
1863        $fillOpacity = ($paints ? $element->fillOpacity() ?? 1.0 : 1.0) * $opacity;
1864        $strokeOpacity = ($paints ? $element->strokeOpacity() ?? 1.0 : 1.0) * $opacity;
1865        if ($fillOpacity >= 0.999 && $strokeOpacity >= 0.999) {
1866            return null;
1867        }
1868        return $this->page->ensureOpacityState($strokeOpacity, $fillOpacity);
1869    }
1870
1871    /**
1872     * Whether the element emits paint operations of its own, as
1873     * opposed to being a container that only groups other elements.
1874     *
1875     * Mirrors the arms of {@see dispatchElement} that reach
1876     * `applyFillAndStroke`: only those elements have a fill or stroke
1877     * to modulate.
1878     */
1879    private static function paintsItsOwnGeometry(Element $element): bool
1880    {
1881        return $element instanceof Rect
1882            || $element instanceof Circle
1883            || $element instanceof Ellipse
1884            || $element instanceof Line
1885            || $element instanceof Polyline
1886            || $element instanceof Polygon
1887            || $element instanceof Path
1888            || $element instanceof TextElement
1889            || $element instanceof SvgImage;
1890    }
1891
1892    /**
1893     * Whether the element carries any stroke parameter that would
1894     * leak past a sibling shape if emitted inline. Used to decide
1895     * whether to wrap the element's painting in `q`/`Q`.
1896     */
1897    /**
1898     * SVG 2 Â§13.2 / Â§13.6 â€” the line width to emit, in the element's
1899     * own user space.
1900     *
1901     * Normally that is `stroke-width` verbatim. Under
1902     * `vector-effect: non-scaling-stroke` the stroke is calculated in
1903     * the host coordinate space instead, so its on-screen thickness
1904     * must not depend on the transforms between here and the root:
1905     * dividing by the scale those transforms apply is what cancels
1906     * them out, and is the approach the harness manifest already named
1907     * for this substrate.
1908     *
1909     * Returns null when there is nothing to emit, so a shape with no
1910     * `stroke-width` and no vector effect still costs no operator.
1911     */
1912    private function usedStrokeWidth(Element $element): ?float
1913    {
1914        $width = $element->strokeWidth();
1915        $scale = $this->nonScalingStrokeScale($element);
1916        if ($scale === null) {
1917            return $width;
1918        }
1919        // The initial `stroke-width` is 1 (§13.2), and it scales too.
1920        return ($width ?? 1.0) / $scale;
1921    }
1922
1923    /**
1924     * The factor `non-scaling-stroke` has to divide out, or null when
1925     * the element does not ask for the effect.
1926     *
1927     * `sqrt(|det|)` is the uniform scale the current matrix applies;
1928     * for a rotation it is 1 and for a flip it is 1, both correct. A
1929     * NON-uniform scale or a shear has no single such factor â€” the
1930     * spec wants the stroke outline computed in host space and mapped
1931     * back, which a PDF line width cannot express â€” so the geometric
1932     * mean of the two axis scales stands in for it. A degenerate
1933     * (zero-determinant) matrix collapses the geometry to nothing, so
1934     * the effect is skipped rather than dividing by zero.
1935     */
1936    private function nonScalingStrokeScale(Element $element): ?float
1937    {
1938        if ($element->vectorEffect() !== 'non-scaling-stroke') {
1939            return null;
1940        }
1941        [$a, $b, $c, $d] = $this->currentMatrix();
1942        $scale = sqrt(abs($a * $d - $b * $c));
1943        return $scale > 1.0e-9 ? $scale : null;
1944    }
1945
1946    private function needsStrokeParams(Element $element): bool
1947    {
1948        if ($element->stroke() === null || $element->stroke() instanceof None_) {
1949            return false;
1950        }
1951        if ($element->strokeWidth() !== null) {
1952            return true;
1953        }
1954        // SVG 2 Â§13.6 â€” `non-scaling-stroke` rewrites the line width
1955        // even when the author declared none, because the default 1
1956        // still has to be divided by the transform scale.
1957        if ($element->vectorEffect() === 'non-scaling-stroke') {
1958            return true;
1959        }
1960        if ($element->strokeLinecap() !== null) {
1961            return true;
1962        }
1963        if ($element->strokeLinejoin() !== null) {
1964            return true;
1965        }
1966        if ($element->strokeMiterlimit() !== null) {
1967            return true;
1968        }
1969        if ($element->strokeDasharray() !== []) {
1970            return true;
1971        }
1972        if ($element->strokeDashoffset() !== null) {
1973            return true;
1974        }
1975        return false;
1976    }
1977
1978    private function applyStrokeParams(Element $element, ContentStream $stream): void
1979    {
1980        $width = $this->usedStrokeWidth($element);
1981        if ($width !== null) {
1982            $stream->setLineWidth($width);
1983        }
1984        $cap = $element->strokeLinecap();
1985        if ($cap !== null) {
1986            $stream->setLineCap(match ($cap) {
1987                'round' => 1,
1988                'square' => 2,
1989                default => 0, // butt
1990            });
1991        }
1992        $join = $element->strokeLinejoin();
1993        if ($join !== null) {
1994            $stream->setLineJoin(match ($join) {
1995                'round' => 1,
1996                'bevel' => 2,
1997                default => 0, // miter / miter-clip / arcs all fall back to PDF's miter
1998            });
1999        }
2000        $miterLimit = $element->strokeMiterlimit();
2001        if ($miterLimit !== null) {
2002            $stream->setMiterLimit($miterLimit);
2003        }
2004        $dash = $element->strokeDasharray();
2005        if ($dash !== []) {
2006            // SVG 2 Â§9.6 â€” `pathLength` recalibrates every
2007            // distance-along-the-path quantity, so the dash pattern is
2008            // authored in the declared units and scaled here.
2009            $scale = $this->pathLengthScale($element);
2010            if (is_infinite($scale)) {
2011                // `pathLength="0"` is a scaling factor of infinity: the
2012                // first dash covers the whole path, which is a solid
2013                // stroke. Emitting no `d` operator IS that stroke.
2014                return;
2015            }
2016            $offset = (int) round(($element->strokeDashoffset() ?? 0.0) * $scale);
2017            $stream->setDashPattern(
2018                array_map(static fn(float $d): float => $d * $scale, $dash),
2019                $offset,
2020            );
2021        }
2022    }
2023
2024    /**
2025     * SVG 2 Â§9.6 â€” the factor that converts a distance expressed in
2026     * the author's declared `pathLength` units into user units.
2027     *
2028     * `1.0` when the element declares no `pathLength`, or when we
2029     * can't measure its geometry (an unmeasurable element must not
2030     * silently rescale its dashes). `INF` when the author declared
2031     * zero, which the spec defines as a scaling factor of infinity.
2032     */
2033    private function pathLengthScale(Element $element): float
2034    {
2035        $declared = $element->pathLength();
2036        if ($declared === null) {
2037            return 1.0;
2038        }
2039        $geometric = $this->geometricPathLength($element);
2040        if ($geometric === null || $geometric <= 0.0) {
2041            return 1.0;
2042        }
2043        if ($declared <= 0.0) {
2044            return INF;
2045        }
2046        return $geometric / $declared;
2047    }
2048
2049    /**
2050     * The user agent's own computation of an element's path length, in
2051     * user units â€” the numerator of the `pathLength` scaling factor.
2052     *
2053     * Geometry comes from the same resolvers the painter emits from, so
2054     * a CSS-declared `r` (SVG 2 Â§10.1) measures the circle that
2055     * actually paints. Shapes measure ANALYTICALLY rather than from the
2056     * Bézier approximation the painter lowers them to: WPT's
2057     * `pathLength` reftests pair a shape against an equivalent
2058     * hand-written `<path>`, and the two only agree on the true
2059     * geometry.
2060     *
2061     * Returns null for elements that have no path (text, groups,
2062     * images) â€” `pathLength` has no meaning there.
2063     */
2064    private function geometricPathLength(Element $element): ?float
2065    {
2066        $vp = $this->currentViewport();
2067        if ($element instanceof Path) {
2068            return PathLengthMeasure::ofPathData($element->d());
2069        }
2070        if ($element instanceof Rect) {
2071            // The painter emits a plain `re`, so the perimeter is the
2072            // rectangle's, with no rounded-corner correction to make.
2073            $w = $this->geometryLength($element, 'width', $vp['w']) ?? 0.0;
2074            $h = $this->geometryLength($element, 'height', $vp['h']) ?? 0.0;
2075            if ($w <= 0.0 || $h <= 0.0) {
2076                return null;
2077            }
2078            return 2.0 * ($w + $h);
2079        }
2080        if ($element instanceof Circle) {
2081            $r = $this->geometryLength($element, 'r', self::normalizedDiagonal($vp)) ?? 0.0;
2082            return $r > 0.0 ? 2.0 * M_PI * $r : null;
2083        }
2084        if ($element instanceof Ellipse) {
2085            $rx = $this->geometryLength($element, 'rx', $vp['w']);
2086            $ry = $this->geometryLength($element, 'ry', $vp['h']);
2087            if ($rx !== null && $rx < 0.0) {
2088                $rx = null;
2089            }
2090            if ($ry !== null && $ry < 0.0) {
2091                $ry = null;
2092            }
2093            $rx ??= $ry;
2094            $ry ??= $rx;
2095            if ($rx === null || $ry === null || $rx <= 0.0 || $ry <= 0.0) {
2096                return null;
2097            }
2098            return self::ellipsePerimeter($rx, $ry);
2099        }
2100        if ($element instanceof Line) {
2101            return hypot($element->x2() - $element->x1(), $element->y2() - $element->y1());
2102        }
2103        if ($element instanceof Polyline) {
2104            return self::polylineLength($element->points(), closed: false);
2105        }
2106        if ($element instanceof Polygon) {
2107            return self::polylineLength($element->points(), closed: true);
2108        }
2109        return null;
2110    }
2111
2112    /**
2113     * Ramanujan's second approximation to the ellipse perimeter â€”
2114     * relative error below 1e-9 for every eccentricity an SVG author
2115     * can write, and exact for the circle.
2116     */
2117    private static function ellipsePerimeter(float $rx, float $ry): float
2118    {
2119        $h = (($rx - $ry) ** 2) / (($rx + $ry) ** 2);
2120        return M_PI * ($rx + $ry)
2121            * (1.0 + (3.0 * $h) / (10.0 + sqrt(4.0 - 3.0 * $h)));
2122    }
2123
2124    /**
2125     * @param list<array{float, float}> $points
2126     */
2127    private static function polylineLength(array $points, bool $closed): ?float
2128    {
2129        $count = count($points);
2130        if ($count < 2) {
2131            return null;
2132        }
2133        $total = 0.0;
2134        for ($i = 1; $i < $count; $i++) {
2135            $total += hypot(
2136                $points[$i][0] - $points[$i - 1][0],
2137                $points[$i][1] - $points[$i - 1][1],
2138            );
2139        }
2140        if ($closed) {
2141            $total += hypot(
2142                $points[0][0] - $points[$count - 1][0],
2143                $points[0][1] - $points[$count - 1][1],
2144            );
2145        }
2146        return $total > 0.0 ? $total : null;
2147    }
2148
2149    private function dispatchElement(Element $element, ContentStream $stream): void
2150    {
2151        match (true) {
2152            $element instanceof Rect => $this->paintRect($element, $stream),
2153            $element instanceof Circle => $this->paintCircle($element, $stream),
2154            $element instanceof Ellipse => $this->paintEllipse($element, $stream),
2155            $element instanceof Line => $this->paintLine($element, $stream),
2156            $element instanceof Polyline => $this->paintPolyline($element, $stream),
2157            $element instanceof Polygon => $this->paintPolygon($element, $stream),
2158            $element instanceof Path => $this->paintPath($element, $stream),
2159            $element instanceof TextElement => $this->paintTextElement($element, $stream),
2160            $element instanceof \Phpdftk\Svg\NestedSvg => $this->paintNestedSvg($element, $stream),
2161            $element instanceof Use_ => $this->paintUse($element, $stream),
2162            $element instanceof SvgImage => $this->paintImage($element, $stream),
2163            // `<defs>` and `<symbol>` are referenceable containers: they
2164            // never paint themselves at the document level. `<use>`
2165            // expands `<defs>` / `<symbol>` referents; `clip-path` on
2166            // a painted element pulls in a `<clipPath>`; `mask` pulls
2167            // in a `<mask>`. Skipping them here also skips their
2168            // nested shape children, which is what the spec wants
2169            // (SVG 2 Â§5.5 / Â§5.6 / Â§14.4 / Â§14.5).
2170            // SVG 2 Â§5.7 â€” an element from a foreign namespace, and
2171            // everything under it, is not rendered. Without this arm
2172            // the generic "recurse into children" fallback painted an
2173            // `<svg>` that an XHTML wrapper had smuggled in.
2174            $element instanceof \Phpdftk\Svg\ForeignElement,
2175            $element instanceof Defs,
2176            $element instanceof Symbol,
2177            $element instanceof ClipPath,
2178            $element instanceof Mask,
2179            // SVG 2 Â§11.6 â€” `<marker>` is referenced via `marker-*`
2180            // properties on shapes; it never paints at the document
2181            // level. The painter pulls in a marker when it's
2182            // requested at a path vertex (future deliverable).
2183            $element instanceof \Phpdftk\Svg\Marker,
2184            // SVG 2 Â§13.3 â€” `<pattern>` is referenced via
2185            // `fill="url(#id)"` / `stroke="url(#id)"`; the painter
2186            // pulls in pattern content when a shape's paint
2187            // resolves to a pattern URL (future deliverable).
2188            $element instanceof \Phpdftk\Svg\Pattern,
2189            // SVG 2 Filter Effects Â§6.1 â€” `<filter>` is referenced
2190            // via `filter="url(#id)"` and applied via SoftMask. Never
2191            // paints at document level.
2192            $element instanceof \Phpdftk\Svg\Filter,
2193            // SVG 2 Â§6.3 â€” `<view>` is a fragment-targeted viewport
2194            // definition. Activated only when callers pass a view id;
2195            // never paints at document level.
2196            $element instanceof \Phpdftk\Svg\View,
2197            // SVG 2 Â§15.2 â€” `<script>` content is JS; never executes
2198            // server-side and never paints. Explicit skip prevents
2199            // any nested `<text>` etc. children from leaking into the
2200            // output stream.
2201            $element instanceof \Phpdftk\Svg\Script,
2202            // SVG 2 Â§19 â€” animation elements never paint at the
2203            // static print medium. Skip to avoid recursing into any
2204            // nested `<mpath>` etc. that would otherwise fall through
2205            // to the default container walk.
2206            $element instanceof \Phpdftk\Svg\Animation => null,
2207            // SVG 2 Â§6.4 â€” `<metadata>` carries RDF/XML or other
2208            // non-render metadata. Skip explicitly so any embedded
2209            // RDF text doesn't accidentally leak.
2210            $element instanceof \Phpdftk\Svg\Metadata => null,
2211            // SVG 2 Â§15.3 â€” `<title>` and `<desc>` are accessibility
2212            // metadata that never renders directly. Skip the recursive
2213            // walk so their text content doesn't leak into output.
2214            $element instanceof \Phpdftk\Svg\Title,
2215            $element instanceof \Phpdftk\Svg\Desc,
2216            // SVG 2 Â§11.6 â€” `<foreignObject>` holds non-SVG content
2217            // (HTML / MathML). Rendering that content requires a
2218            // separate pipeline; the typed class lets callers detect
2219            // and route it. Inside the SVG dispatch we skip the
2220            // foreign tree entirely to avoid painting GenericElement
2221            // children that aren't actual SVG shapes.
2222            $element instanceof \Phpdftk\Svg\ForeignObject => null,
2223            // SVG 2 Â§12.1.1 â€” `<a>` paints its children. The PDF link
2224            // annotation (which is what makes the rendered region
2225            // clickable) is a future concern that needs page-relative
2226            // bounding boxes; we paint the inner content faithfully
2227            // here so the visual stays correct.
2228            $element instanceof \Phpdftk\Svg\A_
2229                => $this->paintChildren($element, $stream),
2230            // SVG 2 Â§5.7 â€” `<switch>` picks the first child whose
2231            // conditional-processing attributes all evaluate true.
2232            $element instanceof \Phpdftk\Svg\Switch_
2233                => $this->paintSwitch($element, $stream),
2234            // `<g>` and any other container fall through here â€” the
2235            // recursive walk still descends into their children.
2236            default => $this->paintChildren($element, $stream),
2237        };
2238    }
2239
2240    /**
2241     * SVG 2 Â§5.7 â€” paint the first child of `<switch>` whose
2242     * conditional-processing tests all evaluate true. Empty-test
2243     * children always pass. Stops after painting the first
2244     * matching child.
2245     */
2246    private function paintSwitch(\Phpdftk\Svg\Switch_ $switch, ContentStream $stream): void
2247    {
2248        foreach ($switch->children as $child) {
2249            if (!$child instanceof Element) {
2250                continue;
2251            }
2252            if (!$this->switchChildPasses($child)) {
2253                continue;
2254            }
2255            $this->paintElement($child, $stream);
2256            return;
2257        }
2258    }
2259
2260    /**
2261     * Evaluate the conditional-processing attributes (SVG 2 Â§5.8) on
2262     * an element:
2263     *
2264     *   - `requiredFeatures` â€” legacy SVG 1.1 list of feature
2265     *     URIs. All listed URIs evaluate true here so the test
2266     *     never fails (matches major browser behaviour now).
2267     *   - `requiredExtensions` â€” author-supplied extension URIs.
2268     *     Any presence fails: print medium can't observe any
2269     *     UA-specific extensions. A PRESENT BUT EMPTY list also
2270     *     evaluates false, which Â§5.8.3 states outright â€” it is not
2271     *     "no requirements".
2272     *   - `systemLanguage` â€” comma-separated BCP 47 tags. The
2273     *     test passes when at least one tag prefix-matches the
2274     *     `xml:lang` (or `lang`) ancestor chain.
2275     *
2276     * These apply to ANY direct rendering element, not just the
2277     * children of a `<switch>`; `<switch>` layers its own
2278     * "first passing child only" rule on top.
2279     */
2280    private function switchChildPasses(Element $child): bool
2281    {
2282        if ($child->hasAttribute('requiredExtensions')) {
2283            return false;
2284        }
2285        if ($child->hasAttribute('systemLanguage')) {
2286            $needed = preg_split(
2287                '/[\s,]+/',
2288                strtolower(trim($child->getAttribute('systemLanguage') ?? '')),
2289            ) ?: [];
2290            $needed = array_filter($needed, static fn(string $s): bool => $s !== '');
2291            $documentLang = $this->resolveSystemLanguage($child);
2292            $matched = false;
2293            foreach ($needed as $tag) {
2294                if ($tag === $documentLang || str_starts_with($documentLang, $tag . '-')) {
2295                    $matched = true;
2296                    break;
2297                }
2298            }
2299            if (!$matched) {
2300                return false;
2301            }
2302        }
2303        return true;
2304    }
2305
2306    /**
2307     * Walk ancestor `xml:lang` / `lang` attributes. Defaults to
2308     * `en` when nothing is set, matching the SVG default UA
2309     * behaviour for systemLanguage matching.
2310     */
2311    private function resolveSystemLanguage(Element $element): string
2312    {
2313        for ($n = $element; $n !== null; $n = $n->parent) {
2314            $lang = $n->getAttribute('xml:lang') ?? $n->getAttribute('lang');
2315            if ($lang !== null && $lang !== '') {
2316                return strtolower($lang);
2317            }
2318        }
2319        return 'en';
2320    }
2321
2322    private function paintUse(Use_ $use, ContentStream $stream): void
2323    {
2324        if ($this->document === null) {
2325            return;
2326        }
2327        // SVG 2 Â§5.6 â€” when the embedding pipeline has already
2328        // materialised the shadow tree (the HTML cascade does, so the
2329        // instance INHERITS from the `<use>`), paint that clone. Falling
2330        // back to re-resolving the `href` would paint the referenced
2331        // element as it is styled where it sits, losing everything the
2332        // `<use>` contributed.
2333        $referent = self::materialisedUseInstance($use) ?? $use->resolve($this->document);
2334        if ($referent === null) {
2335            return;
2336        }
2337        // SVG 2 Â§5.6.2 â€” referencing an ancestor of the `<use>` (or the
2338        // `<use>` itself) is an invalid circular reference: the element
2339        // is not rendered. Unguarded this recursed until the C stack
2340        // gave out and took the process with it (SIGSEGV), so a
2341        // malformed or hostile document could kill the renderer.
2342        for ($n = $use->parent; $n !== null; $n = $n->parent) {
2343            if ($n === $referent) {
2344                return;
2345            }
2346        }
2347        if ($referent === $use || $this->useExpansionsInProgress->contains($referent)) {
2348            return;
2349        }
2350        $this->useExpansionsInProgress->attach($referent, true);
2351        try {
2352            $this->paintUseResolved($use, $referent, $stream);
2353        } finally {
2354            $this->useExpansionsInProgress->detach($referent);
2355        }
2356    }
2357
2358    /**
2359     * The body of {@see paintUse} once the referent is known to be a
2360     * legal, non-circular target.
2361     */
2362    private function paintUseResolved(Use_ $use, Element $referent, ContentStream $stream): void
2363    {
2364        // SVG 2 Â§5.6 â€” the `<use>`'s `x` / `y` translate the referent's
2365        // coordinate system. Width / height overrides on `<symbol>`
2366        // referents resolve through viewBox-to-viewport mapping; that
2367        // mapping needs target-rectangle context we don't have until
2368        // 3R, so 3Q honours the translation only.
2369        $x = $use->x();
2370        $y = $use->y();
2371        // SVG 2 Â§5.6.1 â€” when the referent is a nested `<svg>`, the use's
2372        // width/height (if both set) become that svg's viewport.
2373        $useW = $use->width();
2374        $useH = $use->height();
2375        $override = null;
2376        if ($referent instanceof \Phpdftk\Svg\ViewportElement) {
2377            // Per axis, and only for a POSITIVE override â€” a zero or
2378            // absent dimension leaves the referenced element's own
2379            // value (or the 100 % default) in force.
2380            $override = [
2381                'w' => $useW !== null && $useW > 0.0 ? $useW : null,
2382                'h' => $useH !== null && $useH > 0.0 ? $useH : null,
2383            ];
2384            if ($override['w'] === null && $override['h'] === null) {
2385                $override = null;
2386            }
2387        }
2388        if ($x === 0.0 && $y === 0.0 && $override === null) {
2389            $this->paintUseReferent($referent, $stream);
2390            return;
2391        }
2392        $stream->saveGraphicsState();
2393        if ($x !== 0.0 || $y !== 0.0) {
2394            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
2395        }
2396        $prevOverride = $this->pendingUseViewport;
2397        $this->pendingUseViewport = $override;
2398        $this->paintUseReferent($referent, $stream);
2399        $this->pendingUseViewport = $prevOverride;
2400        $stream->restoreGraphicsState();
2401    }
2402
2403    /**
2404     * The pre-built `<use>` instance, if the embedding pipeline
2405     * materialised one. Identified by the marker attribute
2406     * {@see \Phpdftk\HtmlToPdf\Box\BoxGenerator::USE_INSTANCE_ATTRIBUTE},
2407     * which is spelled out here rather than imported: `svg-to-pdf` must
2408     * not depend on `html-to-pdf` (the dependency runs the other way).
2409     */
2410    private static function materialisedUseInstance(Use_ $use): ?Element
2411    {
2412        foreach ($use->children as $child) {
2413            if ($child instanceof Element
2414                && $child->getAttribute('data-phpdftk-use-instance') !== null
2415            ) {
2416                return $child;
2417            }
2418        }
2419        return null;
2420    }
2421
2422    /**
2423     * `<symbol>` referents have their children painted directly per
2424     * SVG 2 Â§5.5 â€” referencing them via `<use>` is the only way they
2425     * paint at all. Everything else routes through normal dispatch.
2426     */
2427    private function paintUseReferent(Element $referent, ContentStream $stream): void
2428    {
2429        // SVG 2 Â§8.9 â€” `display: none` on the referenced element
2430        // suppresses the instance too. The `<symbol>` branch below
2431        // bypasses `paintElement()`, so the check cannot live there
2432        // alone.
2433        if ($referent->displayValue() === 'none') {
2434            return;
2435        }
2436        if ($referent instanceof Symbol) {
2437            // SVG 2 Â§5.5 / Â§5.6.2 â€” the instance a `<use>` generates for
2438            // a `<symbol>` behaves as an `<svg>`: it establishes a
2439            // viewport, maps any `viewBox` into it, and clips to it.
2440            // Painting the children bare let a symbol's content spill
2441            // across the whole document.
2442            $this->paintViewportInstance($referent, $stream);
2443            return;
2444        }
2445        $this->paintElement($referent, $stream);
2446    }
2447
2448    private function paintImage(SvgImage $image, ContentStream $stream): void
2449    {
2450        $href = $image->href();
2451        if ($href === null) {
2452            return;
2453        }
2454        // SVG 2 Â§8.6 â€” an `<image>` href may carry a URL fragment
2455        // (`…#view`) selecting a `<view>` inside the referenced SVG.
2456        // The fragment is never part of the resource bytes, so it is
2457        // split off before the payload is decoded / read.
2458        [$locator, $fragment] = self::splitHrefFragment($href);
2459        // 3Q: filesystem hrefs.
2460        // 3R+18: `data:` URIs decoded and materialised to a temp
2461        // file so the same `ImageParser` + `PdfWriter::addImage`
2462        // flow (which only accepts paths) can embed them.
2463        // 4F.1: `http(s)://` hrefs route through the optional
2464        // `ResourceLoader` injected at construction. SSRF guard,
2465        // redirect handling, content-length cap, and MIME sniffing
2466        // all live in the loader; we just hand off the URL and
2467        // materialise the bytes to a temp file like the data:
2468        // path. When no loader is configured, http(s):// drops
2469        // silently per the original 3R+18 posture so existing
2470        // callers don't change behaviour.
2471        $sourcePath = $locator;
2472        $bytes = null;
2473        if (str_starts_with($locator, 'data:')) {
2474            $decoded = self::decodeDataUri($locator);
2475            if ($decoded === null) {
2476                return;
2477            }
2478            $bytes = $decoded['bytes'];
2479            $fragment = $decoded['fragment'];
2480        } elseif (str_starts_with($locator, 'http://') || str_starts_with($locator, 'https://')) {
2481            if ($this->resourceLoader === null) {
2482                return;
2483            }
2484            $bytes = $this->fetchHttpHref($locator);
2485            if ($bytes === null) {
2486                return;
2487            }
2488        } elseif (str_contains($locator, '://')) {
2489            return;
2490        } else {
2491            // Filesystem href: sniff the head only, so a large raster
2492            // still streams through `ImageParser` + `addImage` rather
2493            // than being slurped into memory here.
2494            try {
2495                $head = LocalFilesystem::readPrefix($sourcePath, 4096, 'image');
2496            } catch (\Throwable) {
2497                return;
2498            }
2499            if (self::looksLikeSvg($head)) {
2500                try {
2501                    $bytes = LocalFilesystem::readFile($sourcePath, 'svg image');
2502                } catch (\Throwable) {
2503                    return;
2504                }
2505            }
2506        }
2507
2508        // SVG 2 Â§8.6 â€” an `<image>` referencing an SVG resource
2509        // establishes a viewport for it and renders the referenced
2510        // document's own tree into it. There is no raster to embed,
2511        // so this never reaches `PdfWriter::addImage` (which rejects
2512        // SVG bytes outright).
2513        if ($bytes !== null && self::looksLikeSvg($bytes)) {
2514            $this->paintEmbeddedSvg($image, $bytes, $fragment, $stream);
2515            return;
2516        }
2517
2518        if ($this->writer === null || $this->page === null) {
2519            return;
2520        }
2521        $tempPath = null;
2522        if ($bytes !== null) {
2523            $tempPath = self::materialiseBytes($bytes);
2524            if ($tempPath === null) {
2525                return;
2526            }
2527            $sourcePath = $tempPath;
2528        }
2529        try {
2530            $this->paintImageFromPath($sourcePath, $image, $stream);
2531        } finally {
2532            if ($tempPath !== null) {
2533                @unlink($tempPath);
2534            }
2535        }
2536    }
2537
2538    /**
2539     * Split an `<image>` href into its resource locator and URL
2540     * fragment. RFC 3986 makes `#` the fragment delimiter for every
2541     * URI form including `data:` (a literal `#` in a data payload has
2542     * to be percent-encoded), so the FIRST `#` wins.
2543     *
2544     * Bare filesystem paths are exempt unless the pre-`#` prefix is
2545     * itself a readable file â€” a `#` is a legal character in a
2546     * filename and splitting one off would break existing local
2547     * hrefs.
2548     *
2549     * @return array{0: string, 1: string|null}
2550     */
2551    private static function splitHrefFragment(string $href): array
2552    {
2553        if (str_starts_with($href, 'data:')) {
2554            // A `data:` URI carries its payload inline, so splitting on
2555            // a bare `#` would truncate any document that inlines an
2556            // unencoded hex colour (`fill='#0f0'`) â€” technically
2557            // malformed per RFC 3986, but common enough that silently
2558            // blanking those documents is worse than ignoring a
2559            // fragment. {@see decodeDataUri} peels the fragment off the
2560            // decoded bytes instead, where it can tell the two apart.
2561            return [$href, null];
2562        }
2563        $hash = strpos($href, '#');
2564        if ($hash === false) {
2565            return [$href, null];
2566        }
2567        $locator = substr($href, 0, $hash);
2568        $fragment = substr($href, $hash + 1);
2569        if ($locator === '') {
2570            return [$href, null];
2571        }
2572        $isUrl = str_starts_with($href, 'http://') || str_starts_with($href, 'https://');
2573        if (!$isUrl && !is_file($locator)) {
2574            return [$href, null];
2575        }
2576        return [$locator, $fragment === '' ? null : $fragment];
2577    }
2578
2579    /**
2580     * SVG sniffer mirroring {@see ImageParser}'s: a case-insensitive
2581     * `<svg` within the head, and the document must open with an XML
2582     * declaration, a comment, a doctype, or the `<svg` tag itself.
2583     * Rules out arbitrary markup where `<svg` merely appears inside.
2584     */
2585    private static function looksLikeSvg(string $bytes): bool
2586    {
2587        $head = substr($bytes, 0, 4096);
2588        if (stripos($head, '<svg') === false) {
2589            return false;
2590        }
2591        $trimmed = ltrim($head, " \t\r\n");
2592        return str_starts_with($trimmed, '<?xml')
2593            || str_starts_with($trimmed, '<!--')
2594            || stripos($trimmed, '<!doctype') === 0
2595            || stripos($trimmed, '<svg') === 0;
2596    }
2597
2598    /**
2599     * SVG 2 Â§8.6 â€” render an SVG resource referenced by `<image>`.
2600     *
2601     * The element establishes a new viewport at `(x, y, width,
2602     * height)` with `overflow: hidden`, and the referenced document is
2603     * a SEPARATE document: nothing inherits into it, and its ids live
2604     * in their own space. That is why the tree is painted by a fresh
2605     * `Translator` rather than this one â€” `findById` on the host
2606     * document must not resolve against the embedded tree, or vice
2607     * versa.
2608     *
2609     * Sizing follows the CSS Images 3 Â§5.3 default sizing algorithm:
2610     * an omitted `width` / `height` on the `<image>` is derived from
2611     * the referenced document's intrinsic size, or from the other axis
2612     * via its intrinsic ratio.
2613     *
2614     * The viewBox-to-viewport mapping uses, in order of precedence,
2615     * the `preserveAspectRatio` on the `<image>` element (SVG 2 Â§8.6
2616     * makes it override the referenced root's), then the one on an
2617     * activated `<view>`, then the referenced root's own.
2618     */
2619    private function paintEmbeddedSvg(
2620        SvgImage $image,
2621        string $bytes,
2622        ?string $fragment,
2623        ContentStream $stream,
2624    ): void {
2625        if ($this->embeddedSvgDepth >= self::MAX_EMBEDDED_SVG_DEPTH) {
2626            return;
2627        }
2628        try {
2629            $document = (new SvgDocumentParser())->parse($bytes);
2630        } catch (\Throwable) {
2631            // Unparseable resource â€” SVG 2 Â§8.6's "no image available".
2632            return;
2633        }
2634
2635        $viewBox = $document->viewBox();
2636        $referencedPar = $document->getAttribute('preserveAspectRatio');
2637        // SVG 2 Â§18.3 â€” `resource.svg#viewId` activates that `<view>`,
2638        // whose `viewBox` / `preserveAspectRatio` stand in for the
2639        // root element's for this reference.
2640        if ($fragment !== null) {
2641            $view = $document->findByFragment($fragment);
2642            if ($view instanceof SvgView) {
2643                $viewBox = $view->viewBox() ?? $viewBox;
2644                $referencedPar = $view->preserveAspectRatio() ?? $referencedPar;
2645            }
2646        }
2647
2648        [$intrinsicW, $intrinsicH] = self::embeddedSvgIntrinsicSize($document, $viewBox);
2649        $ratio = $intrinsicW > 0.0 && $intrinsicH > 0.0
2650            ? $intrinsicW / $intrinsicH
2651            : ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0
2652                ? $viewBox[2] / $viewBox[3]
2653                : null);
2654
2655        $vp = $this->currentViewport();
2656        $x = $this->resolveViewportLength($image->getAttribute('x'), $vp['w'], 0.0);
2657        $y = $this->resolveViewportLength($image->getAttribute('y'), $vp['h'], 0.0);
2658        $w = self::resolveImageExtent($image->getAttribute('width'), $vp['w']);
2659        $h = self::resolveImageExtent($image->getAttribute('height'), $vp['h']);
2660        if ($w === null && $h === null) {
2661            if ($intrinsicW > 0.0 && $intrinsicH > 0.0) {
2662                $w = $intrinsicW;
2663                $h = $intrinsicH;
2664            } else {
2665                // CSS Images 3 Â§5.3 default sizing with NO specified
2666                // size: the default object size for an `<image>` is
2667                // the nearest SVG viewport. A resource carrying only
2668                // an intrinsic ratio is contained within it; one with
2669                // neither size nor ratio simply takes it. This is the
2670                // `<image href="…svg"/>` case, where a referenced
2671                // document whose root is itself `width: auto;
2672                // height: auto` has no concrete size of its own.
2673                [$w, $h] = self::containWithin($vp['w'], $vp['h'], $ratio);
2674            }
2675        } elseif ($w === null) {
2676            $w = $ratio !== null ? ($h ?? 0.0) * $ratio : $intrinsicW;
2677        } elseif ($h === null) {
2678            $h = $ratio !== null && $ratio > 0.0 ? $w / $ratio : $intrinsicH;
2679        }
2680        if ($w === null || $h === null || $w <= 0.0 || $h <= 0.0) {
2681            // A zero-sized viewport disables rendering (SVG 2 Â§8.6).
2682            return;
2683        }
2684
2685        // Source rect. A referenced root without a `viewBox` has one
2686        // synthesised from its intrinsic size; with neither, the
2687        // document simply lays out at the viewport's own size (1:1),
2688        // which is what makes `width="100%"` / viewport units inside
2689        // the referenced document resolve against the image box.
2690        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
2691            $srcW = $viewBox[2];
2692            $srcH = $viewBox[3];
2693        } elseif ($intrinsicW > 0.0 && $intrinsicH > 0.0) {
2694            $srcW = $intrinsicW;
2695            $srcH = $intrinsicH;
2696        } else {
2697            $srcW = $w;
2698            $srcH = $h;
2699        }
2700
2701        $par = $image->preserveAspectRatio() ?? $referencedPar ?? '';
2702        [$scaleX, $scaleY, $offsetX, $offsetY] = self::viewBoxTransform($par, $srcW, $srcH, $w, $h);
2703
2704        $stream->saveGraphicsState();
2705        $placement = [1.0, 0.0, 0.0, 1.0, $x, $y];
2706        $stream->concatMatrix(...$placement);
2707        $stream->rectangle(0.0, 0.0, $w, $h);
2708        $stream->clip();
2709        $stream->endPath();
2710        $mapping = [$scaleX, 0.0, 0.0, $scaleY, $offsetX, $offsetY];
2711        $stream->concatMatrix(...$mapping);
2712
2713        // The cumulative SVG→page matrix the child inherits, so any
2714        // gradient inside the embedded document reconstructs the right
2715        // `/Matrix` (PDF resolves pattern matrices against DEFAULT
2716        // page space, not the fill-time CTM).
2717        $baseMatrix = self::multiplyAffine(
2718            self::multiplyAffine($this->currentMatrix(), $placement),
2719            $mapping,
2720        );
2721
2722        $child = new self($this->resourceLoader, $this->documentFontProvider);
2723        $child->embeddedSvgDepth = $this->embeddedSvgDepth + 1;
2724        $this->cascadeProjector->project($document);
2725        $child->paint(
2726            $document,
2727            $stream,
2728            $this->page,
2729            $this->writer,
2730            compensateTextFlip: $this->compensateTextFlip,
2731            effectiveViewport: ['w' => $srcW, 'h' => $srcH],
2732            baseMatrix: $baseMatrix,
2733        );
2734        $stream->restoreGraphicsState();
2735    }
2736
2737    /**
2738     * Intrinsic width / height of a referenced SVG root per CSS Images
2739     * 3 Â§4: the `width` / `height` attributes when they are absolute
2740     * lengths, with a missing axis derived from the other plus the
2741     * `viewBox` ratio. Percentage attributes carry no intrinsic
2742     * extent. Returns `[0.0, 0.0]` when neither axis resolves.
2743     *
2744     * @param  array{0: float, 1: float, 2: float, 3: float}|null $viewBox
2745     * @return array{0: float, 1: float}
2746     */
2747    private static function embeddedSvgIntrinsicSize(
2748        SvgDocument $document,
2749        ?array $viewBox,
2750    ): array {
2751        $w = self::parseLengthPrefixForViewport($document->widthAttribute());
2752        $h = self::parseLengthPrefixForViewport($document->heightAttribute());
2753        $ratio = $viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0
2754            ? $viewBox[2] / $viewBox[3]
2755            : null;
2756        if ($w !== null && $h === null && $ratio !== null) {
2757            $h = $w / $ratio;
2758        } elseif ($h !== null && $w === null && $ratio !== null) {
2759            $w = $h * $ratio;
2760        }
2761        if ($w === null || $h === null || $w <= 0.0 || $h <= 0.0) {
2762            return [0.0, 0.0];
2763        }
2764        return [$w, $h];
2765    }
2766
2767    /**
2768     * An `<image>` `width` / `height` attribute as a used length:
2769     * `null` for absent / `auto` / negative (all of which mean "derive
2770     * it"), a percentage resolved against the enclosing viewport, and
2771     * anything else through the element's own unit-aware parse.
2772     */
2773    /**
2774     * CSS Images 3 Â§5.3's contain constraint: the largest rectangle
2775     * with `$ratio` (width Ã· height) that fits inside `$w` Ã— `$h`. A
2776     * null ratio has nothing to preserve, so the box is taken whole.
2777     *
2778     * @return array{0: float, 1: float}
2779     */
2780    private static function containWithin(float $w, float $h, ?float $ratio): array
2781    {
2782        if ($ratio === null || $ratio <= 0.0) {
2783            return [$w, $h];
2784        }
2785        $widthAtFullHeight = $h * $ratio;
2786        return $widthAtFullHeight <= $w
2787            ? [$widthAtFullHeight, $h]
2788            : [$w, $w / $ratio];
2789    }
2790
2791    private static function resolveImageExtent(?string $raw, float $viewport): ?float
2792    {
2793        if ($raw === null) {
2794            return null;
2795        }
2796        $trimmed = trim($raw);
2797        if ($trimmed === '' || strtolower($trimmed) === 'auto') {
2798            return null;
2799        }
2800        if (preg_match('/^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*%$/', $trimmed, $m) === 1) {
2801            $value = ((float) $m[1]) / 100.0 * $viewport;
2802            return $value < 0.0 ? null : $value;
2803        }
2804        $plain = self::parseLengthPrefixForViewport($trimmed);
2805        if ($plain === null || $plain < 0.0) {
2806            return null;
2807        }
2808        return $plain;
2809    }
2810
2811    private function paintImageFromPath(string $path, SvgImage $image, ContentStream $stream): void
2812    {
2813        // Resolve intrinsic source dimensions before registering so we
2814        // can fall back to them when the SVG omits width / height.
2815        // `ImageParser::parse` is cheap (header-only read) and
2816        // bypassing the writer keeps this self-contained.
2817        try {
2818            $info = ImageParser::parse($path);
2819        } catch (\Throwable) {
2820            return;
2821        }
2822        if ($this->writer === null || $this->page === null) {
2823            return;
2824        }
2825        try {
2826            $resourceName = $this->writer->addImage($path, $this->page);
2827        } catch (\Throwable) {
2828            // Missing file / unparseable bytes / unsupported format â€”
2829            // SVG 2 Â§12.6's "no image available" outcome is to paint
2830            // nothing.
2831            return;
2832        }
2833
2834        $x = $image->x();
2835        $y = $image->y();
2836        $w = $image->width();
2837        $h = $image->height();
2838        $intrinsicW = (float) $info->width;
2839        $intrinsicH = (float) $info->height;
2840        // SVG 2 Â§12.6 fallback ladder for missing width / height: when
2841        // one dimension is given the other is scaled to preserve the
2842        // intrinsic aspect ratio; when both are absent the intrinsic
2843        // dimensions are used directly. A zero-size intrinsic image
2844        // still paints nothing.
2845        if ($intrinsicW <= 0.0 || $intrinsicH <= 0.0) {
2846            return;
2847        }
2848        if ($w === null && $h === null) {
2849            $w = $intrinsicW;
2850            $h = $intrinsicH;
2851        } elseif ($w === null) {
2852            // height set, width follows the intrinsic aspect.
2853            $w = ($h ?? 0.0) * ($intrinsicW / $intrinsicH);
2854        } elseif ($h === null) {
2855            $h = $w * ($intrinsicH / $intrinsicW);
2856        }
2857        if ($w <= 0.0 || $h <= 0.0) {
2858            return;
2859        }
2860
2861        // PDF Do paints the image inside a unit square at (0, 0) â†’ (1, 1).
2862        // The transformation matrix translates + scales it to the SVG
2863        // rectangle. Y is flipped so the image's top-left lands at
2864        // (x, y) â€” PDF's image space is y-down within the unit square,
2865        // SVG's image element is y-down too, so the flip cancels them
2866        // out and the image renders right-side-up at the SVG-stated
2867        // position.
2868        $stream->saveGraphicsState();
2869        $stream->concatMatrix($w, 0.0, 0.0, -$h, $x, $y + $h);
2870        $stream->doXObject($resourceName);
2871        $stream->restoreGraphicsState();
2872    }
2873
2874    /**
2875     * Decode a `data:` URI per RFC 2397.
2876     *
2877     *  - `data:image/png;base64,iVBOR…` â†’ base64 payload
2878     *  - `data:image/svg+xml,<svg…>`     â†’ percent-decoded payload
2879     *  - `data:,hello`                    â†’ empty mime, percent-decoded
2880     *
2881     * Returns `null` if the URI is malformed or the base64 won't
2882     * decode. The MIME type is returned for the caller's information
2883     * but is not authoritative â€” `ImageParser::parse` still sniffs the
2884     * actual bytes to determine the PDF colour space + filter.
2885     *
2886     * A URL fragment (`…svg%3e#view`) is peeled off here rather than
2887     * by {@see splitHrefFragment}, because only after decoding can a
2888     * real fragment be told apart from a `#` the author inlined in the
2889     * payload. Base64 data has an alphabet that excludes `#`, so the
2890     * first one delimits; percent-encoded data can contain anything,
2891     * so a fragment is recognised only when it is the whole remainder
2892     * after the document's final `>` â€” which an inline `fill='#0f0'`
2893     * never is.
2894     *
2895     * @return array{bytes: string, mime: string, fragment: string|null}|null
2896     */
2897    private static function decodeDataUri(string $uri): ?array
2898    {
2899        if (!str_starts_with($uri, 'data:')) {
2900            return null;
2901        }
2902        $rest = substr($uri, 5);
2903        $commaPos = strpos($rest, ',');
2904        if ($commaPos === false) {
2905            return null;
2906        }
2907        $meta = substr($rest, 0, $commaPos);
2908        $data = substr($rest, $commaPos + 1);
2909        $isBase64 = false;
2910        $mime = '';
2911        if ($meta !== '') {
2912            $parts = explode(';', $meta);
2913            $mime = $parts[0];
2914            foreach (array_slice($parts, 1) as $param) {
2915                if ($param === 'base64') {
2916                    $isBase64 = true;
2917                }
2918            }
2919        }
2920        $fragment = null;
2921        if ($isBase64) {
2922            $hash = strpos($data, '#');
2923            if ($hash !== false) {
2924                $fragment = substr($data, $hash + 1);
2925                $data = substr($data, 0, $hash);
2926            }
2927            $bytes = base64_decode($data, true);
2928            if ($bytes === false) {
2929                return null;
2930            }
2931        } else {
2932            $bytes = rawurldecode($data);
2933            if (preg_match('/^(?<doc>.*>)\s*#(?<fragment>[^>#\s]+)\s*$/s', $bytes, $m) === 1) {
2934                $bytes = $m['doc'];
2935                $fragment = $m['fragment'];
2936            }
2937        }
2938        return [
2939            'bytes' => $bytes,
2940            'mime' => $mime,
2941            'fragment' => ($fragment === null || $fragment === '') ? null : $fragment,
2942        ];
2943    }
2944
2945    /**
2946     * Write decoded `<image>` payload bytes to a temp file that lives
2947     * only for the duration of `paintImage`. Returns the path on
2948     * success or `null` if either the temp file couldn't be opened or
2949     * the write failed â€” both fall back to the SVG "no image
2950     * available" outcome. Only the raster path needs this; SVG
2951     * payloads are painted straight from the byte string.
2952     */
2953    private static function materialiseBytes(string $bytes): ?string
2954    {
2955        $tmpPath = tempnam(sys_get_temp_dir(), 'svg-img-');
2956        if ($tmpPath === false) {
2957            return null;
2958        }
2959        try {
2960            LocalFilesystem::writeFile($tmpPath, $bytes);
2961            return $tmpPath;
2962        } catch (\Throwable) {
2963            @unlink($tmpPath);
2964            return null;
2965        }
2966    }
2967
2968    /**
2969     * 4F.1 â€” fetch an `http(s)://` `<image>` href through the
2970     * injected ResourceLoader. Returns the response bytes on success
2971     * or null on any failure (SSRF policy violation, network error,
2972     * non-2xx, body cap exceeded) â€” all of which surface as the SVG 2
2973     * Â§12.6 "no image available" outcome.
2974     */
2975    private function fetchHttpHref(string $href): ?string
2976    {
2977        if ($this->resourceLoader === null) {
2978            return null;
2979        }
2980        try {
2981            $result = $this->resourceLoader->fetch($href);
2982        } catch (SsrfBlockedException | FetchFailedException) {
2983            return null;
2984        }
2985        return $result->bytes;
2986    }
2987
2988    private function paintTextElement(TextElement $text, ContentStream $stream): void
2989    {
2990        // Without writer + page references the resolver can't register a
2991        // font, so the text silently drops. Matches the same standalone
2992        // posture the gradient painter uses at 3O.
2993        if ($this->fontResolver === null) {
2994            return;
2995        }
2996        $content = self::collectTextContent($text);
2997        if ($content === '') {
2998            return;
2999        }
3000        // CSS Text 3 Â§2.1 â€” SVG 2 Â§11 delegates text styling to CSS
3001        // wholesale, so `text-transform` applies to `<text>` and has to
3002        // run before shaping.
3003        $content = TextTransform::apply($content, $text->textTransform());
3004
3005        // SVG 2 default fill for `<text>` is black; the existing
3006        // applyFillPaint path covers that, but we apply it *before*
3007        // entering the text object so the colour persists across the
3008        // Tj sequence (PDF text objects share the page graphics state).
3009        $fill = $text->fill();
3010        if (!($fill instanceof None_)) {
3011            $this->applyFillPaint($fill, $text, $stream);
3012        }
3013
3014        $families = $text->fontFamily();
3015        $weight = $text->fontWeight();
3016        $style = $text->fontStyle();
3017        // SVG 2 Â§11.5 delegates font selection to CSS Fonts 4 wholesale,
3018        // so `<text>` inside an HTML document must select from the same
3019        // face set as the surrounding HTML - `@font-face` families
3020        // included. The embedding document offers those through the
3021        // provider; only when it has nothing for this family stack do we
3022        // fall back to the standard-14 mapping.
3023        $document = $this->documentFontProvider?->resolveDocumentFont($families, $weight, $style);
3024        $font = $document ?? new DocumentFont($this->fontResolver->resolve($families, $weight, $style));
3025        $this->activeFont = $font;
3026        $size = $text->fontSize() ?? 16.0;
3027
3028        // SVG 2 Â§11.6 list-valued positioning. `<text x="10 20 30">ABC</text>`
3029        // positions each glyph individually. When any list has > 1 entry
3030        // the painter walks character-by-character; otherwise it falls
3031        // through to the cheaper single-Tj path. Single-value rotate
3032        // also takes the per-glyph code path so the rotation lands in
3033        // the text matrix.
3034        $xList = $text->x();
3035        $yList = $text->y();
3036        $dxList = $text->dx();
3037        $dyList = $text->dy();
3038        $rotateList = $text->rotate();
3039        $perGlyph = count($xList) > 1
3040            || count($yList) > 1
3041            || $dxList !== []
3042            || $dyList !== []
3043            || $rotateList !== [];
3044
3045        // CSS Text Decoration 4 Â§6 â€” paint text-shadow layers BEHIND
3046        // the real text. Each layer is a sharp-offset copy of the
3047        // glyph run in the shadow colour. Blur-radius is parsed but
3048        // not currently rasterised (matches the html-to-pdf path,
3049        // which also ignores blur). The first listed shadow paints on
3050        // top of later shadows, so we reverse for back-to-front order.
3051        // Per-glyph positioning is intentionally NOT shadowed at v1 -
3052        // the WPT corpus only exercises single-position <text> with
3053        // shadows, and per-glyph shadows would compound complexity
3054        // without coverage to validate them.
3055        if (!$perGlyph) {
3056            $fillFallback = $fill instanceof SolidColor ? $fill->color : null;
3057            $shadows = self::parseTextShadow($text->textShadow(), $fillFallback);
3058            foreach (array_reverse($shadows) as $shadow) {
3059                $stream->saveGraphicsState();
3060                $this->setFillColor($stream, $shadow['color']);
3061                $stream->beginText()->setFont($font->font, $size);
3062                $sx = ($xList[0] ?? 0.0) + $shadow['offsetX'];
3063                $sy = ($yList[0] ?? 0.0) + $shadow['offsetY'];
3064                if ($this->compensateTextFlip) {
3065                    $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $sx, $sy);
3066                } else {
3067                    $stream->moveTextPosition($sx, $sy);
3068                }
3069                $this->showTextRun($stream, $content);
3070                $stream->endText();
3071                $stream->restoreGraphicsState();
3072            }
3073        }
3074
3075        $stream->beginText()->setFont($font->font, $size);
3076        if (!$perGlyph) {
3077            $x = $xList[0] ?? 0.0;
3078            $y = $yList[0] ?? 0.0;
3079            if ($this->compensateTextFlip) {
3080                // Under an outer Y-flip CTM (`SvgRenderer::draw` applies
3081                // one), `Td` would render glyphs upside-down. Setting Tm
3082                // with `d = -1` flips text space so the combined
3083                // `Tm Â· CTM` cancels the outer flip and glyphs render
3084                // upright at the SVG-stated baseline.
3085                $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $x, $y);
3086            } else {
3087                $stream->moveTextPosition($x, $y);
3088            }
3089            $this->showTextRun($stream, $content);
3090        } else {
3091            $this->paintTextPerGlyph($content, $xList, $yList, $dxList, $dyList, $rotateList, $stream);
3092        }
3093        $stream->endText();
3094    }
3095
3096    /**
3097     * Emit one `Tj` for `$text` against {@see $activeFont}.
3098     *
3099     * Composite (Type 0 / CID) fonts - the shape every embedded
3100     * `@font-face` takes - carry no single-byte text encoder, so
3101     * `showText()` would pass UTF-8 bytes straight through and the
3102     * viewer would read them as raw CIDs. Those go through
3103     * `showUnicodeText()` with the post-subset GID map instead. The
3104     * standard-14 Type 1 fonts keep the plain `showText()` path, where
3105     * the font handle's encoder does the UTF-8 -> byte translation.
3106     */
3107    private function showTextRun(ContentStream $stream, string $text): void
3108    {
3109        $font = $this->activeFont;
3110        if ($font !== null && $font->unicodeToGid !== []) {
3111            $stream->showUnicodeText($text, $font->unicodeToGid);
3112            return;
3113        }
3114        $stream->showText($text);
3115    }
3116
3117    /**
3118     * Parse the raw CSS `text-shadow` value into renderable shadow
3119     * layers per CSS Text Decoration 4 Â§6.
3120     *
3121     * Grammar:
3122     *
3123     *     <text-shadow> = none | <shadow># (comma-separated layers)
3124     *     <shadow> = <length>{2,3} <color>?
3125     *
3126     * The two lengths are offset-x and offset-y; the optional third
3127     * is blur-radius (parsed and validated as non-negative, but the
3128     * painter does not currently rasterise blur â€” same posture as
3129     * the html-to-pdf path's `collectTextShadowLayers`). When a layer
3130     * omits the colour, it falls back to `$fillFallback` (the text's
3131     * own fill colour) and finally to opaque black.
3132     *
3133     * Returns an empty list for `null` input, `none`, or any layer
3134     * that fails to parse - SVG 2's "invalid â†’ ignored" semantics
3135     * applied per-shadow keeps a malformed shadow from poisoning
3136     * earlier valid ones.
3137     *
3138     * @return list<array{offsetX: float, offsetY: float, color: ColorInterface}>
3139     */
3140    private static function parseTextShadow(?string $raw, ?ColorInterface $fillFallback): array
3141    {
3142        if ($raw === null) {
3143            return [];
3144        }
3145        $items = self::splitTextShadowItems($raw);
3146        $layers = [];
3147        foreach ($items as $item) {
3148            $layer = self::parseTextShadowLayer($item, $fillFallback);
3149            if ($layer !== null) {
3150                $layers[] = $layer;
3151            }
3152        }
3153        return $layers;
3154    }
3155
3156    /**
3157     * Split a `text-shadow` value on top-level commas, preserving
3158     * commas inside `rgb(...)` / `rgba(...)` / `hsl(...)` etc.
3159     *
3160     * @return list<string>
3161     */
3162    private static function splitTextShadowItems(string $raw): array
3163    {
3164        $items = [];
3165        $depth = 0;
3166        $buffer = '';
3167        $length = strlen($raw);
3168        for ($i = 0; $i < $length; $i++) {
3169            $ch = $raw[$i];
3170            if ($ch === '(') {
3171                $depth++;
3172            } elseif ($ch === ')') {
3173                $depth = max(0, $depth - 1);
3174            }
3175            if ($ch === ',' && $depth === 0) {
3176                $items[] = $buffer;
3177                $buffer = '';
3178                continue;
3179            }
3180            $buffer .= $ch;
3181        }
3182        if (trim($buffer) !== '') {
3183            $items[] = $buffer;
3184        }
3185        return array_values(array_filter(array_map('trim', $items), static fn($s) => $s !== ''));
3186    }
3187
3188    /**
3189     * Parse a single shadow layer. Returns null on any structural
3190     * malformation (fewer than two lengths, blur < 0, more than three
3191     * lengths, unparseable colour token).
3192     *
3193     * @return array{offsetX: float, offsetY: float, color: ColorInterface}|null
3194     */
3195    private static function parseTextShadowLayer(string $raw, ?ColorInterface $fillFallback): ?array
3196    {
3197        // Tokenise on whitespace, but keep `rgb(1, 2, 3)` etc. as a
3198        // single token. Lengths are simple; the colour may contain
3199        // spaces inside its parens.
3200        $tokens = self::tokenizeTextShadowLayer($raw);
3201        if ($tokens === []) {
3202            return null;
3203        }
3204        $lengths = [];
3205        $colorRaw = null;
3206        foreach ($tokens as $token) {
3207            $length = self::parseLengthToken($token);
3208            if ($length !== null) {
3209                if (count($lengths) >= 3) {
3210                    return null;
3211                }
3212                $lengths[] = $length;
3213                continue;
3214            }
3215            if ($colorRaw !== null) {
3216                return null;
3217            }
3218            $colorRaw = $token;
3219        }
3220        if (count($lengths) < 2) {
3221            return null;
3222        }
3223        if (count($lengths) === 3 && $lengths[2] < 0.0) {
3224            // CSS Text Decoration 4: blur-radius must be non-negative.
3225            return null;
3226        }
3227        $color = null;
3228        if ($colorRaw !== null) {
3229            $color = SvgColor::parse($colorRaw);
3230            if ($color === null) {
3231                return null;
3232            }
3233        } else {
3234            $color = $fillFallback ?? new RgbColor(0.0, 0.0, 0.0);
3235        }
3236        return [
3237            'offsetX' => $lengths[0],
3238            'offsetY' => $lengths[1],
3239            'color' => $color,
3240        ];
3241    }
3242
3243    /**
3244     * Whitespace-tokenise a single shadow layer keeping function
3245     * forms (`rgb(0, 128, 0)`) as one token.
3246     *
3247     * @return list<string>
3248     */
3249    private static function tokenizeTextShadowLayer(string $raw): array
3250    {
3251        $tokens = [];
3252        $buffer = '';
3253        $depth = 0;
3254        $length = strlen($raw);
3255        for ($i = 0; $i < $length; $i++) {
3256            $ch = $raw[$i];
3257            if ($ch === '(') {
3258                $depth++;
3259                $buffer .= $ch;
3260                continue;
3261            }
3262            if ($ch === ')') {
3263                $depth = max(0, $depth - 1);
3264                $buffer .= $ch;
3265                continue;
3266            }
3267            if ($depth === 0 && (ctype_space($ch) || $ch === ',')) {
3268                if ($buffer !== '') {
3269                    $tokens[] = $buffer;
3270                    $buffer = '';
3271                }
3272                continue;
3273            }
3274            $buffer .= $ch;
3275        }
3276        if ($buffer !== '') {
3277            $tokens[] = $buffer;
3278        }
3279        return $tokens;
3280    }
3281
3282    /**
3283     * Parse a CSS length token used inside `text-shadow`. Supports
3284     * unitless (`0`), `px`, `pt`, `em`, and a couple of absolute
3285     * physical units. Returns null when the token doesn't look like
3286     * a length so the caller treats it as the colour slot instead.
3287     * Unit support is intentionally narrow - text-shadow in real
3288     * documents almost always uses `px` (or `0`).
3289     */
3290    private static function parseLengthToken(string $raw): ?float
3291    {
3292        $trimmed = trim($raw);
3293        if ($trimmed === '') {
3294            return null;
3295        }
3296        if (preg_match('/^([+-]?(?:\d+\.?\d*|\.\d+))([a-zA-Z%]*)$/', $trimmed, $m) !== 1) {
3297            return null;
3298        }
3299        $value = (float) $m[1];
3300        $unit = strtolower($m[2]);
3301        return match ($unit) {
3302            '', 'px', 'pt' => $value,
3303            'in' => $value * 72.0,
3304            'cm' => $value * (72.0 / 2.54),
3305            'mm' => $value * (72.0 / 25.4),
3306            // em / rem / vw / vh / % aren't resolvable without a
3307            // containing context â€” reject so an unknown unit doesn't
3308            // silently land at 0.
3309            default => null,
3310        };
3311    }
3312
3313    /**
3314     * Per-glyph positioning per SVG 2 Â§11.6: walk content character-by-
3315     * character, emit one `Tm` per explicitly-positioned glyph, then
3316     * batch the remaining characters as a single `Tj` so their natural
3317     * advance handles the trailing positioning.
3318     *
3319     * Sticky semantics: when a glyph specifies `x[i]` but not `y[i]`
3320     * (or vice-versa), the unspecified component carries over from the
3321     * previous glyph's position. SVG 2 actually defines this as "the
3322     * previous glyph's effective position" which requires knowing the
3323     * per-glyph advance â€” we don't have font metrics here at 3R+5, so
3324     * we use the last explicit value instead. The result is correct
3325     * for the common case where `x` and `y` have matching lengths.
3326     *
3327     * `dx[i]` / `dy[i]` are additive deltas applied to the resolved
3328     * `(stickyX, stickyY)` â€” SVG 2 Â§11.6 specifies them as relative
3329     * offsets from the glyph's natural position. Without font metrics
3330     * "natural position" collapses to "sticky position", so we treat
3331     * dx/dy as deltas from sticky. The deltas accumulate into sticky
3332     * so subsequent glyphs without their own dx/dy inherit the shift,
3333     * matching the common renderer behaviour for stacked offsets like
3334     * super/subscript adjustments.
3335     *
3336     * @param list<float> $xList
3337     * @param list<float> $yList
3338     * @param list<float> $dxList
3339     * @param list<float> $dyList
3340     * @param list<float> $rotateList
3341     */
3342    private function paintTextPerGlyph(
3343        string $content,
3344        array $xList,
3345        array $yList,
3346        array $dxList,
3347        array $dyList,
3348        array $rotateList,
3349        ContentStream $stream,
3350    ): void {
3351        $chars = mb_str_split($content);
3352        if ($chars === []) {
3353            return;
3354        }
3355        $explicitCount = max(
3356            count($xList),
3357            count($yList),
3358            count($dxList),
3359            count($dyList),
3360            count($rotateList),
3361        );
3362        $stickyX = $xList[0] ?? 0.0;
3363        $stickyY = $yList[0] ?? 0.0;
3364        $stickyRotate = $rotateList[0] ?? 0.0;
3365
3366        $emitted = 0;
3367        foreach ($chars as $i => $char) {
3368            if ($i >= $explicitCount) {
3369                break;
3370            }
3371            $stickyX = $xList[$i] ?? $stickyX;
3372            $stickyY = $yList[$i] ?? $stickyY;
3373            $stickyRotate = $rotateList[$i] ?? $stickyRotate;
3374            if (isset($dxList[$i])) {
3375                $stickyX += $dxList[$i];
3376            }
3377            if (isset($dyList[$i])) {
3378                $stickyY += $dyList[$i];
3379            }
3380
3381            $this->emitTextMatrix($stickyX, $stickyY, $stickyRotate, $stream);
3382            $this->showTextRun($stream, $char);
3383            $emitted++;
3384        }
3385        if ($emitted < count($chars)) {
3386            // Remaining glyphs ride the auto-advance from the last
3387            // positioned glyph â€” emit them as a single `Tj` so the
3388            // PDF reader inter-glyph kerning still applies.
3389            $this->showTextRun($stream, implode('', array_slice($chars, $emitted)));
3390        }
3391    }
3392
3393    /**
3394     * Set the text matrix for a single positioned glyph. Combines the
3395     * per-glyph rotation with the optional outer-flip compensation
3396     * established by `SvgRenderer::draw`. Algebra:
3397     *
3398     *  Without flip: Tm = T(x,y) Â· R(θ) = [cosθ sinθ -sinθ cosθ x y]
3399     *  With    flip: Tm = T(x,y) Â· F Â· R(θ) = [cosθ sinθ sinθ -cosθ x y]
3400     *
3401     * where `F = [1 0 0 -1 0 0]` is the y-axis flip.
3402     */
3403    private function emitTextMatrix(float $x, float $y, float $rotateDegrees, ContentStream $stream): void
3404    {
3405        $rad = deg2rad($rotateDegrees);
3406        $cos = cos($rad);
3407        $sin = sin($rad);
3408        if ($this->compensateTextFlip) {
3409            $stream->setTextMatrix($cos, $sin, $sin, -$cos, $x, $y);
3410        } else {
3411            $stream->setTextMatrix($cos, $sin, -$sin, $cos, $x, $y);
3412        }
3413    }
3414
3415    /**
3416     * Concatenate all `Phpdftk\Svg\Text` (data) descendants in document
3417     * order. SVG 2's whitespace handling is complex (xml:space="preserve"
3418     * vs the default collapse); 3P keeps things simple by emitting the
3419     * source bytes verbatim and leaving whitespace policy to the future
3420     * cascade-aware text painter.
3421     */
3422    private static function collectTextContent(Element $element): string
3423    {
3424        $out = '';
3425        foreach ($element->children as $child) {
3426            if ($child instanceof TextNode) {
3427                $out .= $child->data;
3428                continue;
3429            }
3430            if ($child instanceof Element) {
3431                $out .= self::collectTextContent($child);
3432            }
3433        }
3434        return $out;
3435    }
3436
3437    private function paintPath(Path $path, ContentStream $stream): void
3438    {
3439        $commands = $path->d()->commands;
3440        if ($commands === []) {
3441            return;
3442        }
3443        $state = new PathPainterState();
3444        foreach ($commands as $command) {
3445            $this->emitPathCommand($command, $stream, $state);
3446        }
3447        $this->applyFillAndStroke($path, $stream);
3448        $this->paintMarkers($path, MarkerVertices::ofPathData($path->d()), $stream);
3449    }
3450
3451    private function emitPathCommand(
3452        PathCommand $command,
3453        ContentStream $stream,
3454        PathPainterState $state,
3455    ): void {
3456        match (true) {
3457            $command instanceof MoveTo => $this->emitMoveTo($command, $stream, $state),
3458            $command instanceof LineTo => $this->emitLineTo($command, $stream, $state),
3459            $command instanceof HorizontalLineTo => $this->emitHorizontalLineTo($command, $stream, $state),
3460            $command instanceof VerticalLineTo => $this->emitVerticalLineTo($command, $stream, $state),
3461            $command instanceof CurveTo => $this->emitCurveTo($command, $stream, $state),
3462            $command instanceof SmoothCurveTo => $this->emitSmoothCurveTo($command, $stream, $state),
3463            $command instanceof QuadraticCurveTo => $this->emitQuadraticCurveTo($command, $stream, $state),
3464            $command instanceof SmoothQuadraticCurveTo => $this->emitSmoothQuadraticCurveTo($command, $stream, $state),
3465            $command instanceof ArcTo => $this->emitArcTo($command, $stream, $state),
3466            $command instanceof ClosePath => $this->emitClosePath($stream, $state),
3467            // Sealed-via-convention: 3rd-party impls of `PathCommand` are
3468            // not part of the SVG spec, so we no-op silently rather than
3469            // throw â€” same posture the parser uses for unknown content.
3470            default => null,
3471        };
3472    }
3473
3474    private function emitMoveTo(MoveTo $cmd, ContentStream $stream, PathPainterState $state): void
3475    {
3476        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3477        $stream->moveTo($x, $y);
3478        $state->moveTo($x, $y);
3479    }
3480
3481    private function emitLineTo(LineTo $cmd, ContentStream $stream, PathPainterState $state): void
3482    {
3483        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3484        $stream->lineTo($x, $y);
3485        $state->lineTo($x, $y);
3486    }
3487
3488    private function emitHorizontalLineTo(
3489        HorizontalLineTo $cmd,
3490        ContentStream $stream,
3491        PathPainterState $state,
3492    ): void {
3493        $x = $cmd->absolute ? $cmd->x : $state->currentX + $cmd->x;
3494        $stream->lineTo($x, $state->currentY);
3495        $state->lineTo($x, $state->currentY);
3496    }
3497
3498    private function emitVerticalLineTo(
3499        VerticalLineTo $cmd,
3500        ContentStream $stream,
3501        PathPainterState $state,
3502    ): void {
3503        $y = $cmd->absolute ? $cmd->y : $state->currentY + $cmd->y;
3504        $stream->lineTo($state->currentX, $y);
3505        $state->lineTo($state->currentX, $y);
3506    }
3507
3508    private function emitCurveTo(CurveTo $cmd, ContentStream $stream, PathPainterState $state): void
3509    {
3510        [$x1, $y1] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state);
3511        [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state);
3512        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3513        $stream->curveTo($x1, $y1, $x2, $y2, $x, $y);
3514        $state->currentX = $x;
3515        $state->currentY = $y;
3516        $state->recordCubicControl($x2, $y2);
3517    }
3518
3519    private function emitSmoothCurveTo(
3520        SmoothCurveTo $cmd,
3521        ContentStream $stream,
3522        PathPainterState $state,
3523    ): void {
3524        [$x1, $y1] = $state->reflectedCubicControl();
3525        [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state);
3526        [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3527        $stream->curveTo($x1, $y1, $x2, $y2, $x, $y);
3528        $state->currentX = $x;
3529        $state->currentY = $y;
3530        $state->recordCubicControl($x2, $y2);
3531    }
3532
3533    /**
3534     * PDF has no native quadratic curve operator. Lift to cubic via the
3535     * standard `C1 = P0 + 2/3·(P1-P0)`, `C2 = P2 + 2/3·(P1-P2)` formula â€”
3536     * mathematically exact, no approximation error.
3537     */
3538    private function emitQuadraticCurveTo(
3539        QuadraticCurveTo $cmd,
3540        ContentStream $stream,
3541        PathPainterState $state,
3542    ): void {
3543        [$qx, $qy] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state);
3544        [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3545        $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream);
3546        $state->currentX = $ex;
3547        $state->currentY = $ey;
3548        $state->recordQuadraticControl($qx, $qy);
3549    }
3550
3551    private function emitSmoothQuadraticCurveTo(
3552        SmoothQuadraticCurveTo $cmd,
3553        ContentStream $stream,
3554        PathPainterState $state,
3555    ): void {
3556        [$qx, $qy] = $state->reflectedQuadraticControl();
3557        [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3558        $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream);
3559        $state->currentX = $ex;
3560        $state->currentY = $ey;
3561        $state->recordQuadraticControl($qx, $qy);
3562    }
3563
3564    private function emitArcTo(ArcTo $cmd, ContentStream $stream, PathPainterState $state): void
3565    {
3566        [$endX, $endY] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state);
3567        $segments = ArcToCubic::convert(
3568            $state->currentX,
3569            $state->currentY,
3570            $cmd->rx,
3571            $cmd->ry,
3572            $cmd->xAxisRotation,
3573            $cmd->largeArc,
3574            $cmd->sweep,
3575            $endX,
3576            $endY,
3577        );
3578        if ($segments === []) {
3579            // Degenerate: zero-length or zero-radius. Per SVG 2 Â§9.5.1,
3580            // an arc with a zero radius is rendered as a straight line.
3581            if ($cmd->rx === 0.0 || $cmd->ry === 0.0) {
3582                $stream->lineTo($endX, $endY);
3583                $state->lineTo($endX, $endY);
3584            }
3585            return;
3586        }
3587        foreach ($segments as $segment) {
3588            $stream->curveTo(
3589                $segment['x1'],
3590                $segment['y1'],
3591                $segment['x2'],
3592                $segment['y2'],
3593                $segment['x'],
3594                $segment['y'],
3595            );
3596        }
3597        $state->currentX = $endX;
3598        $state->currentY = $endY;
3599        $state->clearControlPoints();
3600    }
3601
3602    private function emitClosePath(ContentStream $stream, PathPainterState $state): void
3603    {
3604        $stream->closePath();
3605        $state->closeSubpath();
3606    }
3607
3608    private function emitQuadraticAsCubic(
3609        float $p0x,
3610        float $p0y,
3611        float $p1x,
3612        float $p1y,
3613        float $p2x,
3614        float $p2y,
3615        ContentStream $stream,
3616    ): void {
3617        $twoThirds = 2.0 / 3.0;
3618        $c1x = $p0x + $twoThirds * ($p1x - $p0x);
3619        $c1y = $p0y + $twoThirds * ($p1y - $p0y);
3620        $c2x = $p2x + $twoThirds * ($p1x - $p2x);
3621        $c2y = $p2y + $twoThirds * ($p1y - $p2y);
3622        $stream->curveTo($c1x, $c1y, $c2x, $c2y, $p2x, $p2y);
3623    }
3624
3625    /**
3626     * @return array{float, float}
3627     */
3628    private function resolvePoint(float $x, float $y, bool $absolute, PathPainterState $state): array
3629    {
3630        if ($absolute) {
3631            return [$x, $y];
3632        }
3633        return [$state->currentX + $x, $state->currentY + $y];
3634    }
3635
3636    // The paint* entry points below share their geometry with the
3637    // emit*Path methods the clip-region builder uses. Keeping ONE
3638    // resolver per shape is what makes SVG 2 Â§10.1 geometry (CSS-
3639    // declared `cx` / `r` / `width` / â€¦) reach BOTH the painter and
3640    // `clip-path`; the two used to compute coordinates separately and
3641    // only the clip side was ever taught about the cascade.
3642
3643    private function paintRect(Rect $rect, ContentStream $stream): void
3644    {
3645        if (!$this->emitRectPath($rect, $stream)) {
3646            return;
3647        }
3648        $this->applyFillAndStroke($rect, $stream);
3649    }
3650
3651    private function paintCircle(Circle $circle, ContentStream $stream): void
3652    {
3653        if (!$this->emitCirclePath($circle, $stream)) {
3654            return;
3655        }
3656        $this->applyFillAndStroke($circle, $stream);
3657    }
3658
3659    private function paintEllipse(Ellipse $ellipse, ContentStream $stream): void
3660    {
3661        if (!$this->emitEllipsePathFor($ellipse, $stream)) {
3662            return;
3663        }
3664        $this->applyFillAndStroke($ellipse, $stream);
3665    }
3666
3667    private function paintLine(Line $line, ContentStream $stream): void
3668    {
3669        // Lines never enclose an area; only stroke is meaningful. Skip
3670        // entirely when stroke resolves to no paint â€” emitting a stroke
3671        // op with no colour would otherwise draw a black line by
3672        // accident.
3673        $stroke = $line->stroke();
3674        if ($stroke !== null
3675            && !$stroke instanceof None_
3676            && $this->applyStrokePaint($stroke, $line, $stream)
3677        ) {
3678            $stream->moveTo($line->x1(), $line->y1())
3679                ->lineTo($line->x2(), $line->y2())
3680                ->stroke();
3681        }
3682        // SVG 2 Â§11.6.1 â€” markers are a paint channel of their own.
3683        // A `<line>` with no stroke still places them, so this sits
3684        // outside the stroke guard above.
3685        $this->paintMarkers(
3686            $line,
3687            MarkerVertices::ofPoints(
3688                [[$line->x1(), $line->y1()], [$line->x2(), $line->y2()]],
3689                closed: false,
3690            ),
3691            $stream,
3692        );
3693    }
3694
3695    private function paintPolyline(Polyline $polyline, ContentStream $stream): void
3696    {
3697        $points = $polyline->points();
3698        if (count($points) < 2) {
3699            return;
3700        }
3701        $this->emitPolyPath($stream, $points, closed: false);
3702        $this->applyFillAndStroke($polyline, $stream);
3703        $this->paintMarkers($polyline, MarkerVertices::ofPoints($points, closed: false), $stream);
3704    }
3705
3706    private function paintPolygon(Polygon $polygon, ContentStream $stream): void
3707    {
3708        $points = $polygon->points();
3709        if (count($points) < 3) {
3710            return;
3711        }
3712        $this->emitPolyPath($stream, $points, closed: true);
3713        $this->applyFillAndStroke($polygon, $stream);
3714        $this->paintMarkers($polygon, MarkerVertices::ofPoints($points, closed: true), $stream);
3715    }
3716
3717    /**
3718     * Standard 4-cubic-Bézier ellipse approximation. Maximum radial
3719     * error against the true ellipse is ~0.027 % â€” well below print
3720     * resolution for any reasonable PDF size.
3721     */
3722    private function emitEllipsePath(
3723        ContentStream $stream,
3724        float $cx,
3725        float $cy,
3726        float $rx,
3727        float $ry,
3728    ): void {
3729        $kx = $rx * self::KAPPA;
3730        $ky = $ry * self::KAPPA;
3731        $stream
3732            ->moveTo($cx + $rx, $cy)
3733            ->curveTo($cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry)
3734            ->curveTo($cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy)
3735            ->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry)
3736            ->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy)
3737            ->closePath();
3738    }
3739
3740    /**
3741     * @param list<array{float, float}> $points
3742     */
3743    private function emitPolyPath(ContentStream $stream, array $points, bool $closed): void
3744    {
3745        $first = $points[0];
3746        $stream->moveTo($first[0], $first[1]);
3747        for ($i = 1, $n = count($points); $i < $n; $i++) {
3748            $stream->lineTo($points[$i][0], $points[$i][1]);
3749        }
3750        if ($closed) {
3751            $stream->closePath();
3752        }
3753    }
3754
3755    /**
3756     * Resolve the element's fill and stroke and emit the right PDF
3757     * paint operator combination. Defaults follow SVG 2 Â§13.2.1 â€” black
3758     * fill, no stroke â€” so a bare `<rect width=… height=…/>` paints as
3759     * a filled black rectangle.
3760     */
3761    private function applyFillAndStroke(Element $element, ContentStream $stream): void
3762    {
3763        $fill = $element->fill();
3764        $stroke = $element->stroke();
3765
3766        // SVG 2 Â§13.3 â€” when `fill` resolves to a `<pattern>`, the pattern
3767        // tile paints clipped to the shape (replacing the flat-colour
3768        // fill). Handle it before the plain paint path: the current path
3769        // just built by the caller is consumed as the clip region.
3770        $pattern = $this->resolveFillPattern($fill);
3771        if ($pattern !== null) {
3772            $this->paintPatternFill($pattern, $element, $stream, $stroke);
3773            return;
3774        }
3775
3776        $hasFill = $this->applyFillPaint($fill, $element, $stream);
3777        $hasStroke = $this->applyStrokePaint($stroke, $element, $stream);
3778
3779        $rule = $element->fillRule() ?? 'nonzero';
3780
3781        if ($hasFill && $hasStroke) {
3782            $rule === 'evenodd' ? $stream->fillAndStrokeEvenOdd() : $stream->fillAndStroke();
3783            return;
3784        }
3785        if ($hasFill) {
3786            $rule === 'evenodd' ? $stream->fillEvenOdd() : $stream->fill();
3787            return;
3788        }
3789        if ($hasStroke) {
3790            $stream->stroke();
3791            return;
3792        }
3793        // Path constructed but nothing wants to paint it â€” discard so
3794        // we don't bake a leftover current-path into the graphics state.
3795        $stream->endPath();
3796    }
3797
3798    /**
3799     * SVG 2 Â§11.6 â€” paint the `marker-start` / `marker-mid` /
3800     * `marker-end` markers of a shape, at the vertices `$vertices`
3801     * describes.
3802     *
3803     * Markers are a paint channel of their own, drawn AFTER the fill
3804     * and stroke of the shape that places them and on top of both
3805     * (§11.6.1). They are painted here inside the referencing element's
3806     * `q`/`Q` wrap, so they share its user space and its `transform`,
3807     * which is what puts a marker on a rotated path in the right place
3808     * without any extra bookkeeping.
3809     *
3810     * Marker content does NOT inherit from the referencing element â€”
3811     * the `<marker>` subtree carries its own cascade â€” so nothing here
3812     * seeds fill or stroke from `$element`.
3813     *
3814     * @param list<MarkerVertex> $vertices
3815     */
3816    private function paintMarkers(Element $element, array $vertices, ContentStream $stream): void
3817    {
3818        if ($vertices === [] || $this->document === null) {
3819            return;
3820        }
3821        $start = $this->resolveMarker($element->markerStart());
3822        $mid = $this->resolveMarker($element->markerMid());
3823        $end = $this->resolveMarker($element->markerEnd());
3824        if ($start === null && $mid === null && $end === null) {
3825            return;
3826        }
3827        // `markerUnits="strokeWidth"` (the default) scales the marker
3828        // by the USED stroke width of the shape â€” which is 1 when the
3829        // property is absent, per SVG 2 Â§13.2, not 0.
3830        // SVG 2 Â§11.6.2 â€” `markerUnits="strokeWidth"` scales by the USED
3831        // stroke width, which `non-scaling-stroke` has already
3832        // normalised against the transform.
3833        $strokeWidth = $this->usedStrokeWidth($element) ?? 1.0;
3834        $context = new ContextElementPaint($element->fill(), $element->stroke());
3835        $last = count($vertices) - 1;
3836        foreach ($vertices as $index => $vertex) {
3837            // A shape reduced to a single vertex (a lone `moveto`) has
3838            // an END vertex and no start: there is no segment for a
3839            // start marker to sit at the beginning of.
3840            $marker = match (true) {
3841                $index === 0 && $last > 0 => $start,
3842                $index === $last => $end,
3843                default => $mid,
3844            };
3845            if ($marker === null) {
3846                continue;
3847            }
3848            // SVG 2 Â§11.6.3 â€” `orient` is either a FIXED angle, which
3849            // ignores the path entirely, or `auto` / `auto-start-reverse`,
3850            // which face along it.
3851            $orient = $marker->orient();
3852            $angle = is_float($orient) ? $orient : $vertex->angle();
3853            if ($orient === 'auto-start-reverse' && $index === 0 && $last > 0) {
3854                // `auto-start-reverse` is `auto` everywhere except the
3855                // start vertex, where the marker is turned to face back
3856                // down the path. That is what lets one arrowhead
3857                // definition serve both ends of a line.
3858                $angle += 180.0;
3859            }
3860            $this->paintMarkerInstance($marker, $vertex, $angle, $strokeWidth, $context, $stream);
3861        }
3862    }
3863
3864    /**
3865     * Resolve a `marker-*` property value to the `<marker>` it names.
3866     *
3867     * `none`, a malformed value, a reference that misses, and a
3868     * reference to something that is not a `<marker>` all resolve to
3869     * "no marker" â€” SVG 2 Â§11.6.2 gives the property no fallback
3870     * syntax, so an unusable reference simply paints nothing.
3871     */
3872    private function resolveMarker(?string $value): ?Marker
3873    {
3874        if ($value === null || $this->document === null) {
3875            return null;
3876        }
3877        if (preg_match('/^url\(\s*(["\']?)#([^"\')\s]+)\1\s*\)$/i', trim($value), $m) !== 1) {
3878            return null;
3879        }
3880        $target = $this->document->findByFragment($m[2]);
3881        return $target instanceof Marker ? $target : null;
3882    }
3883
3884    /**
3885     * Paint one marker instance at one vertex.
3886     *
3887     * The placement transform is, outermost first (SVG 2 Â§11.6.3):
3888     *
3889     *   1. `translate(vertex)` â€” put the marker on the vertex.
3890     *   2. `rotate(angle)` â€” turn it along the path direction.
3891     *   3. `scale(strokeWidth)` when `markerUnits="strokeWidth"`.
3892     *   4. `translate(-refPoint)` â€” line the marker's reference point
3893     *      up with the vertex, where the reference point is `refX` /
3894     *      `refY` mapped through the `viewBox` transform, because
3895     *      Â§11.6.3 defines them in the marker CONTENT's coordinate
3896     *      system.
3897     *
3898     * What is left after that is the marker viewport: a
3899     * `markerWidth` x `markerHeight` rectangle at the origin, which
3900     * clips the content unless `overflow` says otherwise (the UA
3901     * stylesheet puts `overflow: hidden` on `marker`), and into which
3902     * a `viewBox` maps under `preserveAspectRatio` exactly as it does
3903     * for a nested `<svg>`.
3904     */
3905    private function paintMarkerInstance(
3906        Marker $marker,
3907        MarkerVertex $vertex,
3908        float $angle,
3909        float $strokeWidth,
3910        ContextElementPaint $context,
3911        ContentStream $stream,
3912    ): void {
3913        $markerWidth = $marker->markerWidth();
3914        $markerHeight = $marker->markerHeight();
3915        // SVG 2 Â§11.6.2 â€” "A value of zero disables rendering of the
3916        // element", and a negative value is an error that does the
3917        // same.
3918        if ($markerWidth <= 0.0 || $markerHeight <= 0.0) {
3919            return;
3920        }
3921        $scale = $marker->markerUnits() === 'strokeWidth' ? $strokeWidth : 1.0;
3922        if ($scale <= 0.0) {
3923            return;
3924        }
3925        $viewBox = $marker->viewBox();
3926        $content = null;
3927        $refX = $marker->refX();
3928        $refY = $marker->refY();
3929        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
3930            [$scaleX, $scaleY, $offsetX, $offsetY] = $this->nestedViewBoxTransform(
3931                $marker,
3932                $viewBox[2],
3933                $viewBox[3],
3934                $markerWidth,
3935                $markerHeight,
3936            );
3937            $content = [
3938                $scaleX,
3939                0.0,
3940                0.0,
3941                $scaleY,
3942                $offsetX - $viewBox[0] * $scaleX,
3943                $offsetY - $viewBox[1] * $scaleY,
3944            ];
3945            $refX = $content[0] * $refX + $content[4];
3946            $refY = $content[3] * $refY + $content[5];
3947        }
3948        $radians = deg2rad($angle);
3949        $cos = cos($radians) * $scale;
3950        $sin = sin($radians) * $scale;
3951        $placement = [
3952            $cos,
3953            $sin,
3954            -$sin,
3955            $cos,
3956            $vertex->x - $cos * $refX + $sin * $refY,
3957            $vertex->y - $sin * $refX - $cos * $refY,
3958        ];
3959
3960        $stream->saveGraphicsState();
3961        $stream->concatMatrix(...$placement);
3962        $this->pushMatrix($placement);
3963        // SVG 2 Â§13.2.1 â€” the shape that referenced this marker is the
3964        // context element for any `context-fill` / `context-stroke`
3965        // inside it. Saved and restored rather than just set: markers
3966        // nest (a marker's content can be a shape with markers of its
3967        // own) and the inner one must not keep the outer's context.
3968        $outerContext = $this->contextPaint;
3969        $this->contextPaint = $context;
3970        if (self::viewportClips($marker)) {
3971            $stream->rectangle(0.0, 0.0, $markerWidth, $markerHeight);
3972            $stream->clip()->endPath();
3973        }
3974        $childViewport = ['w' => $markerWidth, 'h' => $markerHeight];
3975        if ($content !== null) {
3976            $stream->concatMatrix(...$content);
3977            $this->pushMatrix($content);
3978            $childViewport = ['w' => $viewBox[2], 'h' => $viewBox[3]];
3979        }
3980        $this->viewportStack[] = $childViewport;
3981        $this->paintChildren($marker, $stream);
3982        array_pop($this->viewportStack);
3983        if ($content !== null) {
3984            $this->popMatrix();
3985        }
3986        $this->popMatrix();
3987        $this->contextPaint = $outerContext;
3988        $stream->restoreGraphicsState();
3989    }
3990
3991    /** Upper bound on tile repetitions, guarding against pathological patterns. */
3992    private const int MAX_PATTERN_TILES = 4096;
3993
3994    /**
3995     * Resolve a `fill` paint to the `<pattern>` it references, or null
3996     * when it isn't a `url(#id)` pointing at a pattern (gradients and
3997     * flat colours take the ordinary paint path).
3998     */
3999    private function resolveFillPattern(?Paint $paint): ?Pattern
4000    {
4001        if (!$paint instanceof Url || $this->document === null) {
4002            return null;
4003        }
4004        $target = $this->document->findByFragment($paint->id);
4005        // SVG 2 Â§13.4 â€” a paint server outside the render tree does not
4006        // resolve; the caller falls through to the `<paint>` fallback.
4007        if (!$target instanceof Pattern || !$target->isInRenderTree()) {
4008            return null;
4009        }
4010        $resolved = $this->resolvePatternTemplate($target);
4011        // CSS Transforms 1 Â§11 â€” a non-invertible `patternTransform`
4012        // invalidates the paint server, so the caller takes the
4013        // ordinary paint path and lands on the `<paint>` fallback.
4014        $transform = $resolved->patternTransform();
4015        if ($transform !== null && !$transform->isInvertible()) {
4016            return null;
4017        }
4018        // SVG 2 Â§13.3 â€” a `<pattern>` with no content is not a usable
4019        // paint server either. Unlike a stop-less gradient it has no
4020        // "paint as none" rule of its own, so Â§13.2's invalid-reference
4021        // handling applies and the fallback is used.
4022        if (!self::hasElementChildren($resolved)) {
4023            return null;
4024        }
4025        return $resolved;
4026    }
4027
4028    /**
4029     * SVG 2 Â§13.3 â€” resolve a `<pattern>`'s template chain.
4030     *
4031     * A pattern may reference another via `href` (or the legacy
4032     * `xlink:href`, which `Pattern::href()` already deprioritises).
4033     * Attributes the referencing pattern does not itself specify are
4034     * inherited from the referenced one, and a pattern with no element
4035     * children of its own paints the referenced pattern's children.
4036     * That is what makes
4037     *
4038     *     <pattern id="Copy" href="#Base"></pattern>
4039     *
4040     * render exactly like `#Base`.
4041     *
4042     * Returns `$pattern` unchanged when it has no `href` (the common
4043     * case, so the walk costs nothing). Otherwise it returns a synthetic
4044     * merged pattern; the merged children keep their ORIGINAL parents so
4045     * style inheritance still reads the defining pattern's cascade.
4046     *
4047     * Cycles (`a -> b -> a`) terminate at the first repeat, so a
4048     * malformed document can't spin the renderer.
4049     */
4050    private function resolvePatternTemplate(Pattern $pattern): Pattern
4051    {
4052        if ($pattern->href() === null || $this->document === null) {
4053            return $pattern;
4054        }
4055        /** @var list<Pattern> $chain */
4056        $chain = [];
4057        $seen = [];
4058        $current = $pattern;
4059        while (count($chain) < self::MAX_PATTERN_TEMPLATE_DEPTH) {
4060            $id = spl_object_id($current);
4061            if (isset($seen[$id])) {
4062                break;
4063            }
4064            $seen[$id] = true;
4065            $chain[] = $current;
4066            $href = $current->href();
4067            if ($href === null || !str_starts_with($href, '#')) {
4068                break;
4069            }
4070            $next = $this->document->findByFragment(substr($href, 1));
4071            if (!$next instanceof Pattern || !$next->isInRenderTree()) {
4072                break;
4073            }
4074            $current = $next;
4075        }
4076        if (count($chain) < 2) {
4077            return $pattern;
4078        }
4079        $merged = new Pattern();
4080        // Nearest definition wins: seed from the far end of the chain and
4081        // let each closer link overwrite, so the referencing pattern's own
4082        // attributes end up on top.
4083        foreach (array_reverse($chain) as $link) {
4084            foreach (self::PATTERN_INHERITED_ATTRIBUTES as $name) {
4085                $value = $link->getAttribute($name);
4086                if ($value !== null) {
4087                    $merged->setAttribute($name, $value);
4088                }
4089            }
4090        }
4091        foreach ($chain as $link) {
4092            if (self::hasElementChildren($link)) {
4093                $merged->children = $link->children;
4094                break;
4095            }
4096        }
4097        return $merged;
4098    }
4099
4100    /**
4101     * SVG 2 Â§13.3 â€” paint `$pattern` into `$element`'s fill region.
4102     *
4103     * The caller has just constructed the shape's path on the stream; we
4104     * turn it into a clip (`W n`) and replicate the pattern tile across
4105     * the shape's bounding box inside that clip. Because this runs inside
4106     * the element's own `q cm â€¦ Q` transform wrap (see paintElement), the
4107     * tiles inherit the element's user-space transform automatically â€”
4108     * which is why we tile as ordinary geometry rather than emitting a
4109     * PDF tiling pattern (whose `/Matrix` is anchored to the page's
4110     * default coordinate system and ignores the fill-time CTM).
4111     *
4112     * A stroke, if present, re-emits the path after the fill (the clip
4113     * consumed the original current path).
4114     */
4115    private function paintPatternFill(
4116        Pattern $pattern,
4117        Element $element,
4118        ContentStream $stream,
4119        ?Paint $stroke,
4120    ): void {
4121        $bbox = BoundingBox::compute($element, viewport: $this->currentViewport());
4122        $tile = $bbox !== null ? $this->resolvePatternTile($pattern, $bbox) : null;
4123        // Whitespace between the tags of an otherwise empty `<pattern>`
4124        // parses as a text node, so "has children" has to mean "has
4125        // ELEMENT children" - otherwise `<pattern id="x">\n</pattern>`
4126        // reads as content-bearing and paints an empty tile.
4127        $hasContent = self::hasElementChildren($pattern);
4128        if ($bbox === null || $tile === null || !$hasContent) {
4129            // Nothing paintable â€” drop the current path (avoid a stray
4130            // fill) but still honour a stroke channel if the shape has one.
4131            $stream->endPath();
4132            $this->strokeAfterPattern($element, $stream, $stroke);
4133            return;
4134        }
4135
4136        $stream->saveGraphicsState();
4137        $stream->clip()->endPath(); // W n â€” shape path becomes the clip
4138        $this->emitPatternTiles($pattern, $tile, $bbox, $stream);
4139        $stream->restoreGraphicsState();
4140
4141        $this->strokeAfterPattern($element, $stream, $stroke);
4142    }
4143
4144    /**
4145     * Whether `$element` has at least one child ELEMENT - as opposed to
4146     * the whitespace text node that source formatting leaves inside an
4147     * empty container.
4148     */
4149    private static function hasElementChildren(Element $element): bool
4150    {
4151        foreach ($element->children as $child) {
4152            if ($child instanceof Element) {
4153                return true;
4154            }
4155        }
4156        return false;
4157    }
4158
4159    /**
4160     * Resolve the pattern's tile rectangle in the referencing element's
4161     * user space. `patternUnits="objectBoundingBox"` (the default) reads
4162     * x/y/width/height as fractions of the element bbox; `userSpaceOnUse`
4163     * reads them as plain user units.
4164     *
4165     * @param array{minX: float, minY: float, width: float, height: float} $bbox
4166     * @return array{x: float, y: float, w: float, h: float}|null
4167     */
4168    private function resolvePatternTile(Pattern $pattern, array $bbox): ?array
4169    {
4170        if ($pattern->patternUnits() === 'objectBoundingBox') {
4171            $x = $bbox['minX'] + $pattern->x() * $bbox['width'];
4172            $y = $bbox['minY'] + $pattern->y() * $bbox['height'];
4173            $w = $pattern->width() * $bbox['width'];
4174            $h = $pattern->height() * $bbox['height'];
4175        } else {
4176            $x = $pattern->x();
4177            $y = $pattern->y();
4178            $w = $pattern->width();
4179            $h = $pattern->height();
4180        }
4181        return $w > 0.0 && $h > 0.0 ? ['x' => $x, 'y' => $y, 'w' => $w, 'h' => $h] : null;
4182    }
4183
4184    /**
4185     * Replicate the pattern tile across the shape's bounding box. Each
4186     * cell is translated onto the tile grid, clipped to the tile rect so
4187     * content can't bleed into neighbours (SVG 2 Â§13.3), and its children
4188     * painted. The base cell (i=j=0) paints children at their authored
4189     * coordinates; further cells translate by whole tile steps.
4190     *
4191     * @param array{x: float, y: float, w: float, h: float} $tile
4192     * @param array{minX: float, minY: float, width: float, height: float} $bbox
4193     */
4194    private function emitPatternTiles(
4195        Pattern $pattern,
4196        array $tile,
4197        array $bbox,
4198        ContentStream $stream,
4199    ): void {
4200        $pw = $tile['w'];
4201        $ph = $tile['h'];
4202        // SVG 2 Â§13.3 â€” `patternTransform` moves the whole tiling
4203        // lattice while the shape it fills stays put. Concatenating it
4204        // once around the tile loop is the lattice half; the range the
4205        // loop covers has to be computed in the PATTERN's own space,
4206        // which is the shape's bounding box mapped back through the
4207        // inverse. Skipping that left the near edge of the shape
4208        // unpainted whenever the transform pushed the lattice forward.
4209        $patternMatrix = $pattern->patternTransform()?->toMatrix();
4210        $inverse = $patternMatrix === null ? null : self::invertAffine($patternMatrix);
4211        if ($patternMatrix !== null && $inverse !== null) {
4212            $stream->saveGraphicsState();
4213            $stream->concatMatrix(
4214                $patternMatrix[0],
4215                $patternMatrix[1],
4216                $patternMatrix[2],
4217                $patternMatrix[3],
4218                $patternMatrix[4],
4219                $patternMatrix[5],
4220            );
4221            $bbox = self::transformBoundingBox($bbox, $inverse);
4222        }
4223        $contentMatrix = $this->patternContentMatrix($pattern, $tile, $bbox);
4224        $i0 = (int) floor(($bbox['minX'] - $tile['x']) / $pw);
4225        $i1 = (int) ceil(($bbox['minX'] + $bbox['width'] - $tile['x']) / $pw);
4226        $j0 = (int) floor(($bbox['minY'] - $tile['y']) / $ph);
4227        $j1 = (int) ceil(($bbox['minY'] + $bbox['height'] - $tile['y']) / $ph);
4228        if (($i1 - $i0) * ($j1 - $j0) <= self::MAX_PATTERN_TILES) {
4229            for ($j = $j0; $j < $j1; $j++) {
4230                for ($i = $i0; $i < $i1; $i++) {
4231                    $stream->saveGraphicsState();
4232                    $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $i * $pw, $j * $ph);
4233                    $stream->rectangle($tile['x'], $tile['y'], $pw, $ph);
4234                    $stream->clip()->endPath();
4235                    // SVG 2 Â§13.3 â€” a `<pattern>` `viewBox` establishes
4236                    // a coordinate system for its content, mapped into
4237                    // the tile rectangle under `preserveAspectRatio`,
4238                    // exactly as a nested `<svg>` maps into its
4239                    // viewport.
4240                    if ($contentMatrix !== null) {
4241                        $stream->concatMatrix(
4242                            $contentMatrix[0],
4243                            0.0,
4244                            0.0,
4245                            $contentMatrix[1],
4246                            $contentMatrix[2],
4247                            $contentMatrix[3],
4248                        );
4249                    }
4250                    $this->paintChildren($pattern, $stream);
4251                    $stream->restoreGraphicsState();
4252                }
4253            }
4254        }
4255        if ($patternMatrix !== null && $inverse !== null) {
4256            $stream->restoreGraphicsState();
4257        }
4258    }
4259
4260    /**
4261     * The viewBox-to-tile mapping for a `<pattern>`'s content, as
4262     * `[scaleX, scaleY, translateX, translateY]`, or null when the
4263     * pattern declares no usable `viewBox`.
4264     *
4265     * SVG 2 Â§13.3 â€” the mapping is the same one a nested `<svg>`
4266     * applies, `preserveAspectRatio` included, with the tile rectangle
4267     * standing in for the viewport. A degenerate viewBox (zero width
4268     * or height) would divide by zero, so it is ignored rather than
4269     * emitting a NaN matrix.
4270     *
4271     * @param array{x: float, y: float, w: float, h: float} $tile
4272     * @param array{minX: float, minY: float, width: float, height: float} $bbox
4273     * @return array{float, float, float, float}|null
4274     */
4275    private function patternContentMatrix(Pattern $pattern, array $tile, array $bbox): ?array
4276    {
4277        $viewBox = $pattern->viewBox();
4278        if ($viewBox === null || $viewBox[2] <= 0.0 || $viewBox[3] <= 0.0) {
4279            // SVG 2 Â§13.3 â€” `patternContentUnits="objectBoundingBox"`
4280            // puts the tile's CONTENT in bounding-box units: the origin
4281            // is the box's own corner and one unit is its full extent,
4282            // so `<rect width="1" height="1"/>` covers the shape. The
4283            // attribute is ignored when a `viewBox` is present, which
4284            // establishes its own coordinate system instead â€” hence
4285            // this sitting on the no-viewBox branch.
4286            if ($pattern->patternContentUnits() !== 'objectBoundingBox'
4287                || $bbox['width'] <= 0.0
4288                || $bbox['height'] <= 0.0
4289            ) {
4290                return null;
4291            }
4292            return [
4293                $bbox['width'],
4294                $bbox['height'],
4295                $bbox['minX'],
4296                $bbox['minY'],
4297            ];
4298        }
4299        [$scaleX, $scaleY, $offsetX, $offsetY]
4300            = $this->nestedViewBoxTransform($pattern, $viewBox[2], $viewBox[3], $tile['w'], $tile['h']);
4301        return [
4302            $scaleX,
4303            $scaleY,
4304            $tile['x'] + $offsetX - $viewBox[0] * $scaleX,
4305            $tile['y'] + $offsetY - $viewBox[1] * $scaleY,
4306        ];
4307    }
4308
4309    /**
4310     * Inverse of an affine `[a, b, c, d, e, f]`, or null when it is
4311     * singular. A singular `patternTransform` is rejected earlier as an
4312     * invalid paint server, so the null branch is a belt-and-braces
4313     * guard against a divide by zero.
4314     *
4315     * @param array{float, float, float, float, float, float} $m
4316     * @return array{float, float, float, float, float, float}|null
4317     */
4318    private static function invertAffine(array $m): ?array
4319    {
4320        [$a, $b, $c, $d, $e, $f] = $m;
4321        $determinant = $a * $d - $b * $c;
4322        if ($determinant === 0.0) {
4323            return null;
4324        }
4325        return [
4326            $d / $determinant,
4327            -$b / $determinant,
4328            -$c / $determinant,
4329            $a / $determinant,
4330            ($c * $f - $d * $e) / $determinant,
4331            ($b * $e - $a * $f) / $determinant,
4332        ];
4333    }
4334
4335    /**
4336     * The axis-aligned bounding box of `$bbox` mapped through `$m` â€”
4337     * all four corners, since a rotation or skew turns the rectangle
4338     * into a parallelogram.
4339     *
4340     * @param array{minX: float, minY: float, width: float, height: float} $bbox
4341     * @param array{float, float, float, float, float, float} $m
4342     * @return array{minX: float, minY: float, width: float, height: float}
4343     */
4344    private static function transformBoundingBox(array $bbox, array $m): array
4345    {
4346        [$a, $b, $c, $d, $e, $f] = $m;
4347        $xs = [];
4348        $ys = [];
4349        foreach (
4350            [
4351                [$bbox['minX'], $bbox['minY']],
4352                [$bbox['minX'] + $bbox['width'], $bbox['minY']],
4353                [$bbox['minX'], $bbox['minY'] + $bbox['height']],
4354                [$bbox['minX'] + $bbox['width'], $bbox['minY'] + $bbox['height']],
4355            ] as [$x, $y]
4356        ) {
4357            $xs[] = $a * $x + $c * $y + $e;
4358            $ys[] = $b * $x + $d * $y + $f;
4359        }
4360        return [
4361            'minX' => min($xs),
4362            'minY' => min($ys),
4363            'width' => max($xs) - min($xs),
4364            'height' => max($ys) - min($ys),
4365        ];
4366    }
4367
4368    /**
4369     * Stroke the shape after a pattern fill. The pattern path was
4370     * consumed by the clip, so re-emit it and stroke when the element
4371     * carries a real stroke paint.
4372     */
4373    private function strokeAfterPattern(Element $element, ContentStream $stream, ?Paint $stroke): void
4374    {
4375        if ($stroke === null || $stroke instanceof None_) {
4376            return;
4377        }
4378        if (!$this->applyStrokePaint($stroke, $element, $stream)) {
4379            return;
4380        }
4381        $this->emitElementPath($element, $stream);
4382        $stream->stroke();
4383    }
4384
4385    /**
4386     * Configure the fill colour and report whether the element wants a
4387     * fill at all. Default (null paint) = SVG-spec black fill; explicit
4388     * `none` = no fill; `currentColor` resolves to black at 3K (the
4389     * cascade-resolved `color` lands later). Gradient/pattern `url(#…)`
4390     * is deferred to 3O.
4391     */
4392    private function applyFillPaint(?Paint $paint, Element $element, ContentStream $stream): bool
4393    {
4394        if ($paint instanceof None_) {
4395            return false;
4396        }
4397        if ($paint instanceof Url) {
4398            if ($this->gradientPainter?->applyAsFill($paint->id, $element, $stream, $this->currentMatrix(), $this->currentViewport()) ?? false) {
4399                return true;
4400            }
4401            // SVG 2 Â§13.4 â€” a gradient that resolves but defines no
4402            // stops paints as `none`. The server EXISTS and says
4403            // "paint nothing", so this is not a failed reference and
4404            // the fallback must not overpaint what's underneath.
4405            if ($this->gradientPainter?->paintsAsNone($paint->id) ?? false) {
4406                return false;
4407            }
4408            // SVG 2 Â§13.2 â€” the reference didn't resolve to a usable
4409            // paint server, so the fallback paint applies. With NO
4410            // fallback the element is in error and is simply not
4411            // rendered: several WPT fixtures stack a correct shape
4412            // underneath and rely on the broken one staying invisible,
4413            // so falling through to the black default here would paint
4414            // over them.
4415            if ($paint->fallback === null) {
4416                return false;
4417            }
4418            return $this->applyFillPaint($paint->fallback, $element, $stream);
4419        }
4420        if ($paint instanceof SolidColor) {
4421            $this->setFillColor($stream, $paint->color);
4422            return true;
4423        }
4424        if ($paint instanceof CurrentColor) {
4425            $this->setFillColor($stream, $this->currentColorOf($element));
4426            return true;
4427        }
4428        if ($paint instanceof ContextPaint) {
4429            // SVG 2 Â§13.2.1 â€” outside a referencing context there is
4430            // no context element, and the keyword resolves to `none`.
4431            $resolved = $this->contextPaint?->of($paint);
4432            return $resolved !== null
4433                && $this->applyFillPaint($resolved, $element, $stream);
4434        }
4435        // null â†’ SVG 2 Â§13.2.1 default of black.
4436        $stream->setFillColorRGB(0.0, 0.0, 0.0);
4437        return true;
4438    }
4439
4440    /**
4441     * CSS Color 4 Â§3.2 â€” the used value of `currentColor` is the
4442     * computed value of the element's own `color` property, whose
4443     * initial value is black.
4444     *
4445     * `color` inherits, and the cascade projection is what carries an
4446     * ancestor's declaration down to the element, so this reads the
4447     * element's own resolved value rather than walking parents here.
4448     */
4449    private function currentColorOf(Element $element): ColorInterface
4450    {
4451        $raw = $element->colorValue();
4452        if ($raw !== null) {
4453            $color = SvgColor::parse($raw);
4454            if ($color !== null) {
4455                return $color;
4456            }
4457        }
4458        return new RgbColor(0, 0, 0);
4459    }
4460
4461    /**
4462     * Configure the stroke colour and report whether the element wants
4463     * to stroke. Default (null paint) = SVG-spec "no stroke"; explicit
4464     * `none` = no stroke.
4465     */
4466    private function applyStrokePaint(?Paint $paint, Element $element, ContentStream $stream): bool
4467    {
4468        if ($paint === null || $paint instanceof None_) {
4469            return false;
4470        }
4471        if ($paint instanceof Url) {
4472            if ($this->gradientPainter?->applyAsStroke($paint->id, $element, $stream, $this->currentMatrix(), $this->currentViewport()) ?? false) {
4473                return true;
4474            }
4475            // SVG 2 Â§13.4, as in applyFillPaint(): a stop-less gradient
4476            // paints as `none`, which is not a failed reference.
4477            if ($this->gradientPainter?->paintsAsNone($paint->id) ?? false) {
4478                return false;
4479            }
4480            // SVG 2 Â§13.2, as in applyFillPaint(): fallback, or don't
4481            // stroke at all.
4482            if ($paint->fallback === null) {
4483                return false;
4484            }
4485            return $this->applyStrokePaint($paint->fallback, $element, $stream);
4486        }
4487        if ($paint instanceof SolidColor) {
4488            $this->setStrokeColor($stream, $paint->color);
4489            return true;
4490        }
4491        if ($paint instanceof ContextPaint) {
4492            $resolved = $this->contextPaint?->of($paint);
4493            return $resolved !== null
4494                && $this->applyStrokePaint($resolved, $element, $stream);
4495        }
4496        $this->setStrokeColor($stream, $this->currentColorOf($element));
4497        return true;
4498    }
4499
4500    private function setFillColor(ContentStream $stream, ColorInterface $color): void
4501    {
4502        match (true) {
4503            $color instanceof RgbColor => $stream->setFillRgbColor($color),
4504            $color instanceof CmykColor => $stream->setFillCmykColor($color),
4505            $color instanceof GrayColor => $stream->setFillGrayColor($color),
4506            default => $stream->setFillColorRGB(0.0, 0.0, 0.0),
4507        };
4508    }
4509
4510    private function setStrokeColor(ContentStream $stream, ColorInterface $color): void
4511    {
4512        match (true) {
4513            $color instanceof RgbColor => $stream->setStrokeRgbColor($color),
4514            $color instanceof CmykColor => $stream->setStrokeCmykColor($color),
4515            $color instanceof GrayColor => $stream->setStrokeGrayColor($color),
4516            default => $stream->setStrokeColorRGB(0.0, 0.0, 0.0),
4517        };
4518    }
4519}