Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.61% covered (warning)
68.61%
2142 / 3122
24.34% covered (danger)
24.34%
37 / 152
CRAP
0.00% covered (danger)
0.00%
0 / 1
Painter
68.61% covered (warning)
68.61%
2142 / 3122
24.34% covered (danger)
24.34%
37 / 152
41694.97
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
 __destruct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 paint
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 resolveOverflowPropagation
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 boxOverflowIsVisible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 paintCanvasBackgroundFromRoot
64.29% covered (warning)
64.29%
27 / 42
0.00% covered (danger)
0.00%
0 / 1
16.51
 propagatedOriginRect
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 boxHasPaintableBackground
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 resolveColorWithCurrentColor
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
11.36
 preferredLightDarkArm
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 resolveRelativeColor
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
156
 resolveSlotPermutation
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
156
 srgbToHsl
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 srgbToHwb
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 hslToRgb
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 hwbToRgb
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 relativeColorSlotIdents
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 isIdentMatching
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isAlphaSlotOrOne
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 findBodyChild
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 boxIsPaintContained
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 containKeywordImpliesPaint
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 paintBox
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
15
 boxEntirelyOffPage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 applyBoxTransform
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 composeTransformMatrix
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 transformFunctionToPdfMatrix
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
9.42
 multiplyMatrices
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 resolveTransformOrigin
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 resolveOriginComponent
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
28.66
 lengthOrPercentageToFloat
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 shouldClampDecorationsToPage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 isCloneDecorationBreak
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 clampGeometryToPage
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 isFloated
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintImage
90.28% covered (success)
90.28%
65 / 72
0.00% covered (danger)
0.00%
0 / 1
27.67
 objectFitKeyword
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveObjectFit
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
6.01
 resolveImageSrc
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 fetchHttpSrc
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
6.14
 materializeDataUrl
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
7.60
 resolveBackgroundClip
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveBackgroundOrigin
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 backgroundOriginRect
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 shouldOverflowClip
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 axisClips
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 originalAxisClips
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 emitOverflowClipPath
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 resolveClipRect
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
7
 applyClipPath
45.00% covered (danger)
45.00%
27 / 60
0.00% covered (danger)
0.00%
0 / 1
52.43
 shapeLengthPercent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 emitEllipsePath
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 positionComponent
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
72
 circleRadius
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 ellipseRadius
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 clipEdgePx
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 isBackfaceHidden
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
8.22
 isVisibilityHidden
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintBoxShadow
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
10.04
 paintInsetShadow
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 paintFilterDropShadow
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 collectDropShadowFilters
40.00% covered (danger)
40.00%
8 / 20
0.00% covered (danger)
0.00%
0 / 1
31.60
 parseDropShadowArgs
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
10.71
 collectShadowLayers
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 parseShadowLayer
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
10.03
 resolveOpacityGsName
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 paintListMarker
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
11
 formatCounterMarker
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 listItemIndex
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
17.47
 paintCounterMarker
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
5.03
 paintMarkerSquare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 paintMarkerCircle
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 dominantFontSize
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 paintLineBoxes
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 paintInlineBackgrounds
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
12.05
 collectTextShadowLayers
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
12.07
 paintLine
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 paintTextDecorations
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
13.36
 emitDecorationStyled
53.33% covered (warning)
53.33%
16 / 30
0.00% covered (danger)
0.00%
0 / 1
14.50
 emitWavyDecoration
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
5
 resolveDecorationThickness
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveUnderlineOffset
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 textDecorationStyle
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 textDecorationLines
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
8.19
 textDecorationColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 paintFragment
87.30% covered (warning)
87.30%
55 / 63
0.00% covered (danger)
0.00%
0 / 1
16.52
 collectBlockLinkRect
36.00% covered (danger)
36.00%
9 / 25
0.00% covered (danger)
0.00%
0 / 1
36.21
 snapKern
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 paintBackground
90.68% covered (success)
90.68%
107 / 118
0.00% covered (danger)
0.00%
0 / 1
31.78
 extractBackgroundLayers
50.00% covered (danger)
50.00%
9 / 18
0.00% covered (danger)
0.00%
0 / 1
38.50
 imageFunctionColorArg
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
11.08
 paintColorImage
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
9.29
 extractCommaList
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 paintRadialGradient
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
9.13
 resolveGradientStops
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
16
 resolveRepeatingLinearCycle
65.91% covered (warning)
65.91%
29 / 44
0.00% covered (danger)
0.00%
0 / 1
26.14
 buildRepeatingLinearAxis
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
8.03
 paintLinearGradient
93.33% covered (success)
93.33%
56 / 60
0.00% covered (danger)
0.00%
0 / 1
10.03
 paintBackgroundImage
90.10% covered (success)
90.10%
91 / 101
0.00% covered (danger)
0.00%
0 / 1
21.43
 repeatAxes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 repeatModes
54.55% covered (warning)
54.55%
12 / 22
0.00% covered (danger)
0.00%
0 / 1
32.41
 roundTileDim
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 resolveBackgroundPosition
86.49% covered (warning)
86.49%
32 / 37
0.00% covered (danger)
0.00%
0 / 1
16.63
 axisOffsetFromValue
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
13.34
 resolveBackgroundSize
69.62% covered (warning)
69.62%
55 / 79
0.00% covered (danger)
0.00%
0 / 1
35.57
 backgroundSizeAxisLength
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 isNoRepeat
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 isDefaultGradientSize
20.00% covered (danger)
20.00%
3 / 15
0.00% covered (danger)
0.00%
0 / 1
50.47
 computeGradientTileRect
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
2.00
 resolveGradientTileSize
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
15.00
 resolveAutoSizePair
45.45% covered (danger)
45.45%
10 / 22
0.00% covered (danger)
0.00%
0 / 1
35.37
 intrinsicSizePartial
22.22% covered (danger)
22.22%
4 / 18
0.00% covered (danger)
0.00%
0 / 1
57.05
 intrinsicSize
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
12.33
 isSvgSrc
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 loadSvgDocument
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 decodeSvgDataUri
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 intrinsicSvgSize
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
51.77
 parseSvgLengthAttribute
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 paintBorderImage
4.03% covered (danger)
4.03%
5 / 124
0.00% covered (danger)
0.00%
0 / 1
825.46
 emitImageSlice
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 emitImageEdge
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
156
 parseBorderImageSliceNumber
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 resolveBorderImageSliceComponent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 resolveBorderImageSliceSides
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
182
 parseBorderImageRepeat
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
90
 svgRenderer
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 inlineSvgAdapter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 inlineMathmlAdapter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 mathmlRenderer
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 mathFontDataFor
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
156
 mathmlRendererFor
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 paintInlineMath
86.36% covered (warning)
86.36%
38 / 44
0.00% covered (danger)
0.00%
0 / 1
16.65
 resolveInlineAbsoluteOrigin
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
16.67
 paintImgSvg
65.79% covered (warning)
65.79%
25 / 38
0.00% covered (danger)
0.00%
0 / 1
14.00
 paintInlineSvg
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
19.36
 parseViewBox
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 parseSvgLength
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
 paintBorders
79.41% covered (warning)
79.41%
54 / 68
0.00% covered (danger)
0.00%
0 / 1
14.47
 paintBorderSide
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
11
 paintDashedDottedSide
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 dashedSideEndpoints
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 resolve3dBorderColor
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 borderStyleName
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 bordersAreUniform
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
13.61
 emitRoundedStroke
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
30
 paintOutline
81.54% covered (warning)
81.54%
53 / 65
0.00% covered (danger)
0.00%
0 / 1
25.05
 paintColumnRules
81.82% covered (warning)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
11.73
 borderIsVisible
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 borderColor
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 emitRect
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 borderRadii
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 emitRoundedFill
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Painter;
6
7use Phpdftk\Css\Cascade\WritingMode;
8use Phpdftk\Css\Value\Color;
9use Phpdftk\Css\Value\Keyword;
10use Phpdftk\HtmlToPdf\Box\Box;
11use Phpdftk\HtmlToPdf\Layout\BoxGeometry;
12use Phpdftk\HtmlToPdf\Layout\InlineFragment;
13use Phpdftk\HtmlToPdf\Layout\LineBox;
14use Phpdftk\Pdf\Core\Content\ContentStream;
15use Phpdftk\Pdf\Core\Font\RegisteredFont;
16use Phpdftk\Pdf\Writer\Font as WriterFont;
17use Phpdftk\Pdf\Writer\Page as WriterPage;
18use Phpdftk\Pdf\Writer\PdfWriter;
19use Phpdftk\ResourceLoader\Exception\FetchFailedException;
20use Phpdftk\ResourceLoader\Exception\SsrfBlockedException;
21use Phpdftk\ResourceLoader\ResourceLoader as HttpResourceLoader;
22
23/**
24 * Phase 1G — paints a laid-out box tree onto a {@see ContentStream}.
25 *
26 * The painter walks the box tree depth-first and emits PDF operators for
27 * each box's visual contributions: background colour (rect + fill), then
28 * border edges (four straight strokes one per side, honouring per-side
29 * widths and colours), then recurses into children. Text rendering uses
30 * the line-box / shaped-glyph data deposited by {@see InlineLayout};
31 * Phase 1G.1 ships background + border painting and leaves text as a
32 * follow-up that depends on `@font-face` integration (1M) — for now line
33 * boxes are walked but text painting is a no-op, so the painter exercises
34 * end-to-end without requiring a font registration.
35 *
36 * **Coordinate-system flip**: the layout uses PDF user-space units but
37 * with Y growing downward from the top of the page (the convention of CSS
38 * and every other layout engine). PDF's native content-stream coordinates
39 * grow upward from the bottom. The painter flips Y when emitting
40 * rectangles so consumers see PDF-correct output; the underlying box
41 * geometry stays in top-down space for layout sanity.
42 */
43final class Painter
44{
45    public function __construct(
46        private readonly float $pageHeight,
47        private readonly ?RegisteredFont $defaultFont = null,
48        private readonly ?WriterPage $page = null,
49        /**
50         * Layout-Y range this page covers. When set, the painter skips
51         * any box whose geometry sits entirely above or entirely below
52         * this range — a multi-page document no longer re-paints every
53         * box on every page, just the ones intersecting the current
54         * page slot.
55         */
56        private readonly ?float $pageRangeStart = null,
57        private readonly ?float $pageRangeEnd = null,
58        /**
59         * When set, the painter can register Image XObjects via the
60         * writer's `addImage` and emit `Do` for `<img>` elements whose
61         * `src` is a `data:image/{png,jpeg}` URL. When null, image
62         * painting is a no-op (the alt-text fallback still flows).
63         */
64        private readonly ?PdfWriter $writer = null,
65        /**
66         * Base directory for resolving relative `<img src>` paths against
67         * the filesystem. When null, only `data:` URLs paint.
68         */
69        private readonly ?string $baseDir = null,
70        /**
71         * Optional broader sandbox the resolved path must remain
72         * under. Defaults to `baseDir`. Set wider when relative
73         * URLs are expected to escape `baseDir` via `..` walks —
74         * e.g. WPT refs in `reference/` loading `../support/img.png`.
75         */
76        private readonly ?string $sandboxRoot = null,
77        /**
78         * Map of `postScriptName → RegisteredFont` keyed by the font's
79         * raw PS name. Used to switch `Tf` per fragment when an inline
80         * subtree shaped against an alternate font from the `FontResolver`.
81         * Defaults to `[$defaultFont->postScriptName => $defaultFont]`
82         * when only the default is registered.
83         *
84         * @var array<string, RegisteredFont>
85         */
86        private readonly array $registeredFonts = [],
87        /**
88         * Map of `lowercase font-family → OpenTypeData`. Parallel to
89         * `$registeredFonts` (which carries PDF-side handles); this
90         * carries the parsed-font side so inline foreign-content
91         * painters can hand the full font data to embedded
92         * renderers (e.g. paintInlineMath threads the math element's
93         * resolved font into MathmlRenderer so it picks up the
94         * font's MATH-table constants like FractionRuleThickness).
95         *
96         * Keyed by lowercase family name to match how Renderer
97         * builds its `$fontMap` from @font-face and
98         * RendererOptions::fontMap.
99         *
100         * @var array<string, \Phpdftk\FontParser\FontFaceData>
101         */
102        private readonly array $fontDataByFamily = [],
103        /**
104         * Page width in PDF user-space units. Used by per-axis
105         * `overflow-x` / `overflow-y` clipping to extend the clip
106         * rect across the unconstrained axis. Defaults to a value
107         * large enough that any reasonable page-width effectively
108         * disables horizontal clipping when only the Y axis clips.
109         */
110        private readonly float $pageWidth = 100000.0,
111        /**
112         * Optional `phpdftk/resource-loader` for `http(s)://`
113         * `<img src>`, `<picture><source>`, `<iframe src>` etc.
114         * hrefs. When null (the default — preserves existing
115         * behaviour byte-for-byte), network hrefs drop silently per
116         * the same SVG 2 §12.6 / image-loading no-image outcome
117         * pattern. When supplied, the loader runs (with its SSRF
118         * guard, redirect handling, body cap, and MIME sniffing)
119         * and the embedded bytes get materialised to a temp file
120         * the same way `data:` URLs do.
121         */
122        private readonly ?HttpResourceLoader $resourceLoader = null,
123    ) {}
124
125    /**
126     * Track tempfile paths created for `data:` URL images so we can
127     * delete them when the Painter is destroyed.
128     *
129     * @var list<string>
130     */
131    private array $tempImagePaths = [];
132
133    /**
134     * Cache `data:` URL → registered XObject resource name for the
135     * current page, so the same image used multiple times only spills
136     * + registers once.
137     *
138     * @var array<string, string>
139     */
140    private array $imageNameCache = [];
141
142    /**
143     * Cache `src` → parsed SvgDocument (or `false` when parsing failed)
144     * so each unique SVG background-image is only read + parsed once.
145     *
146     * @var array<string, \Phpdftk\Svg\SvgDocument|false>
147     */
148    private array $svgDocumentCache = [];
149
150    /**
151     * Lazy-built SvgRenderer for SVG background-image painting. SVG
152     * resources (gradients, fonts) register on the page on first draw.
153     */
154    private ?\Phpdftk\SvgToPdf\SvgRenderer $svgRenderer = null;
155
156    /**
157     * Lazy-built adapter that converts an inline-SVG HTML DOM subtree
158     * into a typed SvgDocument the renderer can paint. Caches its
159     * results by element identity so a multi-page document only pays
160     * the parse cost once per inline SVG.
161     */
162    private ?\Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter $inlineSvgAdapter = null;
163
164    /**
165     * Sibling of $inlineSvgAdapter for MathML. Both adapters share
166     * {@see \Phpdftk\HtmlToPdf\ForeignContent\DomXmlSerializer} but
167     * keep their own caches so a fixture with both inline SVG and
168     * inline MathML doesn't conflate them.
169     */
170    private ?\Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter $inlineMathmlAdapter = null;
171
172    /**
173     * Lazy-built MathML renderer. Same lifecycle as $svgRenderer —
174     * holds a reference to the writer + page once, registers the
175     * standard fonts on first draw.
176     */
177    private ?\Phpdftk\MathmlToPdf\MathmlRenderer $mathmlRenderer = null;
178
179    public function __destruct()
180    {
181        foreach ($this->tempImagePaths as $path) {
182            if (is_file($path)) {
183                @unlink($path);
184            }
185        }
186    }
187
188    /**
189     * Link rects in PDF coordinates collected during the most recent
190     * {@see paint()} call. Each entry is `{href, llx, lly, urx, ury,
191     * title}` with the Y-flip already applied. The Renderer reads this
192     * list to register `/Link` annotations on the current page.
193     *
194     * @var list<array{href: string, llx: float, lly: float, urx: float, ury: float, title: ?string}>
195     */
196    public array $collectedLinks = [];
197
198    /**
199     * Box whose background was propagated to the canvas this paint
200     * pass (CSS Backgrounds 3 §3.11.2) — either the root or, when the
201     * root's background is transparent and the root is an HTML
202     * document, the first body child. `paintBackground` skips this box
203     * to avoid double-painting at the box's geometry.
204     */
205    private ?Box $propagatedBgBox = null;
206
207    /**
208     * Body box whose `overflow` propagated to the root canvas this
209     * paint pass (CSS Overflow 3 §3.3). When the root's overflow is
210     * `visible` and the body's isn't, the body's value propagates to
211     * the root and the body's OWN overflow is treated as `visible`
212     * for paint purposes. `shouldOverflowClip` suppresses the body's
213     * descendant clip so the test/ref pair (which sets the
214     * post-propagation state on `html` directly) renders identically.
215     */
216    private ?Box $propagatedOverflowBox = null;
217
218    /**
219     * Root box that received the propagated overflow this paint pass.
220     * `axisClips` consults this to apply the body's overflow keyword
221     * to the root (instead of the root's own visible default) so the
222     * canvas gets the spec-mandated post-propagation behaviour.
223     */
224    private ?Box $propagatedOverflowRoot = null;
225
226    public function paint(Box $root, ContentStream $stream): void
227    {
228        $this->collectedLinks = [];
229        $this->imageNameCache = [];
230        // CSS Backgrounds 3 §3.11.2 — if the root has a non-transparent
231        // background, paint the entire canvas with it BEFORE walking the
232        // tree (so descendants paint on top). When the root is
233        // transparent but its body child carries a background, the body
234        // propagates to the canvas instead. The propagated box's own
235        // paint-background pass is suppressed by the propagatedBgBox check.
236        $this->paintCanvasBackgroundFromRoot($root, $stream);
237        // CSS Overflow 3 §3.3 — overflow propagation. When the root's
238        // overflow is `visible` and the body's isn't, the body's
239        // overflow value propagates to the root and the body's own
240        // overflow becomes `visible`. Both sides matter: we suppress
241        // the body's descendant clip AND apply the body's overflow
242        // keyword to the root so the root clips at its content area
243        // (which in our auto-height renderer wraps the body's outer
244        // box). `shouldOverflowClip` / `axisClips` consult the
245        // propagated-* tracking to redirect the per-axis clip.
246        $this->resolveOverflowPropagation($root);
247        $this->paintBox($root, $stream);
248        $this->propagatedBgBox = null;
249        $this->propagatedOverflowBox = null;
250        $this->propagatedOverflowRoot = null;
251    }
252
253    private function resolveOverflowPropagation(Box $root): void
254    {
255        if ($this->boxIsPaintContained($root)) {
256            return;
257        }
258        if (!$this->boxOverflowIsVisible($root)) {
259            // Root itself constrains — no propagation per spec; the
260            // root's overflow applies at the root and the body's
261            // overflow applies at the body normally.
262            return;
263        }
264        $body = $this->findBodyChild($root);
265        if ($body === null || $this->boxIsPaintContained($body)) {
266            return;
267        }
268        if (!$this->boxOverflowIsVisible($body)) {
269            $this->propagatedOverflowBox = $body;
270            $this->propagatedOverflowRoot = $root;
271        }
272    }
273
274    private function boxOverflowIsVisible(Box $box): bool
275    {
276        return !$this->axisClips($box, 'x') && !$this->axisClips($box, 'y');
277    }
278
279    private function paintCanvasBackgroundFromRoot(Box $root, ContentStream $stream): void
280    {
281        $source = $root;
282        // CSS Containment 3 §4.4 — `contain: paint` (or `contain:
283        // layout`, which implies paint containment in the propagation
284        // sense) on the root element creates a stacking + paint
285        // boundary, so neither the root's nor the body's background
286        // propagates to the canvas. Bail out before doing any canvas
287        // paint when the root is paint-contained.
288        if ($this->boxIsPaintContained($source)) {
289            return;
290        }
291        if (!$this->boxHasPaintableBackground($source)) {
292            // CSS Backgrounds 3 §3.11.2 second paragraph — when the root
293            // element of an HTML/XHTML document has a transparent
294            // background, the canvas uses the *first body child's*
295            // background instead, and the body itself paints
296            // transparent. Skipping the body lookup for non-HTML root
297            // elements is harmless: only HTML structure has a `<body>`.
298            $body = $this->findBodyChild($root);
299            if ($body === null
300                || !$this->boxHasPaintableBackground($body)
301                // CSS Containment 3 §4.4 — a paint-contained body does
302                // not propagate either. The body's bg paints at the
303                // body's own geometry, and the canvas stays at the
304                // initial value (transparent).
305                || $this->boxIsPaintContained($body)
306            ) {
307                return;
308            }
309            $source = $body;
310        }
311        $this->propagatedBgBox = $source;
312        $color = $this->resolveColorWithCurrentColor(
313            $source->style->get('background-color'),
314            $source,
315        );
316        $bgImage = $source->style->get('background-image');
317        $hasColor = $color instanceof Color && $color->a > 0.0;
318        $hasImage = $bgImage instanceof \Phpdftk\Css\Value\Url;
319        $hasGradient = $bgImage instanceof \Phpdftk\Css\Value\LinearGradient;
320        $hasRadial = $bgImage instanceof \Phpdftk\Css\Value\RadialGradient;
321        // The canvas rect is the entire page in PDF user-space — the
322        // painter's `pageHeight` is the top, `pageWidth` the right edge.
323        // Layout-Y 0 corresponds to the page top; emitRect handles the
324        // CSS-to-PDF Y flip internally.
325        if ($hasColor) {
326            $this->emitRect($stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight, fill: $color);
327        }
328        if ($hasImage) {
329            $sizeValue = $source->style->get('background-size');
330            $positionValue = $source->style->get('background-position');
331            $repeatValue = $source->style->get('background-repeat');
332            // CSS 2.1 §14.2 / Backgrounds 3 §3.11.2 — a propagated root
333            // background PAINTS over the whole canvas but is POSITIONED /
334            // tiled as if painted for the source element's own box (its
335            // padding box, the default `background-origin`). So the image
336            // anchors at the element's margin offset, not the page corner
337            // (e.g. `repeat-x top left` on an `html` with `margin: 1in`
338            // puts the stripe 1in down, not at y=0).
339            $this->paintBackgroundImage(
340                $bgImage,
341                $stream,
342                0.0,
343                0.0,
344                $this->pageWidth,
345                $this->pageHeight,
346                $sizeValue,
347                $positionValue,
348                $repeatValue,
349                $this->propagatedOriginRect($source),
350            );
351        }
352        if ($hasGradient) {
353            $this->paintLinearGradient($bgImage, $stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight);
354        }
355        if ($hasRadial) {
356            $this->paintRadialGradient($bgImage, $stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight);
357        }
358    }
359
360    /**
361     * The background positioning area for a propagated root/body
362     * background: the source element's padding box (the default
363     * `background-origin`), in layout-Y coordinates. `paintBackgroundImage`
364     * anchors `background-position` / tiling to this rect while painting
365     * over the whole canvas.
366     *
367     * @return array{x: float, top: float, width: float, height: float}
368     */
369    private function propagatedOriginRect(Box $source): array
370    {
371        $g = $source->geometry;
372        return [
373            'x' => $g->x - $g->paddingLeft,
374            'top' => $g->y - $g->paddingTop,
375            'width' => $g->paddingLeft + $g->width + $g->paddingRight,
376            'height' => $g->paddingTop + $g->height + $g->paddingBottom,
377        ];
378    }
379
380    private function boxHasPaintableBackground(Box $box): bool
381    {
382        $color = $this->resolveColorWithCurrentColor(
383            $box->style->get('background-color'),
384            $box,
385        );
386        $bgImage = $box->style->get('background-image');
387        if ($color instanceof Color && $color->a > 0.0) {
388            return true;
389        }
390        return $bgImage instanceof \Phpdftk\Css\Value\Url
391            || $bgImage instanceof \Phpdftk\Css\Value\LinearGradient
392            || $bgImage instanceof \Phpdftk\Css\Value\RadialGradient;
393    }
394
395    /**
396     * If `$value` is the `currentcolor` keyword, resolve it against
397     * the box's `color` property per CSS Color 3 §3.2 / CSS Color 4
398     * §3.6. Other values (already-typed `Color`, `null`, other
399     * keywords) pass through unchanged. The painter calls this at
400     * any property that documents `currentcolor` as a valid value
401     * (`background-color`, `border-*-color` initials, etc.).
402     */
403    private function resolveColorWithCurrentColor(?\Phpdftk\Css\Value\Value $value, Box $box): ?\Phpdftk\Css\Value\Value
404    {
405        // CSS Color 5 §5 — `light-dark(<light>, <dark>)` picks the
406        // arm matching the using element's `color-scheme`. Inspect the
407        // box's resolved `color-scheme` to decide: when the cascaded
408        // value lists `dark` (e.g. `color-scheme: dark` or `color-
409        // scheme: light dark` with dark first), pick the dark arm.
410        // Anything else (including the `normal` initial or
411        // `color-scheme: light`) falls back to the light arm — the
412        // spec's default.
413        if ($value instanceof \Phpdftk\Css\Value\LightDark) {
414            $value = $this->preferredLightDarkArm($box, $value);
415        }
416        if ($value instanceof \Phpdftk\Css\Value\Keyword
417            && strtolower($value->name) === 'currentcolor'
418        ) {
419            $current = $box->style->get('color');
420            if ($current instanceof \Phpdftk\Css\Value\LightDark) {
421                $current = $this->preferredLightDarkArm($box, $current);
422            }
423            $value = $current instanceof Color ? $current : null;
424        }
425        if ($value instanceof \Phpdftk\Css\Value\RelativeColor) {
426            $value = $this->resolveRelativeColor($value, $box);
427        }
428        if ($value instanceof Color && $value->space !== \Phpdftk\Css\Value\ColorSpace::sRGB) {
429            // CSS Color 4 §17 — every wide-gamut / polar / Lab-family
430            // value stores its native components on the Color struct
431            // (`color(display-p3 …)` etc.). Convert to sRGB before the
432            // painter emits `rg` so PDF's DeviceRGB sees in-gamut
433            // values; out-of-gamut components clip at the boundary.
434            return \Phpdftk\Css\Value\ColorConverter::toSrgb($value);
435        }
436        return $value;
437    }
438
439    /**
440     * Pick the appropriate arm of a `light-dark()` expression based on
441     * the box's resolved `color-scheme`. CSS Color 5 §5 — the spec
442     * default is the light arm; `color-scheme: dark` (or a list whose
443     * first preferred scheme is dark) selects the dark arm.
444     */
445    private function preferredLightDarkArm(Box $box, \Phpdftk\Css\Value\LightDark $value): \Phpdftk\Css\Value\Value
446    {
447        $scheme = $box->style->get('color-scheme');
448        $isDark = false;
449        if ($scheme instanceof \Phpdftk\Css\Value\Keyword
450            && strtolower($scheme->name) === 'dark'
451        ) {
452            $isDark = true;
453        } elseif ($scheme instanceof \Phpdftk\Css\Value\ValueList) {
454            foreach ($scheme->values as $entry) {
455                if (!$entry instanceof \Phpdftk\Css\Value\Keyword) {
456                    continue;
457                }
458                $name = strtolower($entry->name);
459                if ($name === 'dark') {
460                    $isDark = true;
461                    break;
462                }
463                if ($name === 'light') {
464                    break;
465                }
466            }
467        }
468        return $isDark ? $value->dark : $value->light;
469    }
470
471    /**
472     * CSS Color 5 §4 relative-color resolution. Returns the resolved
473     * Color or null when the relative-color expression can't be
474     * statically evaluated.
475     *
476     * The common case the WPT relative-currentcolor cluster exercises
477     * is `<colorFn>(from currentColor c1 c2 c3)` where the components
478     * are the bare slot identifiers (`l a b`, `r g b`, …) — that just
479     * round-trips the source through the target color space.
480     */
481    private function resolveRelativeColor(\Phpdftk\Css\Value\RelativeColor $rc, Box $box): ?Color
482    {
483        $source = $rc->source;
484        if ($source instanceof \Phpdftk\Css\Value\Keyword) {
485            $name = strtolower($source->name);
486            if ($name === 'transparent') {
487                $source = new Color(0.0, 0.0, 0.0, 0.0);
488            } elseif ($name === 'currentcolor') {
489                $current = $box->style->get('color');
490                if (!$current instanceof Color) {
491                    return null;
492                }
493                $source = $current;
494            } else {
495                return null;
496            }
497        }
498        // When every component is just its space's slot identifier
499        // (e.g. `lab(from X l a b)`), the relative-color expression
500        // is the identity round-trip — return the source unchanged
501        // and let the painter's sRGB toSrgb path do the final
502        // conversion.
503        $candidates = $this->relativeColorSlotIdents($rc->space);
504        foreach ($candidates as $slotIdents) {
505            if ($this->isIdentMatching($rc->component1, $slotIdents[0])
506                && $this->isIdentMatching($rc->component2, $slotIdents[1])
507                && $this->isIdentMatching($rc->component3, $slotIdents[2])
508                && $this->isAlphaSlotOrOne($rc->alpha)
509            ) {
510                return $source;
511            }
512        }
513        // Slot permutations — every component is a bare slot
514        // identifier from SOME valid slot trio, but the slots are
515        // shuffled (e.g. `rgb(from currentColor g r b)` from WPT
516        // relative-currentcolor-rgb-02). Map each component to the
517        // source's value for that slot and rebuild the target color.
518        foreach ($candidates as $slotIdents) {
519            $permuted = $this->resolveSlotPermutation($rc, $source, $slotIdents);
520            if ($permuted !== null) {
521                return $permuted;
522            }
523        }
524        // More general relative expressions (literal substitutions,
525        // calc() over slot identifiers, alpha overrides) aren't
526        // modelled yet — return null so the rule falls through to
527        // its initial.
528        return null;
529    }
530
531    /**
532     * Resolve a relative-color expression whose components are bare
533     * slot identifiers but possibly shuffled. Returns a new Color
534     * with the source's slot values rearranged, or null when any
535     * component isn't a recognised slot identifier from the given
536     * trio.
537     *
538     * @param array{0:string,1:string,2:string} $slotIdents
539     */
540    private function resolveSlotPermutation(
541        \Phpdftk\Css\Value\RelativeColor $rc,
542        Color $source,
543        array $slotIdents,
544    ): ?Color {
545        // Build slot-name → source-channel-value lookup. For each
546        // syntactic slot trio we convert the sRGB-stored source into
547        // the matching color model so the slot identifiers refer to
548        // the right channels. The result is then converted back to
549        // the source's storage space.
550        if ($slotIdents === ['r', 'g', 'b']) {
551            $sourceChannels = ['r' => $source->r, 'g' => $source->g, 'b' => $source->b];
552            $rebuild = static fn(float $c1, float $c2, float $c3, float $a)
553                => new Color($c1, $c2, $c3, $a, $source->space);
554        } elseif ($slotIdents === ['h', 's', 'l']) {
555            // CSS Color 4 §6 — sRGB→HSL. The slots refer to source's
556            // HSL components; result rebuilds via hslToRgb. Hue is
557            // stored as degrees per CSS, saturation and lightness as
558            // 0–1 fractions.
559            [$h, $s, $l] = self::srgbToHsl($source->r, $source->g, $source->b);
560            $sourceChannels = ['h' => $h, 's' => $s, 'l' => $l];
561            $rebuild = static function (float $c1, float $c2, float $c3, float $a) use ($source): Color {
562                [$r, $g, $b] = self::hslToRgb($c1 / 360.0, $c2, $c3);
563                return new Color($r, $g, $b, $a, $source->space);
564            };
565        } elseif ($slotIdents === ['h', 'w', 'b']) {
566            [$h, $w, $blk] = self::srgbToHwb($source->r, $source->g, $source->b);
567            $sourceChannels = ['h' => $h, 'w' => $w, 'b' => $blk];
568            $rebuild = static function (float $c1, float $c2, float $c3, float $a) use ($source): Color {
569                [$r, $g, $b] = self::hwbToRgb($c1, $c2, $c3);
570                return new Color($r, $g, $b, $a, $source->space);
571            };
572        } else {
573            return null;
574        }
575        if (!$this->isAlphaSlotOrOne($rc->alpha)) {
576            return null;
577        }
578        $resolved = [];
579        foreach ([$rc->component1, $rc->component2, $rc->component3] as $i => $comp) {
580            // Component is EITHER a slot identifier (mapped above) OR
581            // a bare literal number — WPT relative-currentcolor-hsl-02
582            // uses `hsl(from currentColor 120 s l)` where the hue is
583            // a literal `120` (degrees).
584            if ($comp instanceof \Phpdftk\Css\Value\Keyword) {
585                $name = strtolower($comp->name);
586                if (!isset($sourceChannels[$name])) {
587                    return null;
588                }
589                $resolved[$i] = $sourceChannels[$name];
590                continue;
591            }
592            if ($comp instanceof \Phpdftk\Css\Value\Number) {
593                $resolved[$i] = $comp->value;
594                continue;
595            }
596            if ($comp instanceof \Phpdftk\Css\Value\Integer) {
597                $resolved[$i] = (float) $comp->value;
598                continue;
599            }
600            if ($comp instanceof \Phpdftk\Css\Value\Percentage) {
601                // hsl saturation / lightness as a percentage maps to
602                // [0, 1]; for hue or other components the spec keeps
603                // the percentage verbatim but no test we hit takes
604                // that path, so leave the simple 0..1 conversion.
605                $resolved[$i] = $comp->value / 100.0;
606                continue;
607            }
608            return null;
609        }
610        $alpha = $rc->alpha instanceof \Phpdftk\Css\Value\Number
611            ? $rc->alpha->value
612            : $source->a;
613        return $rebuild($resolved[0], $resolved[1], $resolved[2], $alpha);
614    }
615
616    /**
617     * CSS Color 4 §6 — sRGB → HSL. Returns hue in degrees, saturation
618     * and lightness in [0, 1].
619     *
620     * @return array{0:float,1:float,2:float}
621     */
622    private static function srgbToHsl(float $r, float $g, float $b): array
623    {
624        $max = max($r, $g, $b);
625        $min = min($r, $g, $b);
626        $delta = $max - $min;
627        $l = ($max + $min) / 2.0;
628        if ($delta === 0.0) {
629            return [0.0, 0.0, $l];
630        }
631        $s = $l > 0.5 ? $delta / (2.0 - $max - $min) : $delta / ($max + $min);
632        if ($max === $r) {
633            $h = (($g - $b) / $delta) + ($g < $b ? 6.0 : 0.0);
634        } elseif ($max === $g) {
635            $h = (($b - $r) / $delta) + 2.0;
636        } else {
637            $h = (($r - $g) / $delta) + 4.0;
638        }
639        return [$h * 60.0, $s, $l];
640    }
641
642    /**
643     * CSS Color 4 §8 — sRGB → HWB. Returns hue in degrees, whiteness
644     * and blackness in [0, 1].
645     *
646     * @return array{0:float,1:float,2:float}
647     */
648    private static function srgbToHwb(float $r, float $g, float $b): array
649    {
650        $max = max($r, $g, $b);
651        $min = min($r, $g, $b);
652        $h = self::srgbToHsl($r, $g, $b)[0];
653        return [$h, $min, 1.0 - $max];
654    }
655
656    /**
657     * CSS Color 4 §6 — HSL → sRGB, with hue in [0, 1) (fraction of a
658     * full turn), saturation and lightness in [0, 1].
659     *
660     * @return array{0:float,1:float,2:float}
661     */
662    private static function hslToRgb(float $h, float $s, float $l): array
663    {
664        if ($s === 0.0) {
665            return [$l, $l, $l];
666        }
667        $q = $l < 0.5 ? $l * (1.0 + $s) : $l + $s - $l * $s;
668        $p = 2.0 * $l - $q;
669        $toRgb = static function (float $t) use ($p, $q): float {
670            if ($t < 0.0) {
671                $t += 1.0;
672            }
673            if ($t > 1.0) {
674                $t -= 1.0;
675            }
676            if ($t < 1.0 / 6.0) {
677                return $p + ($q - $p) * 6.0 * $t;
678            }
679            if ($t < 0.5) {
680                return $q;
681            }
682            if ($t < 2.0 / 3.0) {
683                return $p + ($q - $p) * (2.0 / 3.0 - $t) * 6.0;
684            }
685            return $p;
686        };
687        return [
688            $toRgb($h + 1.0 / 3.0),
689            $toRgb($h),
690            $toRgb($h - 1.0 / 3.0),
691        ];
692    }
693
694    /**
695     * CSS Color 4 §8 — HWB → sRGB. Hue in degrees, whiteness and
696     * blackness in [0, 1].
697     *
698     * @return array{0:float,1:float,2:float}
699     */
700    private static function hwbToRgb(float $h, float $w, float $b): array
701    {
702        if ($w + $b >= 1.0) {
703            $gray = $w / ($w + $b);
704            return [$gray, $gray, $gray];
705        }
706        [$rh, $gh, $bh] = self::hslToRgb($h / 360.0, 1.0, 0.5);
707        return [
708            $rh * (1.0 - $w - $b) + $w,
709            $gh * (1.0 - $w - $b) + $w,
710            $bh * (1.0 - $w - $b) + $w,
711        ];
712    }
713
714    /**
715     * Slot identifier name CANDIDATES for a relative-color
716     * expression's target space. The CSS Color 5 parser maps the
717     * function name to a storage space (`hsl(from …)` and `hwb(from
718     * …)` both land at sRGB), so we accept any slot triple that's
719     * syntactically valid for THE SYNTAX a sRGB-stored relative
720     * color could have been written with.
721     *
722     * Returns an empty array when the space isn't a named family
723     * we model.
724     *
725     * @return list<array{0:string,1:string,2:string}>
726     */
727    private function relativeColorSlotIdents(\Phpdftk\Css\Value\ColorSpace $space): array
728    {
729        return match ($space) {
730            // The sRGB-family spaces accept rgb/hsl/hwb syntax depending
731            // on the relative-color function name; all three are valid
732            // here because we collapsed them onto a single storage space.
733            \Phpdftk\Css\Value\ColorSpace::sRGB,
734            \Phpdftk\Css\Value\ColorSpace::sRGBLinear => [
735                ['r', 'g', 'b'],
736                ['h', 's', 'l'],
737                ['h', 'w', 'b'],
738            ],
739            \Phpdftk\Css\Value\ColorSpace::DisplayP3,
740            \Phpdftk\Css\Value\ColorSpace::DisplayP3Linear,
741            \Phpdftk\Css\Value\ColorSpace::A98RGB,
742            \Phpdftk\Css\Value\ColorSpace::A98RGBLinear,
743            \Phpdftk\Css\Value\ColorSpace::ProPhotoRGB,
744            \Phpdftk\Css\Value\ColorSpace::ProPhotoRGBLinear,
745            \Phpdftk\Css\Value\ColorSpace::Rec2020,
746            \Phpdftk\Css\Value\ColorSpace::Rec2020Linear
747                => [['r', 'g', 'b']],
748            \Phpdftk\Css\Value\ColorSpace::HWB => [['h', 'w', 'b']],
749            \Phpdftk\Css\Value\ColorSpace::Lab => [['l', 'a', 'b']],
750            \Phpdftk\Css\Value\ColorSpace::Lch => [['l', 'c', 'h']],
751            \Phpdftk\Css\Value\ColorSpace::OKLab => [['l', 'a', 'b']],
752            \Phpdftk\Css\Value\ColorSpace::OKLCH => [['l', 'c', 'h']],
753            \Phpdftk\Css\Value\ColorSpace::XYZ,
754            \Phpdftk\Css\Value\ColorSpace::XYZD65,
755            \Phpdftk\Css\Value\ColorSpace::XYZD50 => [['x', 'y', 'z']],
756        };
757    }
758
759    private function isIdentMatching(\Phpdftk\Css\Value\Value $value, string $name): bool
760    {
761        return $value instanceof \Phpdftk\Css\Value\Keyword
762            && strtolower($value->name) === $name;
763    }
764
765    private function isAlphaSlotOrOne(\Phpdftk\Css\Value\Value $value): bool
766    {
767        if ($value instanceof \Phpdftk\Css\Value\Number) {
768            return abs($value->value - 1.0) < 1e-9;
769        }
770        return $this->isIdentMatching($value, 'alpha');
771    }
772
773    private function findBodyChild(Box $root): ?Box
774    {
775        foreach ($root->children as $child) {
776            if ($child->element !== null && strtolower($child->element->localName) === 'body') {
777                return $child;
778            }
779        }
780        return null;
781    }
782
783    /**
784     * Return true when the box's `contain` property includes a value
785     * that creates a paint-containment boundary — either `paint` or
786     * `strict` or `content` (which include paint by definition), or
787     * the multi-keyword shorthand listing one of those terms. CSS
788     * Containment 3 §2.4 / §4.4. Per CSS Backgrounds 3 §3.11.2, when
789     * the root or body is paint-contained the body→canvas background
790     * propagation is suppressed.
791     */
792    private function boxIsPaintContained(Box $box): bool
793    {
794        $contain = $box->style->get('contain');
795        if ($contain instanceof \Phpdftk\Css\Value\Keyword) {
796            return $this->containKeywordImpliesPaint($contain->name);
797        }
798        if ($contain instanceof \Phpdftk\Css\Value\ValueList) {
799            foreach ($contain->values as $v) {
800                if ($v instanceof \Phpdftk\Css\Value\Keyword
801                    && $this->containKeywordImpliesPaint($v->name)
802                ) {
803                    return true;
804                }
805            }
806        }
807        return false;
808    }
809
810    private function containKeywordImpliesPaint(string $keyword): bool
811    {
812        // CSS Containment 3 — `paint`, `layout`, `size`, AND `style`
813        // each block ancestor propagation of properties like
814        // `background` and `overflow` to the root (§4.1 for layout;
815        // §3.4 for style explicitly lists the body→canvas
816        // background propagation as one of the things style
817        // containment blocks; size containment makes the element
818        // fully independent of its contents which has the same
819        // propagation-blocking effect for `<html>` / `<body>`
820        // backgrounds — covered by the `contain-{html,body}-bg-003/4`
821        // WPT fixtures). `strict` = layout+paint+style+size and
822        // `content` = layout+paint+style both include several of
823        // these so they get the same treatment.
824        return $keyword === 'paint'
825            || $keyword === 'layout'
826            || $keyword === 'size'
827            || $keyword === 'style'
828            || $keyword === 'strict'
829            || $keyword === 'content';
830    }
831
832    private function paintBox(Box $box, ContentStream $stream): void
833    {
834        // Off-page skip: the box's layout-Y range doesn't overlap this
835        // page's range. We still must descend into children for the
836        // `<a href>` link-rect collection (which uses the page constant
837        // to compute PDF-Y), but skip the heavy paint operations.
838        if ($this->boxEntirelyOffPage($box)) {
839            return;
840        }
841        $opacityGsName = $this->resolveOpacityGsName($box);
842        if ($opacityGsName !== null) {
843            $stream->saveGraphicsState();
844            $stream->setGraphicsState($opacityGsName);
845        }
846        // CSS Transforms 2 §6: apply the box's transform (if any)
847        // before any drawing. The graphics state save/restore wraps
848        // the entire paint (background + content + children) so the
849        // transform affects every nested operation.
850        $hasTransform = $this->applyBoxTransform($box, $stream);
851        // CSS Transforms 2 §15 — `backface-visibility: hidden`
852        // suppresses paint when the cumulative 3D rotation around
853        // the X / Y axis flips the box past 90° (cos(θ) < 0). The
854        // 2D-projected matrix already has the cos-flatten baked in,
855        // so we approximate by checking whether ANY X/Y rotation in
856        // the transform list exceeds 90°.
857        $hidden = $this->isVisibilityHidden($box)
858            || $this->isBackfaceHidden($box);
859        // CSS 2.1 §11.1.2 `clip` — clip an abspos element (its own paint AND
860        // descendants) to `rect(top, right, bottom, left)` of its border box.
861        // Pushed before any drawing so the rect can crop the box itself, and
862        // popped after the children loop.
863        $clipRect = $this->resolveClipRect($box);
864        if ($clipRect !== null) {
865            $stream->saveGraphicsState();
866            $clipPdfY = $this->pageHeight - $clipRect['y'] - $clipRect['h'];
867            $stream->rectangle($clipRect['x'], $clipPdfY, $clipRect['w'], $clipRect['h']);
868            $stream->clip();
869            $stream->endPath();
870        }
871        // CSS Masking 1 §6 `clip-path: <basic-shape>` — clip to a shape
872        // resolved against the border box, wrapping the box + descendants.
873        $clipPathApplied = $this->applyClipPath($box, $stream);
874        if (!$hidden) {
875            // CSS Fragmentation 4 §5.5: `box-decoration-break: clone`
876            // makes each fragment paint full decorations as if it were
877            // a standalone box. For a straddling box we temporarily
878            // swap in a geometry clamped to this page's visible
879            // extent, so background/border/shadow draw at the page
880            // seam as a synthetic edge.
881            $originalGeo = null;
882            if ($this->shouldClampDecorationsToPage($box)) {
883                $originalGeo = $box->geometry;
884                $box->geometry = $this->clampGeometryToPage($originalGeo);
885            }
886            // Filter Effects 1 §16.1 — `filter: drop-shadow(...)`
887            // paints an offset rect behind the box (below the
888            // background, like an outset box-shadow). Other filter
889            // primitives (`blur`, `grayscale`, etc.) require raster
890            // pre-painting and are intentionally not honoured.
891            $this->paintFilterDropShadow($box, $stream);
892            // CSS Backgrounds 3 §6.1.1 — paint stack from bottom up:
893            // outset shadows → background → inset shadows → border.
894            $this->paintBoxShadow($box, $stream, insetOnly: false);
895            $this->paintBackground($box, $stream);
896            $this->paintBoxShadow($box, $stream, insetOnly: true);
897            $this->paintBorders($box, $stream);
898            if ($originalGeo !== null) {
899                $box->geometry = $originalGeo;
900            }
901            $this->paintOutline($box, $stream);
902            $this->paintColumnRules($box, $stream);
903            $this->paintImage($box, $stream);
904            $this->paintListMarker($box, $stream);
905            $this->paintLineBoxes($box, $stream);
906            $this->collectBlockLinkRect($box);
907        }
908        // CSS Overflow 3 §3 — `overflow: hidden | clip | scroll | auto`
909        // clips descendants to the box's padding-edge. `visible` (the
910        // initial value) lets descendants render outside the box.
911        // Print medium can't scroll, so `scroll` / `auto` behave like
912        // `hidden` here. The clip is push/popped around the children
913        // loop so siblings of this box stay unaffected.
914        $overflowClip = $this->shouldOverflowClip($box);
915        if ($overflowClip) {
916            $stream->saveGraphicsState();
917            $this->emitOverflowClipPath($stream, $box);
918        }
919        foreach ($box->children as $child) {
920            $this->paintBox($child, $stream);
921        }
922        if ($overflowClip) {
923            $stream->restoreGraphicsState();
924        }
925        if ($clipPathApplied) {
926            $stream->restoreGraphicsState();
927        }
928        if ($clipRect !== null) {
929            $stream->restoreGraphicsState();
930        }
931        if ($hasTransform) {
932            $stream->restoreGraphicsState();
933        }
934        if ($opacityGsName !== null) {
935            $stream->restoreGraphicsState();
936        }
937    }
938
939    /**
940     * Return `true` when the box's layout-Y range sits entirely above or
941     * entirely below the painter's configured page range. Skipping these
942     * subtrees lets a 100-page document not re-paint every box on every
943     * page. Falls back to `false` (always paint) when the page range
944     * isn't set — preserves the old behaviour for single-page renders.
945     */
946    private function boxEntirelyOffPage(Box $box): bool
947    {
948        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
949            return false;
950        }
951        $g = $box->geometry;
952        $top = $g->y;
953        $bottom = $g->y + $g->outerHeight();
954        // Outline boxes / anonymous boxes can carry zero geometry; never
955        // skip them — descendants may still be in range.
956        if ($bottom === $top) {
957            return false;
958        }
959        return $bottom <= $this->pageRangeStart || $top >= $this->pageRangeEnd;
960    }
961
962    /**
963     * Apply the box's CSS `transform` (if any) via the PDF `cm`
964     * operator. Returns `true` if a graphics-state save was emitted
965     * (the caller must restoreGraphicsState after painting); `false`
966     * if no transform applied. The transform is composed as
967     *   T(origin) × M_css→pdf × T(-origin)
968     * where M is the composition of all transform functions and the
969     * origin sits at the box's `transform-origin` in PDF coordinates.
970     */
971    private function applyBoxTransform(Box $box, ContentStream $stream): bool
972    {
973        $value = $box->style->get('transform');
974        if (!$value instanceof \Phpdftk\Css\Value\Transform || $value->functions === []) {
975            return false;
976        }
977        $matrix = $this->composeTransformMatrix($value, $box);
978        if ($matrix === null) {
979            return false;
980        }
981        [$ox, $oy] = $this->resolveTransformOrigin($box);
982        // T(ox, oy) × M × T(-ox, -oy). PDF cm composes
983        // CTM_new = CTM_old × M_provided, so submit in
984        // outer-to-inner order: translate(+), matrix, translate(-).
985        $stream->saveGraphicsState();
986        if ($ox !== 0.0 || $oy !== 0.0) {
987            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $ox, $oy);
988        }
989        $stream->concatMatrix($matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5]);
990        if ($ox !== 0.0 || $oy !== 0.0) {
991            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$ox, -$oy);
992        }
993        return true;
994    }
995
996    /**
997     * Compose the box's transform-function list into a single 2D
998     * matrix [a, b, c, d, e, f] in PDF coordinate space (Y-up).
999     * Returns `null` if no function produced output (3D-only
1000     * transforms flatten to identity at Phase 2).
1001     *
1002     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
1003     */
1004    private function composeTransformMatrix(\Phpdftk\Css\Value\Transform $transform, Box $box): ?array
1005    {
1006        $result = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // identity
1007        $any = false;
1008        foreach ($transform->functions as $fn) {
1009            $m = $this->transformFunctionToPdfMatrix($fn, $box);
1010            if ($m === null) {
1011                continue;
1012            }
1013            $result = $this->multiplyMatrices($result, $m);
1014            $any = true;
1015        }
1016        return $any ? $result : null;
1017    }
1018
1019    /**
1020     * Convert a single CSS transform-function to a 2D PDF matrix
1021     * (Y-up). The conversion negates the (b, c, f) entries — that's
1022     * the matrix-form of conjugating by a Y-axis flip, which maps
1023     * CSS's Y-down coords to PDF's Y-up.
1024     *
1025     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
1026     */
1027    private function transformFunctionToPdfMatrix(
1028        \Phpdftk\Css\Value\TransformFunction $fn,
1029        Box $box,
1030    ): ?array {
1031        if ($fn instanceof \Phpdftk\Css\Value\TranslateTransform) {
1032            $tx = $this->lengthOrPercentageToFloat($fn->x, $box->geometry->width);
1033            $ty = $this->lengthOrPercentageToFloat($fn->y, $box->geometry->height);
1034            return [1.0, 0.0, 0.0, 1.0, $tx, -$ty];
1035        }
1036        if ($fn instanceof \Phpdftk\Css\Value\RotateTransform) {
1037            // CSS Transforms 2 §13 — rotateX / rotateY collapse onto
1038            // a 2D plane in print. Approximation: rotateX(θ) scales
1039            // the box vertically by |cos(θ)| (mirroring past 90°),
1040            // rotateY(θ) scales horizontally. Visually correct at
1041            // canonical angles (0° = unchanged, 90° = edge-on,
1042            // 180° = mirrored). Z rotation keeps the 2D rotation
1043            // matrix.
1044            $rad = deg2rad($fn->angleDeg);
1045            $cos = cos($rad);
1046            $sin = sin($rad);
1047            $axisLen = sqrt($fn->ax * $fn->ax + $fn->ay * $fn->ay + $fn->az * $fn->az);
1048            if ($axisLen <= 0.0) {
1049                return null;
1050            }
1051            $nx = $fn->ax / $axisLen;
1052            $ny = $fn->ay / $axisLen;
1053            $nz = $fn->az / $axisLen;
1054            // Decompose rotate3d into axis projections. For an axis
1055            // that's purely Z (nx=ny=0, nz=±1), keep the planar
1056            // rotation; for X/Y components, flatten via cos-scaling.
1057            if (abs($nz) > 0.9999) {
1058                // Z rotation (or close to it).
1059                $sign = $nz > 0 ? 1.0 : -1.0;
1060                return [$cos, -$sign * $sin, $sign * $sin, $cos, 0.0, 0.0];
1061            }
1062            // X / Y rotation (or a tilt): use cos-flatten. For mixed
1063            // axes, weight by the axis components. Pure rotateX
1064            // (nx=1) → sy = cos, sx = 1. Pure rotateY (ny=1) → sx
1065            // = cos, sy = 1.
1066            $sy = 1.0 - abs($nx) + abs($nx) * $cos;
1067            $sx = 1.0 - abs($ny) + abs($ny) * $cos;
1068            return [$sx, 0.0, 0.0, $sy, 0.0, 0.0];
1069        }
1070        if ($fn instanceof \Phpdftk\Css\Value\ScaleTransform) {
1071            return [$fn->sx, 0.0, 0.0, $fn->sy, 0.0, 0.0];
1072        }
1073        if ($fn instanceof \Phpdftk\Css\Value\SkewTransform) {
1074            $tanX = tan(deg2rad($fn->xDeg));
1075            $tanY = tan(deg2rad($fn->yDeg));
1076            // CSS skewX: [1, 0, tan(x), 1]; PDF flips b+c → [1, 0, -tan(x), 1]
1077            // CSS skewY: [1, tan(y), 0, 1]; PDF flips → [1, -tan(y), 0, 1]
1078            return [1.0, -$tanY, -$tanX, 1.0, 0.0, 0.0];
1079        }
1080        if ($fn instanceof \Phpdftk\Css\Value\MatrixTransform) {
1081            return [$fn->a, -$fn->b, -$fn->c, $fn->d, $fn->e, -$fn->f];
1082        }
1083        return null;
1084    }
1085
1086    /**
1087     * Multiply two 2D affine matrices in [a, b, c, d, e, f] form:
1088     *   M = [a c e]    M' = [a' c' e']    M × M' = [...]
1089     *       [b d f]         [b' d' f']
1090     *       [0 0 1]         [0  0  1]
1091     *
1092     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m1
1093     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m2
1094     * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
1095     */
1096    private function multiplyMatrices(array $m1, array $m2): array
1097    {
1098        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
1099        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
1100        return [
1101            $a1 * $a2 + $c1 * $b2,
1102            $b1 * $a2 + $d1 * $b2,
1103            $a1 * $c2 + $c1 * $d2,
1104            $b1 * $c2 + $d1 * $d2,
1105            $a1 * $e2 + $c1 * $f2 + $e1,
1106            $b1 * $e2 + $d1 * $f2 + $f1,
1107        ];
1108    }
1109
1110    /**
1111     * Resolve `transform-origin` to a PDF coordinate point. Default
1112     * `50% 50%` puts the pivot at the box's centre. Lengths and
1113     * percentages compose; percentages resolve against the box's
1114     * border-box dimension on each axis.
1115     *
1116     * @return array{0: float, 1: float} (px, py) in PDF coords.
1117     */
1118    private function resolveTransformOrigin(Box $box): array
1119    {
1120        $g = $box->geometry;
1121        $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight;
1122        $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom;
1123        $boxX = $g->x - $g->paddingLeft - $g->borderLeft;
1124        $boxY = $g->y - $g->paddingTop - $g->borderTop;
1125
1126        $value = $box->style->get('transform-origin');
1127        $offX = $width / 2.0;
1128        $offY = $height / 2.0;
1129        if ($value instanceof \Phpdftk\Css\Value\ValueList && count($value->values) >= 2) {
1130            $offX = $this->resolveOriginComponent($value->values[0], $width, $offX);
1131            $offY = $this->resolveOriginComponent($value->values[1], $height, $offY);
1132        }
1133        $cssY = $boxY + $offY;
1134        return [$boxX + $offX, $this->pageHeight - $cssY];
1135    }
1136
1137    private function resolveOriginComponent(
1138        \Phpdftk\Css\Value\Value $value,
1139        float $extent,
1140        float $fallback,
1141    ): float {
1142        if ($value instanceof \Phpdftk\Css\Value\Length) {
1143            return $value->value;
1144        }
1145        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1146            return $value->value / 100.0 * $extent;
1147        }
1148        // CSS Values 4 §5.2 — a unitless `0` is equivalent to `0px` in
1149        // any length context. The generic stylesheet parser stores it as
1150        // `Integer` / `Number`, which the cascade keeps unchanged for
1151        // properties (like `transform-origin`) that don't have a
1152        // dedicated typed parser. Treat both shapes as a px length so
1153        // `transform-origin: 0 0` doesn't silently fall back to the 50%
1154        // default.
1155        if ($value instanceof \Phpdftk\Css\Value\Integer
1156            || $value instanceof \Phpdftk\Css\Value\Number
1157        ) {
1158            return (float) $value->value;
1159        }
1160        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1161            return match (strtolower($value->name)) {
1162                'left', 'top' => 0.0,
1163                'right', 'bottom' => $extent,
1164                'center' => $extent / 2.0,
1165                default => $fallback,
1166            };
1167        }
1168        return $fallback;
1169    }
1170
1171    private function lengthOrPercentageToFloat(
1172        \Phpdftk\Css\Value\Length|\Phpdftk\Css\Value\Percentage $value,
1173        float $basis,
1174    ): float {
1175        if ($value instanceof \Phpdftk\Css\Value\Length) {
1176            return $value->value;
1177        }
1178        return $value->value / 100.0 * $basis;
1179    }
1180
1181    /**
1182     * `true` when this box (a) declares `box-decoration-break: clone`
1183     * AND (b) actually straddles the current page boundary. Boxes that
1184     * fit entirely on one page don't need the clamp — slice and clone
1185     * paint identically in that case.
1186     */
1187    private function shouldClampDecorationsToPage(Box $box): bool
1188    {
1189        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
1190            return false;
1191        }
1192        if (!$this->isCloneDecorationBreak($box)) {
1193            return false;
1194        }
1195        $g = $box->geometry;
1196        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
1197        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
1198        return $outerTop < $this->pageRangeStart || $outerBottom > $this->pageRangeEnd;
1199    }
1200
1201    private function isCloneDecorationBreak(Box $box): bool
1202    {
1203        $value = $box->style->get('box-decoration-break');
1204        if (!($value instanceof Keyword)) {
1205            return false;
1206        }
1207        return strtolower($value->name) === 'clone';
1208    }
1209
1210    /**
1211     * Return a clone of `$g` with content y / height clamped so the
1212     * box's outer margin-box sits entirely inside the painter's
1213     * current page range. Used by `box-decoration-break: clone` to
1214     * make each fragment paint full borders at its visible extent.
1215     */
1216    private function clampGeometryToPage(BoxGeometry $g): BoxGeometry
1217    {
1218        $clone = clone $g;
1219        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
1220            return $clone;
1221        }
1222        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
1223        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
1224        if ($outerTop < $this->pageRangeStart) {
1225            $delta = $this->pageRangeStart - $outerTop;
1226            $clone->y += $delta;
1227            $clone->height = max(0.0, $clone->height - $delta);
1228        }
1229        if ($outerBottom > $this->pageRangeEnd) {
1230            $delta = $outerBottom - $this->pageRangeEnd;
1231            $clone->height = max(0.0, $clone->height - $delta);
1232        }
1233        return $clone;
1234    }
1235
1236    /**
1237     * Phase-1 `<img src="data:image/...">` painter: decodes the data URL,
1238     * spills the bytes to a tempfile, registers an Image XObject on the
1239     * current page via the writer, and emits `q cm /Name Do Q` at the
1240     * box's geometry. No-op when the writer or page is not wired in, or
1241     * when the src isn't a `data:image/png|jpeg` URL we can paint.
1242     */
1243    private function isFloated(Box $box): bool
1244    {
1245        $float = $box->style->get('float');
1246        return $float instanceof \Phpdftk\Css\Value\Keyword
1247            && in_array(strtolower($float->name), ['left', 'right', 'inline-start', 'inline-end'], true);
1248    }
1249
1250    private function paintImage(Box $box, ContentStream $stream): void
1251    {
1252        // Replaced elements render whether they are atomic-inline
1253        // (`display: inline-block`) OR a FLOAT — a floated `<img>` /
1254        // `<embed>` / `<object>` is blockified out of the inline flow into
1255        // a BlockBox but must still paint its image (the css-images
1256        // object-fit / object-position clusters float their replaced
1257        // elements). Restricted to horizontal-flow floats: a `position:
1258        // absolute` / grid replaced BlockBox — or a float in a VERTICAL
1259        // writing mode — reaches paint through a geometry path whose
1260        // positioning isn't correct yet, so painting it there regresses.
1261        $isFloatedReplaced = $box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox
1262            && $this->isFloated($box)
1263            && !WritingMode::fromStyle($box->style)->isVertical();
1264        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)
1265            && !$isFloatedReplaced
1266        ) {
1267            return;
1268        }
1269        if ($this->writer === null || $this->page === null) {
1270            return;
1271        }
1272        $element = $box->element;
1273        if ($element === null) {
1274            return;
1275        }
1276        // Inline foreign content (`<svg>` / `<math>`): the parser
1277        // tagged the subtree with its namespace, but our
1278        // AtomicInlineBox path historically only knew about `<img>`.
1279        // Detect each foreign namespace and route to the dedicated
1280        // painter before the img-src lookup.
1281        $foreignKind = \Phpdftk\HtmlToPdf\Box\BoxGenerator::foreignContentKind($element);
1282        if ($foreignKind === 'svg') {
1283            $this->paintInlineSvg($element, $box, $stream);
1284            return;
1285        }
1286        if ($foreignKind === 'math') {
1287            $this->paintInlineMath($element, $box, $stream);
1288            return;
1289        }
1290        // `<img src>`, `<embed src>`, `<object data>` and a `<video>`'s
1291        // `poster` frame all render an external image resource through the
1292        // same SVG / raster paint path (object-fit / object-position aware).
1293        $tag = strtolower($element->localName);
1294        if ($tag !== 'img' && $tag !== 'embed' && $tag !== 'object' && $tag !== 'video') {
1295            return;
1296        }
1297        $src = $element->getAttribute(match ($tag) {
1298            'object' => 'data',
1299            'video' => 'poster',
1300            default => 'src',
1301        });
1302        if ($src === null) {
1303            return;
1304        }
1305        // `<img src="*.svg">` and `<img src="data:image/svg+xml,...">`
1306        // route through the SVG painter rather than the raster image
1307        // XObject path — SVG isn't a pixel container, so PdfWriter's
1308        // addImage rejects it. The painter has the loader / sizer /
1309        // renderer already used by background-image:url(svg); reuse
1310        // it here so an inline replaced `<img>` honours its CSS
1311        // geometry plus `object-fit` / `object-position`.
1312        if ($this->isSvgSrc($src)) {
1313            $this->paintImgSvg($element, $box, $stream, $src);
1314            return;
1315        }
1316        // Per-page cache: the same src only spills + registers once on
1317        // this page. Multi-page reuse still re-registers — XObject
1318        // resource names are page-local in PdfWriter.
1319        if (isset($this->imageNameCache[$src])) {
1320            $name = $this->imageNameCache[$src];
1321        } else {
1322            $resolvedPath = $this->resolveImageSrc($src);
1323            if ($resolvedPath === null) {
1324                return;
1325            }
1326            // ImageParser throws on malformed bytes; swallow + fall back to
1327            // the alt-text path (or empty box) rather than crashing the whole
1328            // render because of one bad asset.
1329            try {
1330                $name = $this->writer->addImage($resolvedPath, $this->page);
1331            } catch (\Throwable) {
1332                return;
1333            }
1334            $this->imageNameCache[$src] = $name;
1335        }
1336        $geo = $box->geometry;
1337        if ($geo->width <= 0.0) {
1338            // No declared size — skip; the alt-text fallback path covers
1339            // unsized images via the BoxGenerator's InlineBox lowering.
1340            return;
1341        }
1342        $height = $geo->height > 0.0 ? $geo->height : $geo->width;
1343        // CSS Images 3 §5: `object-fit` controls the image's scale
1344        // within its declared content rect. `fill` (default) stretches;
1345        // `contain` / `cover` / `none` / `scale-down` preserve aspect.
1346        // `object-position` selects which part of the box the image
1347        // anchors to when there's slack — defaults to centre.
1348        $fit = $this->objectFitKeyword($box);
1349        $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height);
1350        $positionValue = $box->style->get('object-position');
1351        if ($positionValue !== null
1352            && ($rect['w'] !== $geo->width || $rect['h'] !== $height)
1353        ) {
1354            $pos = $this->resolveBackgroundPosition(
1355                $positionValue,
1356                $rect['w'],
1357                $rect['h'],
1358                $geo->width,
1359                $height,
1360            );
1361            $rect['offsetX'] = $pos['offsetX'];
1362            $rect['offsetY'] = $pos['offsetY'];
1363        }
1364        // PDF y-axis is inverted; the `cm` matrix maps the unit square
1365        // [0,1]^2 to the box's PDF-space rect.
1366        $pdfY = $this->pageHeight - $geo->y - $height;
1367        $stream->saveGraphicsState();
1368        // Clip to the box rect so `cover` overflow doesn't bleed into
1369        // sibling boxes.
1370        $stream->rectangle($geo->x, $pdfY, $geo->width, $height);
1371        $stream->clip();
1372        $stream->endPath();
1373        $stream->concatMatrix(
1374            $rect['w'],
1375            0.0,
1376            0.0,
1377            $rect['h'],
1378            $geo->x + $rect['offsetX'],
1379            $pdfY + ($height - $rect['h'] - $rect['offsetY']),
1380        );
1381        $stream->doXObject($name);
1382        $stream->restoreGraphicsState();
1383    }
1384
1385    /**
1386     * Read the box's cascaded `object-fit` value, normalised to one of
1387     * `fill` / `contain` / `cover` / `none` / `scale-down`. Unknown
1388     * keywords fall back to `fill`.
1389     */
1390    private function objectFitKeyword(Box $box): string
1391    {
1392        $value = $box->style->get('object-fit');
1393        if ($value instanceof Keyword) {
1394            $kw = strtolower($value->name);
1395            if (in_array($kw, ['fill', 'contain', 'cover', 'none', 'scale-down'], true)) {
1396                return $kw;
1397            }
1398        }
1399        return 'fill';
1400    }
1401
1402    /**
1403     * Compute the painted rect for a replaced element under `object-fit`.
1404     * Mirrors CSS Images 3 §5 semantics:
1405     *   - `fill` → stretch to the box.
1406     *   - `contain` → preserve aspect, fit inside; centred slack.
1407     *   - `cover` → preserve aspect, fill; clipped overflow.
1408     *   - `none` → natural size; centred slack.
1409     *   - `scale-down` → min(`none`, `contain`) — uses natural size when
1410     *     the image already fits, otherwise behaves like `contain`.
1411     *
1412     * @return array{w: float, h: float, offsetX: float, offsetY: float}
1413     */
1414    private function resolveObjectFit(
1415        string $fit,
1416        string $src,
1417        float $boxWidth,
1418        float $boxHeight,
1419    ): array {
1420        if ($fit === 'fill') {
1421            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
1422        }
1423        $intrinsic = $this->intrinsicSize($src);
1424        if ($intrinsic === null) {
1425            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
1426        }
1427        [$natW, $natH] = $intrinsic;
1428        if ($fit === 'none') {
1429            return [
1430                'w' => (float) $natW,
1431                'h' => (float) $natH,
1432                'offsetX' => ($boxWidth - $natW) / 2,
1433                'offsetY' => ($boxHeight - $natH) / 2,
1434            ];
1435        }
1436        $scaleW = $boxWidth / $natW;
1437        $scaleH = $boxHeight / $natH;
1438        if ($fit === 'scale-down') {
1439            // Use 1.0 (natural) when it already fits; else contain.
1440            $scale = min(1.0, $scaleW, $scaleH);
1441        } elseif ($fit === 'cover') {
1442            $scale = max($scaleW, $scaleH);
1443        } else {
1444            // contain
1445            $scale = min($scaleW, $scaleH);
1446        }
1447        $finalW = $natW * $scale;
1448        $finalH = $natH * $scale;
1449        return [
1450            'w' => $finalW,
1451            'h' => $finalH,
1452            'offsetX' => ($boxWidth - $finalW) / 2,
1453            'offsetY' => ($boxHeight - $finalH) / 2,
1454        ];
1455    }
1456
1457    /**
1458     * Resolve an `<img src>` value to a real path that
1459     * {@see PdfWriter::addImage} can read. Handles:
1460     *   - `data:image/{png,jpeg}[;base64],...` → spilled tempfile
1461     *   - `http(s)://...` → fetched via `phpdftk/resource-loader`
1462     *     (4F.5) when a loader was supplied; spilled to a tempfile.
1463     *     Without a loader, http(s) silently drops the image.
1464     *   - relative paths → joined with `baseDir`, must resolve under it
1465     *
1466     * Returns null when the source isn't a Phase-1 supported variant or
1467     * when path resolution escapes `baseDir`.
1468     */
1469    private function resolveImageSrc(string $src): ?string
1470    {
1471        if (str_starts_with($src, 'data:')) {
1472            return $this->materializeDataUrl($src);
1473        }
1474        if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) {
1475            return $this->fetchHttpSrc($src);
1476        }
1477        return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir, $this->sandboxRoot))
1478            ->resolveLocalPath($src);
1479    }
1480
1481    /**
1482     * 4F.5 — fetch an `http(s)://` `<img src>` through the injected
1483     * ResourceLoader and materialise the bytes to a temp file so
1484     * the existing `ImageParser` + `PdfWriter::addImage` flow can
1485     * register them as a PDF XObject. Returns the temp path on
1486     * success or null on any failure (no loader configured, SSRF
1487     * policy violation, network error, non-2xx, body cap exceeded,
1488     * write failure) — all of which surface as the no-image
1489     * outcome.
1490     */
1491    private function fetchHttpSrc(string $src): ?string
1492    {
1493        if ($this->resourceLoader === null) {
1494            return null;
1495        }
1496        try {
1497            $result = $this->resourceLoader->fetch($src);
1498        } catch (SsrfBlockedException | FetchFailedException) {
1499            return null;
1500        }
1501        $tmpPath = tempnam(sys_get_temp_dir(), 'phpdftk-http-img-');
1502        if ($tmpPath === false) {
1503            return null;
1504        }
1505        try {
1506            \Phpdftk\Filesystem\LocalFilesystem::writeFile($tmpPath, $result->bytes);
1507        } catch (\Throwable) {
1508            @unlink($tmpPath);
1509            return null;
1510        }
1511        $this->tempImagePaths[] = $tmpPath;
1512        return $tmpPath;
1513    }
1514
1515    /**
1516     * Decode `data:image/{png,jpeg};base64,...` (or the rfc2397 non-base64
1517     * form) into a tempfile so {@see PdfWriter::addImage} can parse it.
1518     * Returns null when the URL isn't a Phase-1 supported variant.
1519     */
1520    private function materializeDataUrl(string $dataUrl): ?string
1521    {
1522        // `data:image/png;base64,iVBORw0K...` — match the MIME + optional
1523        // `;base64` flag + the payload.
1524        if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $dataUrl, $m) !== 1) {
1525            return null;
1526        }
1527        $mime = $m[1] === 'jpg' ? 'jpeg' : $m[1];
1528        $payload = $m[2] === 'base64,'
1529            ? base64_decode($m[3], strict: true)
1530            : urldecode($m[3]);
1531        if ($payload === false || $payload === '') {
1532            return null;
1533        }
1534        $ext = $mime === 'jpeg' ? 'jpg' : 'png';
1535        $tempPath = tempnam(sys_get_temp_dir(), 'phpdftk-img-') . '.' . $ext;
1536        \Phpdftk\Filesystem\LocalFilesystem::writeFile($tempPath, $payload);
1537        $this->tempImagePaths[] = $tempPath;
1538        return $tempPath;
1539    }
1540
1541    /**
1542     * Resolve CSS Backgrounds 3 §3.5 `background-clip` to one of
1543     * `border-box` / `padding-box` / `content-box`. Unknown
1544     * keywords fall back to the initial `border-box`.
1545     */
1546    private function resolveBackgroundClip(Box $box): string
1547    {
1548        $value = $box->style->get('background-clip');
1549        if (!($value instanceof Keyword)) {
1550            return 'border-box';
1551        }
1552        $name = strtolower($value->name);
1553        // CSS Backgrounds 4 §3.5 — `border-area` paints only inside
1554        // the border ring (border-box ∖ padding-box). The bg-clip
1555        // resolution still hands back the OUTER bounding rect
1556        // (border-box dimensions) so the caller computes positions
1557        // against that, but the actual paint is intersected with
1558        // the ring path; the special-case emission lives in
1559        // `paintBackground`.
1560        if (in_array($name, ['border-box', 'padding-box', 'content-box', 'border-area'], true)) {
1561            return $name;
1562        }
1563        return 'border-box';
1564    }
1565
1566    /**
1567     * Resolve CSS Backgrounds 3 §3.4 `background-origin` to one of
1568     * `padding-box` (initial) / `border-box` / `content-box`. The
1569     * origin rect anchors `background-position`'s percentage math.
1570     */
1571    private function resolveBackgroundOrigin(Box $box): string
1572    {
1573        $value = $box->style->get('background-origin');
1574        if (!($value instanceof Keyword)) {
1575            return 'padding-box';
1576        }
1577        $name = strtolower($value->name);
1578        if (in_array($name, ['border-box', 'padding-box', 'content-box'], true)) {
1579            return $name;
1580        }
1581        return 'padding-box';
1582    }
1583
1584    /**
1585     * Compute the (x, top, width, height) rect that
1586     * `background-origin: <value>` selects on `$box`. `x/top` are in
1587     * top-down layout space.
1588     *
1589     * @return array{x: float, top: float, width: float, height: float}
1590     */
1591    private function backgroundOriginRect(Box $box, string $origin): array
1592    {
1593        $g = $box->geometry;
1594        switch ($origin) {
1595            case 'content-box':
1596                return [
1597                    'x' => $g->x,
1598                    'top' => $g->y,
1599                    'width' => $g->width,
1600                    'height' => $g->height,
1601                ];
1602            case 'border-box':
1603                return [
1604                    'x' => $g->x - $g->paddingLeft - $g->borderLeft,
1605                    'top' => $g->y - $g->paddingTop - $g->borderTop,
1606                    'width' => $g->paddingLeft + $g->width + $g->paddingRight
1607                        + $g->borderLeft + $g->borderRight,
1608                    'height' => $g->paddingTop + $g->height + $g->paddingBottom
1609                        + $g->borderTop + $g->borderBottom,
1610                ];
1611            default: // 'padding-box'
1612                return [
1613                    'x' => $g->x - $g->paddingLeft,
1614                    'top' => $g->y - $g->paddingTop,
1615                    'width' => $g->paddingLeft + $g->width + $g->paddingRight,
1616                    'height' => $g->paddingTop + $g->height + $g->paddingBottom,
1617                ];
1618        }
1619    }
1620
1621    /**
1622     * CSS Overflow 3 §3 — return true when this box should clip its
1623     * descendants on at least one axis. `visible` (initial) → no
1624     * clip; any of `hidden` / `clip` / `scroll` / `auto` → clip.
1625     * Per-axis: when `overflow-x` is constraining and `overflow-y`
1626     * isn't (or vice versa), the clip rect extends to the page on
1627     * the unconstrained axis so spec-correct one-axis clipping holds.
1628     */
1629    private function shouldOverflowClip(Box $box): bool
1630    {
1631        // CSS Overflow 3 §3.3 — when the body's overflow has propagated
1632        // to the root, the body itself paints as if overflow:visible.
1633        if ($box === $this->propagatedOverflowBox) {
1634            return false;
1635        }
1636        return $this->axisClips($box, 'x') || $this->axisClips($box, 'y');
1637    }
1638
1639    /**
1640     * Return true when the given axis (`'x'` or `'y'`) should clip
1641     * for this box. Checks the axis-specific longhand first, then
1642     * the `overflow` shorthand.
1643     */
1644    private function axisClips(Box $box, string $axis): bool
1645    {
1646        // CSS Overflow 3 §3.3 — when overflow propagated from body to
1647        // root, the root's effective overflow is the body's
1648        // (whichever axis the body constrained on).
1649        if ($box === $this->propagatedOverflowRoot && $this->propagatedOverflowBox !== null) {
1650            return $this->originalAxisClips($this->propagatedOverflowBox, $axis);
1651        }
1652        return $this->originalAxisClips($box, $axis);
1653    }
1654
1655    private function originalAxisClips(Box $box, string $axis): bool
1656    {
1657        foreach (["overflow-$axis", 'overflow'] as $prop) {
1658            $value = $box->style->get($prop);
1659            if (!($value instanceof Keyword)) {
1660                continue;
1661            }
1662            $name = strtolower($value->name);
1663            if ($name === 'visible') {
1664                return false;
1665            }
1666            if ($name === 'hidden' || $name === 'clip' || $name === 'scroll' || $name === 'auto') {
1667                return true;
1668            }
1669        }
1670        return false;
1671    }
1672
1673    /**
1674     * Emit a clip rect for CSS Overflow 3 §4. Unconstrained axes are
1675     * widened to the full page so the clip is effectively one-axis;
1676     * fully-constrained boxes clip to the padding rect on both axes.
1677     * Caller is responsible for the `saveGraphicsState` /
1678     * `restoreGraphicsState` envelope.
1679     */
1680    private function emitOverflowClipPath(ContentStream $stream, Box $box): void
1681    {
1682        $g = $box->geometry;
1683        $padX = $g->x - $g->paddingLeft;
1684        $padTop = $g->y - $g->paddingTop;
1685        $padWidth = $g->paddingLeft + $g->width + $g->paddingRight;
1686        $padHeight = $g->paddingTop + $g->height + $g->paddingBottom;
1687        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
1688            return;
1689        }
1690        $clipsX = $this->axisClips($box, 'x');
1691        $clipsY = $this->axisClips($box, 'y');
1692        $rectX = $clipsX ? $padX : 0.0;
1693        $rectWidth = $clipsX ? $padWidth : $this->pageWidth;
1694        $rectTop = $clipsY ? $padTop : 0.0;
1695        $rectHeight = $clipsY ? $padHeight : $this->pageHeight;
1696        $pdfY = $this->pageHeight - $rectTop - $rectHeight;
1697        $stream->rectangle($rectX, $pdfY, $rectWidth, $rectHeight);
1698        $stream->clip();
1699        $stream->endPath();
1700    }
1701
1702    /**
1703     * CSS 2.1 §11.1.2 `clip: rect(top, right, bottom, left)` — resolve the
1704     * clipping rectangle (physical layout coords, y-down) for an
1705     * absolutely-positioned box, or `null` when `clip` is `auto`, the box
1706     * is not absolutely positioned, or the value isn't a `rect()`.
1707     * `top`/`left` offset from the border box's top/left edge; `right`/
1708     * `bottom` are measured from those same edges; `auto` means the
1709     * corresponding border edge.
1710     *
1711     * @return array{x: float, y: float, w: float, h: float}|null
1712     */
1713    private function resolveClipRect(Box $box): ?array
1714    {
1715        $clip = $box->style->get('clip');
1716        if (!($clip instanceof \Phpdftk\Css\Value\CssFunction)
1717            || strtolower($clip->name) !== 'rect'
1718            || count($clip->arguments) !== 4
1719        ) {
1720            return null;
1721        }
1722        $position = $box->style->get('position');
1723        if (!($position instanceof Keyword)) {
1724            return null;
1725        }
1726        $pos = strtolower($position->name);
1727        if ($pos !== 'absolute' && $pos !== 'fixed') {
1728            return null;
1729        }
1730        $g = $box->geometry;
1731        $borderBoxX = $g->x - $g->paddingLeft - $g->borderLeft;
1732        $borderBoxY = $g->y - $g->paddingTop - $g->borderTop;
1733        $borderBoxW = $g->borderLeft + $g->paddingLeft + $g->width
1734            + $g->paddingRight + $g->borderRight;
1735        $borderBoxH = $g->borderTop + $g->paddingTop + $g->height
1736            + $g->paddingBottom + $g->borderBottom;
1737        // rect(top, right, bottom, left); `auto` → border edge.
1738        [$topV, $rightV, $bottomV, $leftV] = $clip->arguments;
1739        $top = $this->clipEdgePx($topV, 0.0);
1740        $right = $this->clipEdgePx($rightV, $borderBoxW);
1741        $bottom = $this->clipEdgePx($bottomV, $borderBoxH);
1742        $left = $this->clipEdgePx($leftV, 0.0);
1743        $x = $borderBoxX + $left;
1744        $y = $borderBoxY + $top;
1745        return [
1746            'x' => $x,
1747            'y' => $y,
1748            'w' => max(0.0, ($borderBoxX + $right) - $x),
1749            'h' => max(0.0, ($borderBoxY + $bottom) - $y),
1750        ];
1751    }
1752
1753    /**
1754     * CSS Masking 1 §6 — apply a `clip-path: <basic-shape>` to the box by
1755     * pushing a PDF clip path for the shape, resolved against the BORDER
1756     * box. Returns true when a clip was pushed (caller restores after the
1757     * children loop). Supports inset / circle / ellipse / polygon; the
1758     * `<geometry-box>` form and `url()` references are not handled.
1759     */
1760    private function applyClipPath(Box $box, ContentStream $stream): bool
1761    {
1762        $shape = $box->style->get('clip-path');
1763        $g = $box->geometry;
1764        $bx = $g->x - $g->paddingLeft - $g->borderLeft;
1765        $by = $g->y - $g->paddingTop - $g->borderTop;
1766        $bw = $g->borderLeft + $g->paddingLeft + $g->width + $g->paddingRight + $g->borderRight;
1767        $bh = $g->borderTop + $g->paddingTop + $g->height + $g->paddingBottom + $g->borderBottom;
1768        if ($bw <= 0.0 || $bh <= 0.0) {
1769            return false;
1770        }
1771        $ph = $this->pageHeight;
1772        if ($shape instanceof \Phpdftk\Css\Value\InsetShape) {
1773            $ins = $shape->insets;
1774            // 1–4 values: top, right, bottom, left (CSS shorthand expansion).
1775            $n = count($ins);
1776            $top = $this->shapeLengthPercent($ins[0], $bh);
1777            $right = $this->shapeLengthPercent($ins[$n >= 2 ? 1 : 0], $bw);
1778            $bottom = $this->shapeLengthPercent($ins[$n >= 3 ? 2 : 0], $bh);
1779            $left = $this->shapeLengthPercent($ins[$n >= 4 ? 3 : ($n >= 2 ? 1 : 0)], $bw);
1780            $x = $bx + $left;
1781            $w = max(0.0, $bw - $left - $right);
1782            $h = max(0.0, $bh - $top - $bottom);
1783            $stream->saveGraphicsState();
1784            $stream->rectangle($x, $ph - ($by + $top) - $h, $w, $h);
1785            $stream->clip();
1786            $stream->endPath();
1787            return true;
1788        }
1789        if ($shape instanceof \Phpdftk\Css\Value\CircleShape) {
1790            $cx = $bx + $this->positionComponent($shape->centerX, $bw);
1791            $cy = $by + $this->positionComponent($shape->centerY, $bh);
1792            $r = $this->circleRadius($shape->radius, $cx - $bx, $cy - $by, $bw, $bh);
1793            $stream->saveGraphicsState();
1794            $this->emitEllipsePath($stream, $cx, $ph - $cy, $r, $r);
1795            $stream->clip();
1796            $stream->endPath();
1797            return true;
1798        }
1799        if ($shape instanceof \Phpdftk\Css\Value\EllipseShape) {
1800            $cx = $bx + $this->positionComponent($shape->centerX, $bw);
1801            $cy = $by + $this->positionComponent($shape->centerY, $bh);
1802            $rx = $this->ellipseRadius($shape->radiusX, $cx - $bx, $bw, $bw);
1803            $ry = $this->ellipseRadius($shape->radiusY, $cy - $by, $bh, $bh);
1804            $stream->saveGraphicsState();
1805            $this->emitEllipsePath($stream, $cx, $ph - $cy, $rx, $ry);
1806            $stream->clip();
1807            $stream->endPath();
1808            return true;
1809        }
1810        if ($shape instanceof \Phpdftk\Css\Value\PolygonShape) {
1811            if (count($shape->vertices) < 3) {
1812                return false;
1813            }
1814            $stream->saveGraphicsState();
1815            foreach ($shape->vertices as $i => [$vx, $vy]) {
1816                $px = $bx + $this->shapeLengthPercent($vx, $bw);
1817                $py = $ph - ($by + $this->shapeLengthPercent($vy, $bh));
1818                if ($i === 0) {
1819                    $stream->moveTo($px, $py);
1820                } else {
1821                    $stream->lineTo($px, $py);
1822                }
1823            }
1824            $stream->closePath();
1825            if (strtolower($shape->fillRule) === 'evenodd') {
1826                $stream->clipEvenOdd();
1827            } else {
1828                $stream->clip();
1829            }
1830            $stream->endPath();
1831            return true;
1832        }
1833        return false;
1834    }
1835
1836    /**
1837     * `<length-percentage>` from a basic-shape's generic `Value` slot →
1838     * px against `$basis`; non-length/percentage values resolve to 0.
1839     */
1840    private function shapeLengthPercent(\Phpdftk\Css\Value\Value $value, float $basis): float
1841    {
1842        if ($value instanceof \Phpdftk\Css\Value\Length
1843            || $value instanceof \Phpdftk\Css\Value\Percentage
1844        ) {
1845            return $this->lengthOrPercentageToFloat($value, $basis);
1846        }
1847        return 0.0;
1848    }
1849
1850    /**
1851     * Approximate an ellipse (radii rx, ry) centred at (cx, cy) with four
1852     * cubic Béziers and append it as the current path.
1853     */
1854    private function emitEllipsePath(ContentStream $stream, float $cx, float $cy, float $rx, float $ry): void
1855    {
1856        $kx = $rx * 0.5522847498307933;
1857        $ky = $ry * 0.5522847498307933;
1858        $stream->moveTo($cx + $rx, $cy);
1859        $stream->curveTo($cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry);
1860        $stream->curveTo($cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy);
1861        $stream->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry);
1862        $stream->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy);
1863    }
1864
1865    /**
1866     * Resolve a `<position>` component (clip-path center). `null` →
1867     * centre (50%); keywords map to 0 / 50% / 100% of `$basis`.
1868     */
1869    private function positionComponent(?\Phpdftk\Css\Value\Value $value, float $basis): float
1870    {
1871        if ($value === null) {
1872            return $basis * 0.5;
1873        }
1874        if ($value instanceof Keyword) {
1875            return match (strtolower($value->name)) {
1876                'left', 'top' => 0.0,
1877                'right', 'bottom' => $basis,
1878                default => $basis * 0.5,
1879            };
1880        }
1881        if ($value instanceof \Phpdftk\Css\Value\Length
1882            || $value instanceof \Phpdftk\Css\Value\Percentage
1883        ) {
1884            return $this->lengthOrPercentageToFloat($value, $basis);
1885        }
1886        return $basis * 0.5;
1887    }
1888
1889    /**
1890     * CSS Shapes 1 — resolve a `circle()` radius. `null` / keyword
1891     * default `closest-side`; percentage against `sqrt(W²+H²)/√2`.
1892     */
1893    private function circleRadius(?\Phpdftk\Css\Value\Value $value, float $cx, float $cy, float $w, float $h): float
1894    {
1895        if ($value instanceof \Phpdftk\Css\Value\Length) {
1896            return $value->value;
1897        }
1898        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1899            return $value->value / 100.0 * (sqrt($w * $w + $h * $h) / M_SQRT2);
1900        }
1901        $kw = $value instanceof Keyword ? strtolower($value->name) : 'closest-side';
1902        $sides = [$cx, $w - $cx, $cy, $h - $cy];
1903        $corners = [
1904            hypot($cx, $cy), hypot($w - $cx, $cy),
1905            hypot($cx, $h - $cy), hypot($w - $cx, $h - $cy),
1906        ];
1907        return match ($kw) {
1908            'farthest-side' => max($sides),
1909            'closest-corner' => min($corners),
1910            'farthest-corner' => max($corners),
1911            default => min($sides), // closest-side
1912        };
1913    }
1914
1915    /**
1916     * Resolve one `ellipse()` radius (`rx` or `ry`). `null` / keyword
1917     * default `closest-side`; percentage against `$pctBasis`; `$center`
1918     * is the centre offset along this axis, `$extent` the box extent.
1919     */
1920    private function ellipseRadius(?\Phpdftk\Css\Value\Value $value, float $center, float $extent, float $pctBasis): float
1921    {
1922        if ($value instanceof \Phpdftk\Css\Value\Length) {
1923            return $value->value;
1924        }
1925        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1926            return $value->value / 100.0 * $pctBasis;
1927        }
1928        $kw = $value instanceof Keyword ? strtolower($value->name) : 'closest-side';
1929        return $kw === 'farthest-side'
1930            ? max($center, $extent - $center)
1931            : min($center, $extent - $center);
1932    }
1933
1934    /**
1935     * One `clip` rect edge → px. `auto` returns `$autoValue` (the border
1936     * edge); a `<length>` / `0` returns its value.
1937     */
1938    private function clipEdgePx(\Phpdftk\Css\Value\Value $value, float $autoValue): float
1939    {
1940        if ($value instanceof Keyword && strtolower($value->name) === 'auto') {
1941            return $autoValue;
1942        }
1943        if ($value instanceof \Phpdftk\Css\Value\Length) {
1944            return $value->value;
1945        }
1946        if ($value instanceof \Phpdftk\Css\Value\Integer
1947            || $value instanceof \Phpdftk\Css\Value\Number
1948        ) {
1949            return (float) $value->value;
1950        }
1951        return $autoValue;
1952    }
1953
1954    /**
1955     * Return true when the box's cumulative `transform` rotates the
1956     * backface forward AND `backface-visibility: hidden` is set.
1957     * We walk the box's rotate functions, summing the (signed) X /
1958     * Y rotations; the backface is forward-facing when either total
1959     * exceeds 90° (mod 360°) on the canonical face.
1960     */
1961    private function isBackfaceHidden(Box $box): bool
1962    {
1963        $visibility = $box->style->get('backface-visibility');
1964        if (!$visibility instanceof Keyword
1965            || strtolower($visibility->name) !== 'hidden'
1966        ) {
1967            return false;
1968        }
1969        $transform = $box->style->get('transform');
1970        if (!$transform instanceof \Phpdftk\Css\Value\Transform) {
1971            return false;
1972        }
1973        $cumX = 0.0;
1974        $cumY = 0.0;
1975        foreach ($transform->functions as $fn) {
1976            if (!$fn instanceof \Phpdftk\Css\Value\RotateTransform) {
1977                continue;
1978            }
1979            $axisLen = sqrt($fn->ax * $fn->ax + $fn->ay * $fn->ay + $fn->az * $fn->az);
1980            if ($axisLen <= 0.0) {
1981                continue;
1982            }
1983            // Distribute the rotation across X / Y by axis component.
1984            $cumX += $fn->angleDeg * ($fn->ax / $axisLen);
1985            $cumY += $fn->angleDeg * ($fn->ay / $axisLen);
1986        }
1987        $isFlippedX = cos(deg2rad($cumX)) < 0;
1988        $isFlippedY = cos(deg2rad($cumY)) < 0;
1989        return $isFlippedX || $isFlippedY;
1990    }
1991
1992    private function isVisibilityHidden(Box $box): bool
1993    {
1994        $value = $box->style->get('visibility');
1995        return $value instanceof Keyword
1996            && in_array(strtolower($value->name), ['hidden', 'collapse'], true);
1997    }
1998
1999    /**
2000     * Paint CSS Backgrounds 3 §6 `box-shadow`. Phase-1 implementation:
2001     * draws a hard-edged shadow rect (no blur — blur needs Filter Effects 1
2002     * which is Phase 2). Honours `<offset-x>`, `<offset-y>`, optional
2003     * `<spread-radius>`, and `<color>` (defaults to cascaded `color`).
2004     * Inset shadows are not yet emitted (would clip inward).
2005     *
2006     * Multi-shadow comma lists are read; each shadow paints in reverse
2007     * order so the first listed sits on top — matching CSS stacking.
2008     */
2009    private function paintBoxShadow(Box $box, ContentStream $stream, bool $insetOnly): void
2010    {
2011        $value = $box->style->get('box-shadow');
2012        if ($value === null
2013            || ($value instanceof Keyword && strtolower($value->name) === 'none')
2014        ) {
2015            return;
2016        }
2017        $shadows = $this->collectShadowLayers($value);
2018        if ($shadows === []) {
2019            return;
2020        }
2021        $defaultColor = $box->style->get('color');
2022        $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1);
2023        $geo = $box->geometry;
2024
2025        // Paint last shadow first so earlier-listed shadows sit on top.
2026        foreach (array_reverse($shadows) as $shadow) {
2027            if ($shadow['inset'] !== $insetOnly) {
2028                continue;
2029            }
2030            $color = $shadow['color'] ?? $textColor;
2031            // CSS Backgrounds 3 §6 — a fully-transparent shadow colour
2032            // contributes nothing visible; skip the paint so the
2033            // alpha=0 colour doesn't resolve through the DeviceRGB
2034            // `rg` operator (which has no alpha) and render as black.
2035            if ($color->a <= 0.0) {
2036                continue;
2037            }
2038            $spread = $shadow['spread'];
2039            if ($shadow['inset']) {
2040                $this->paintInsetShadow($geo, $shadow, $color, $stream);
2041                continue;
2042            }
2043            $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'] - $spread;
2044            $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'] - $spread;
2045            $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
2046                + $geo->borderLeft + $geo->borderRight + 2 * $spread;
2047            $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
2048                + $geo->borderTop + $geo->borderBottom + 2 * $spread;
2049            $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
2050        }
2051    }
2052
2053    /**
2054     * Paint an inset box-shadow per CSS Backgrounds 3 §6. The shadow
2055     * paints INSIDE the padding-box edge (not outside the border-box
2056     * like the default outset case). Offsets are inverted in effect:
2057     * a positive `offsetX` makes the shadow visible at the *left*
2058     * edge of the inside (the shadow "comes from" the +X direction).
2059     * Positive `spread` makes the visible inner shadow band thicker
2060     * by shrinking the unshaded inner rect.
2061     *
2062     * Implementation: paint the padding-box outer rect plus the
2063     * computed inner rect as two subpaths, then fill with the
2064     * even-odd rule (`f*`) so PDF leaves the inner rect transparent
2065     * and fills only the frame between them with the shadow colour.
2066     *
2067     * @param array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool} $shadow
2068     */
2069    private function paintInsetShadow(\Phpdftk\HtmlToPdf\Layout\BoxGeometry $geo, array $shadow, Color $color, ContentStream $stream): void
2070    {
2071        // Padding-box edge (one step inside the border edge).
2072        $padX = $geo->x - $geo->paddingLeft;
2073        $padTop = $geo->y - $geo->paddingTop;
2074        $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
2075        $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
2076        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
2077            return;
2078        }
2079        $spread = $shadow['spread'];
2080        // Inner unshaded rect — inset from the padding-box by the
2081        // offset on the corresponding side, then further by spread on
2082        // every side. CSS 2 §6: a positive +X offset moves the shadow
2083        // toward +X (visible at the OPPOSITE edge — the left), so the
2084        // inner rect's left edge advances by offsetX.
2085        $innerX = $padX + max(0.0, $shadow['offsetX']) + $spread;
2086        $innerTop = $padTop + max(0.0, $shadow['offsetY']) + $spread;
2087        $innerRight = $padX + $padWidth + min(0.0, $shadow['offsetX']) - $spread;
2088        $innerBottom = $padTop + $padHeight + min(0.0, $shadow['offsetY']) - $spread;
2089        $innerWidth = $innerRight - $innerX;
2090        $innerHeight = $innerBottom - $innerTop;
2091        if ($innerWidth <= 0.0 || $innerHeight <= 0.0) {
2092            // Spread+offset consumes the whole padding box — fill it
2093            // solid with the shadow colour.
2094            $this->emitRect($stream, $padX, $padTop, $padWidth, $padHeight, fill: $color);
2095            return;
2096        }
2097        // PDF Y axis is inverted vs layout. Flip both rects.
2098        $padPdfY = $this->pageHeight - $padTop - $padHeight;
2099        $innerPdfY = $this->pageHeight - $innerTop - $innerHeight;
2100        $stream->saveGraphicsState();
2101        $stream->setFillColorRGB($color->r, $color->g, $color->b);
2102        $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight);
2103        $stream->rectangle($innerX, $innerPdfY, $innerWidth, $innerHeight);
2104        $stream->fillEvenOdd();
2105        $stream->restoreGraphicsState();
2106    }
2107
2108    /**
2109     * Filter Effects 1 §16.1 — paint `filter: drop-shadow(...)` as an
2110     * offset rect behind the box. Syntax matches `box-shadow`'s
2111     * `<offset-x> <offset-y> <blur>? <color>?` minus `inset` and
2112     * `spread`. Multiple drop-shadow filters in the value list paint
2113     * back-to-front (first listed sits on top), matching CSS stacking.
2114     * Other filter primitives (`blur`, `brightness`, `grayscale`, …)
2115     * silently fall through — they require raster pre-painting.
2116     */
2117    private function paintFilterDropShadow(Box $box, ContentStream $stream): void
2118    {
2119        $value = $box->style->get('filter');
2120        if ($value === null
2121            || ($value instanceof Keyword && strtolower($value->name) === 'none')
2122        ) {
2123            return;
2124        }
2125        $shadows = $this->collectDropShadowFilters($value);
2126        if ($shadows === []) {
2127            return;
2128        }
2129        $defaultColor = $box->style->get('color');
2130        $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1);
2131        $geo = $box->geometry;
2132        foreach (array_reverse($shadows) as $shadow) {
2133            $color = $shadow['color'] ?? $textColor;
2134            $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'];
2135            $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'];
2136            $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
2137                + $geo->borderLeft + $geo->borderRight;
2138            $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
2139                + $geo->borderTop + $geo->borderBottom;
2140            if ($width <= 0.0 || $height <= 0.0) {
2141                continue;
2142            }
2143            $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
2144        }
2145    }
2146
2147    /**
2148     * Walk a `filter` value collecting every `drop-shadow(...)` call.
2149     * Returns `[]` when no drop-shadow appears (other filter primitives
2150     * are skipped without warning).
2151     *
2152     * @return list<array{offsetX: float, offsetY: float, blur: float, color: ?Color}>
2153     */
2154    private function collectDropShadowFilters(\Phpdftk\Css\Value\Value $value): array
2155    {
2156        $out = [];
2157        // Filter post-processing typed form: Filter<list<FilterFunction>>.
2158        if ($value instanceof \Phpdftk\Css\Value\Filter) {
2159            foreach ($value->functions as $fn) {
2160                if ($fn->kind === \Phpdftk\Css\Value\FilterKind::DropShadow) {
2161                    $parsed = $this->parseDropShadowArgs($fn->args);
2162                    if ($parsed !== null) {
2163                        $out[] = $parsed;
2164                    }
2165                }
2166            }
2167            return $out;
2168        }
2169        // Legacy generic form (CssFunction / ValueList<CssFunction>) for
2170        // value-paths that bypass Parser::makeDeclaration.
2171        $items = $value instanceof \Phpdftk\Css\Value\ValueList
2172            ? $value->values
2173            : [$value];
2174        foreach ($items as $item) {
2175            if (!$item instanceof \Phpdftk\Css\Value\CssFunction) {
2176                continue;
2177            }
2178            if (strtolower($item->name) !== 'drop-shadow') {
2179                continue;
2180            }
2181            $parsed = $this->parseDropShadowArgs($item->arguments);
2182            if ($parsed !== null) {
2183                $out[] = $parsed;
2184            }
2185        }
2186        return $out;
2187    }
2188
2189    /**
2190     * Parse `drop-shadow(<offset-x> <offset-y> [<blur>] [<color>])`
2191     * arguments into a layer struct. The CSS parser wraps a
2192     * space-separated function-arg list inside a single ValueList,
2193     * so flatten one level of ValueList before scanning.
2194     *
2195     * @param list<\Phpdftk\Css\Value\Value> $args
2196     * @return array{offsetX: float, offsetY: float, blur: float, color: ?Color}|null
2197     */
2198    private function parseDropShadowArgs(array $args): ?array
2199    {
2200        $flat = [];
2201        foreach ($args as $a) {
2202            if ($a instanceof \Phpdftk\Css\Value\ValueList) {
2203                foreach ($a->values as $inner) {
2204                    $flat[] = $inner;
2205                }
2206            } else {
2207                $flat[] = $a;
2208            }
2209        }
2210        $color = null;
2211        $lengths = [];
2212        foreach ($flat as $a) {
2213            if ($a instanceof Color) {
2214                $color = $a;
2215                continue;
2216            }
2217            if ($a instanceof \Phpdftk\Css\Value\Length) {
2218                $lengths[] = $a->value;
2219                continue;
2220            }
2221            if ($a instanceof \Phpdftk\Css\Value\Integer
2222                || $a instanceof \Phpdftk\Css\Value\Number
2223            ) {
2224                $lengths[] = (float) $a->value;
2225            }
2226        }
2227        if (count($lengths) < 2) {
2228            return null;
2229        }
2230        return [
2231            'offsetX' => $lengths[0],
2232            'offsetY' => $lengths[1],
2233            'blur' => $lengths[2] ?? 0.0,
2234            'color' => $color,
2235        ];
2236    }
2237
2238    /**
2239     * Parse the value list(s) into per-shadow layer arrays.
2240     *
2241     * @return list<array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}>
2242     */
2243    private function collectShadowLayers(\Phpdftk\Css\Value\Value $value): array
2244    {
2245        if ($value instanceof \Phpdftk\Css\Value\ValueList
2246            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
2247        ) {
2248            $layers = [];
2249            foreach ($value->values as $item) {
2250                $parsed = $this->parseShadowLayer($item);
2251                if ($parsed !== null) {
2252                    $layers[] = $parsed;
2253                }
2254            }
2255            return $layers;
2256        }
2257        $single = $this->parseShadowLayer($value);
2258        return $single === null ? [] : [$single];
2259    }
2260
2261    /**
2262     * @return array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}|null
2263     */
2264    private function parseShadowLayer(\Phpdftk\Css\Value\Value $value): ?array
2265    {
2266        $components = $value instanceof \Phpdftk\Css\Value\ValueList
2267            ? $value->values
2268            : [$value];
2269
2270        $inset = false;
2271        $color = null;
2272        $lengths = [];
2273        foreach ($components as $c) {
2274            if ($c instanceof Keyword && strtolower($c->name) === 'inset') {
2275                $inset = true;
2276                continue;
2277            }
2278            if ($c instanceof Color) {
2279                $color = $c;
2280                continue;
2281            }
2282            if ($c instanceof \Phpdftk\Css\Value\Length) {
2283                $lengths[] = $c->value;
2284                continue;
2285            }
2286            // CSS Values 4 §6.2: a unitless `0` is a valid zero-length
2287            // wherever a length is expected. Accept Integer/Number
2288            // values as zero (and treat non-zero numerics as 0 — the
2289            // grammar requires a unit otherwise).
2290            if ($c instanceof \Phpdftk\Css\Value\Integer
2291                || $c instanceof \Phpdftk\Css\Value\Number
2292            ) {
2293                $lengths[] = (float) $c->value;
2294            }
2295        }
2296        if (count($lengths) < 2) {
2297            return null;
2298        }
2299        return [
2300            'offsetX' => $lengths[0],
2301            'offsetY' => $lengths[1],
2302            'blur' => $lengths[2] ?? 0.0,
2303            'spread' => $lengths[3] ?? 0.0,
2304            'color' => $color,
2305            'inset' => $inset,
2306        ];
2307    }
2308
2309    /**
2310     * Resolve the box's cascaded `opacity`. Returns the page-level
2311     * `ExtGState` resource name to invoke for partial opacity, or null
2312     * when opacity is full (1.0) or the painter wasn't given a Page
2313     * reference. Opacity affects this box plus every descendant since
2314     * the `gs` operator persists until the matching `Q`.
2315     */
2316    private function resolveOpacityGsName(Box $box): ?string
2317    {
2318        if ($this->page === null) {
2319            return null;
2320        }
2321        $value = $box->style->get('opacity');
2322        $alpha = match (true) {
2323            $value instanceof \Phpdftk\Css\Value\Number => $value->value,
2324            $value instanceof \Phpdftk\Css\Value\Integer => (float) $value->value,
2325            default => 1.0,
2326        };
2327        $alpha = max(0.0, min(1.0, $alpha));
2328        if ($alpha >= 0.999) {
2329            return null;
2330        }
2331        return $this->page->ensureOpacityState($alpha, $alpha);
2332    }
2333
2334    /**
2335     * Paint a CSS Lists 3 list marker (the `::marker` pseudo) for boxes
2336     * with `display: list-item`, honouring `list-style-type` for the
2337     * three geometric markers (`disc` / `circle` / `square`). Counter-
2338     * style markers (`decimal`, `lower-alpha`, etc.) require font
2339     * rendering of the running counter and live in Phase 2; they fall
2340     * through to a `disc` (filled circle) here. Marker colour follows
2341     * the cascaded `color`.
2342     */
2343    private function paintListMarker(Box $box, ContentStream $stream): void
2344    {
2345        $display = $box->style->get('display');
2346        if (!$display instanceof Keyword || strtolower($display->name) !== 'list-item') {
2347            return;
2348        }
2349        $typeValue = $box->style->get('list-style-type');
2350        $type = $typeValue instanceof Keyword ? strtolower($typeValue->name) : 'disc';
2351        if ($type === 'none') {
2352            return;
2353        }
2354
2355        $color = $box->style->get('color');
2356        $markerColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
2357        $fontSize = $this->dominantFontSize($box);
2358
2359        // Counter-style markers — formatted text, requires a registered font.
2360        $counterText = $this->formatCounterMarker($box, $type);
2361        if ($counterText !== null && $this->defaultFont !== null) {
2362            $this->paintCounterMarker($box, $stream, $markerColor, $fontSize, $counterText);
2363            return;
2364        }
2365
2366        $size = max(2.0, $fontSize / 3.0);
2367        $x = $box->geometry->x - max(6.0, $fontSize * 0.5);
2368        $layoutY = $box->geometry->y + $fontSize * 0.35;
2369        $pdfY = $this->pageHeight - $layoutY - $size;
2370
2371        $stream->saveGraphicsState();
2372        $stream->setFillColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
2373        $stream->setStrokeColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
2374        match ($type) {
2375            'circle' => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: false),
2376            'square' => $this->paintMarkerSquare($stream, $x, $pdfY, $size),
2377            default => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: true),
2378        };
2379        $stream->restoreGraphicsState();
2380    }
2381
2382    /**
2383     * Format the marker text for counter-style list-style-types. Returns
2384     * null for geometric / unknown / `none` types (caller paints the
2385     * geometric stand-in or skips).
2386     */
2387    private function formatCounterMarker(Box $box, string $type): ?string
2388    {
2389        $index = $this->listItemIndex($box);
2390        if ($index < 1) {
2391            return null;
2392        }
2393        $supported = [
2394            'decimal', 'decimal-leading-zero',
2395            'lower-alpha', 'lower-latin', 'upper-alpha', 'upper-latin',
2396            'lower-roman', 'upper-roman',
2397        ];
2398        if (!in_array(strtolower($type), $supported, true)) {
2399            return null;
2400        }
2401        return \Phpdftk\HtmlToPdf\Layout\CounterFormat::format($index, $type) . '.';
2402    }
2403
2404    /**
2405     * Compute the 1-based index of `$box` among its `<li>` siblings by
2406     * walking the originating Element's previousSibling chain. Returns
2407     * 0 when `$box` isn't bound to a DOM element (e.g. anonymous).
2408     */
2409    private function listItemIndex(Box $box): int
2410    {
2411        if ($box->element === null) {
2412            return 0;
2413        }
2414        $thisLi = $box->element;
2415        // HTML 5 §4.4.5.2: `<li value="N">` sets the explicit ordinal — and
2416        // also resets the count for following siblings. Walk left-to-right
2417        // from the parent's first child until we hit `$thisLi`; bumping on
2418        // each `<li>` and snapping to `value` whenever a sibling provides
2419        // it.
2420        $parent = $thisLi->parentNode;
2421        if (!$parent instanceof \Phpdftk\Html\Dom\Element) {
2422            return 1;
2423        }
2424        // HTML 5 §4.4.5.3: `<ol start="N">` sets the starting count.
2425        // `<ol reversed>` counts down instead.
2426        $start = 1;
2427        $reversed = false;
2428        if (strtolower($parent->localName) === 'ol') {
2429            $rawStart = $parent->getAttribute('start');
2430            if ($rawStart !== null && preg_match('/^-?\d+$/', trim($rawStart)) === 1) {
2431                $start = (int) trim($rawStart);
2432            }
2433            $reversed = $parent->getAttribute('reversed') !== null;
2434        }
2435        if ($reversed) {
2436            // Count `<li>` siblings to derive the reversed initial value.
2437            $liCount = 0;
2438            for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) {
2439                if ($n instanceof \Phpdftk\Html\Dom\Element
2440                    && strtolower($n->localName) === 'li'
2441                ) {
2442                    $liCount++;
2443                }
2444            }
2445            $count = $start === 1 ? $liCount + 1 : $start + 1;
2446            $step = -1;
2447        } else {
2448            $count = $start - 1;
2449            $step = 1;
2450        }
2451        for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) {
2452            if (!($n instanceof \Phpdftk\Html\Dom\Element)
2453                || strtolower($n->localName) !== 'li'
2454            ) {
2455                continue;
2456            }
2457            $raw = $n->getAttribute('value');
2458            if ($raw !== null && preg_match('/^-?\d+$/', trim($raw)) === 1) {
2459                $count = (int) trim($raw);
2460            } else {
2461                $count += $step;
2462            }
2463            if ($n === $thisLi) {
2464                return $count;
2465            }
2466        }
2467        return $start;
2468    }
2469
2470    /**
2471     * Paint a counter-style marker — shapes the text against the
2472     * registered font and emits a Tj at the marker position.
2473     */
2474    private function paintCounterMarker(
2475        Box $box,
2476        ContentStream $stream,
2477        Color $color,
2478        float $fontSize,
2479        string $text,
2480    ): void {
2481        $font = $this->defaultFont;
2482        if (!$font instanceof WriterFont) {
2483            return;
2484        }
2485        $otd = $font->getParsedData();
2486        if (!$otd instanceof \Phpdftk\FontParser\OpenTypeData) {
2487            return;
2488        }
2489        $shaper = new \Phpdftk\Text\Shaper();
2490        $shapedRun = $shaper->shapeRun(
2491            $text,
2492            new \Phpdftk\Text\ShapingContext($otd, $fontSize),
2493        );
2494        if ($shapedRun->glyphs === []) {
2495            return;
2496        }
2497        $ascent = ($otd->ascent / max(1, $otd->unitsPerEm)) * $fontSize;
2498        // Right-align the marker so it sits just to the left of the box content.
2499        $width = $shapedRun->totalAdvance;
2500        $x = $box->geometry->x - $width - max(2.0, $fontSize * 0.2);
2501        $baselineY = $box->geometry->y + $ascent;
2502        $pdfY = $this->pageHeight - $baselineY;
2503
2504        $hex = '';
2505        $gidMap = $font->getOldToNewGidMap();
2506        foreach ($shapedRun->glyphs as $g) {
2507            $hex .= sprintf('%04X', $gidMap[$g->glyphId] ?? $g->glyphId);
2508        }
2509
2510        $stream->saveGraphicsState();
2511        $stream->setFillColorRGB($color->r, $color->g, $color->b);
2512        $stream->beginText();
2513        $stream->setFont($font, $fontSize);
2514        $stream->setTextMatrix(1, 0, 0, 1, $x, $pdfY);
2515        $stream->showTextHex($hex);
2516        $stream->endText();
2517        $stream->restoreGraphicsState();
2518    }
2519
2520    private function paintMarkerSquare(ContentStream $stream, float $x, float $y, float $size): void
2521    {
2522        $stream->rectangle($x, $y, $size, $size);
2523        $stream->fill();
2524    }
2525
2526    /**
2527     * Approximate a circle inside the bounding box (x, y, size, size) with
2528     * four cubic Bézier curves. The classic offset constant for unit-radius
2529     * approximation is `0.5522847498` — keeps the curve within ≈ 0.027% of
2530     * the true circle, more than enough at marker scale.
2531     */
2532    private function paintMarkerCircle(
2533        ContentStream $stream,
2534        float $x,
2535        float $y,
2536        float $size,
2537        bool $fill,
2538    ): void {
2539        $r = $size / 2.0;
2540        $cx = $x + $r;
2541        $cy = $y + $r;
2542        $k = $r * 0.5522847498307933;
2543        $stream->moveTo($cx + $r, $cy);
2544        $stream->curveTo($cx + $r, $cy + $k, $cx + $k, $cy + $r, $cx, $cy + $r);
2545        $stream->curveTo($cx - $k, $cy + $r, $cx - $r, $cy + $k, $cx - $r, $cy);
2546        $stream->curveTo($cx - $r, $cy - $k, $cx - $k, $cy - $r, $cx, $cy - $r);
2547        $stream->curveTo($cx + $k, $cy - $r, $cx + $r, $cy - $k, $cx + $r, $cy);
2548        $stream->closePath();
2549        if ($fill) {
2550            $stream->fill();
2551        } else {
2552            $stream->setLineWidth(max(0.4, $r / 6.0));
2553            $stream->stroke();
2554        }
2555    }
2556
2557    private function dominantFontSize(Box $box): float
2558    {
2559        $value = $box->style->get('font-size');
2560        if ($value instanceof \Phpdftk\Css\Value\Length) {
2561            return $value->value;
2562        }
2563        return 12.0;
2564    }
2565
2566    /**
2567     * Emit glyphs for every {@see InlineFragment} in this box's line boxes.
2568     * Requires a {@see RegisteredFont} on the painter — without one, text
2569     * painting is a no-op so block + border content still renders.
2570     *
2571     * Coordinates: layout space is top-down; PDF text positioning is
2572     * baseline-relative in bottom-up space. The baseline sits at
2573     * `lineBox.y + ascent` where ascent = (font.ascent / unitsPerEm) ×
2574     * fontSize. The painter converts to PDF Y by subtracting from
2575     * `$this->pageHeight`.
2576     */
2577    private function paintLineBoxes(Box $box, ContentStream $stream): void
2578    {
2579        // Text emission needs a registered font to look up. Skip
2580        // entirely when neither the registered map nor the explicit
2581        // default supplies a candidate Tf resource for any fragment —
2582        // the result is a no-op text pass so background/border
2583        // content still renders.
2584        if ($box->lineBoxes === []
2585            || ($this->defaultFont === null && $this->registeredFonts === [])
2586        ) {
2587            return;
2588        }
2589        $color = $box->style->get('color');
2590        $textColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
2591
2592        // Inline backgrounds (`<mark>` and friends) paint as a strip behind
2593        // each fragment that carries a `backgroundColor`. Goes before the
2594        // text + shadow passes so the glyphs sit on top.
2595        foreach ($box->lineBoxes as $line) {
2596            $this->paintInlineBackgrounds($box, $line, $stream);
2597        }
2598
2599        $shadows = $this->collectTextShadowLayers($box, $textColor);
2600        foreach ($box->lineBoxes as $line) {
2601            // Paint shadow layers behind the real text. CSS Text Decoration 4
2602            // §6 says the first listed shadow is painted on top, so we
2603            // reverse the list for the back-to-front emission order.
2604            foreach (array_reverse($shadows) as $shadow) {
2605                $this->paintLine(
2606                    $box,
2607                    $line,
2608                    $stream,
2609                    $shadow['color'],
2610                    $shadow['offsetX'],
2611                    $shadow['offsetY'],
2612                );
2613            }
2614            $this->paintLine($box, $line, $stream, $textColor);
2615            $this->paintTextDecorations($box, $line, $stream, $textColor);
2616        }
2617    }
2618
2619    /**
2620     * Paint per-fragment inline background rectangles. Each fragment that
2621     * carries a `backgroundColor` (propagated from an inline element like
2622     * `<mark>` whose cascade sets `background-color`) gets a filled rect
2623     * spanning the fragment's width and the line's height. Adjacent
2624     * fragments with the same colour are merged so we emit one wider rect
2625     * per run of same-colour fragments — cheaper output without sub-pixel
2626     * gaps from neighbouring fills.
2627     */
2628    private function paintInlineBackgrounds(Box $box, LineBox $line, ContentStream $stream): void
2629    {
2630        if ($line->fragments === []) {
2631            return;
2632        }
2633        // Coalesce contiguous fragments that share a background colour into
2634        // single rects so we emit cheaper output without sub-pixel gaps.
2635        /** @var list<array{x: float, width: float, color: Color}> $runs */
2636        $runs = [];
2637        foreach ($line->fragments as $fragment) {
2638            $bg = $fragment->backgroundColor;
2639            if ($bg === null) {
2640                continue;
2641            }
2642            $last = $runs === [] ? null : $runs[array_key_last($runs)];
2643            $sameAsLast = $last !== null
2644                && abs($last['x'] + $last['width'] - $fragment->x) < 0.001
2645                && $last['color']->r === $bg->r
2646                && $last['color']->g === $bg->g
2647                && $last['color']->b === $bg->b;
2648            if ($sameAsLast) {
2649                $runs[array_key_last($runs)]['width'] = ($fragment->x + $fragment->width) - $last['x'];
2650            } else {
2651                $runs[] = [
2652                    'x' => $fragment->x,
2653                    'width' => $fragment->width,
2654                    'color' => $bg,
2655                ];
2656            }
2657        }
2658        if ($runs === []) {
2659            return;
2660        }
2661        $pdfY = $this->pageHeight - ($box->geometry->y + $line->y + $line->height);
2662        foreach ($runs as $run) {
2663            $stream->saveGraphicsState();
2664            $stream->setFillColorRGB($run['color']->r, $run['color']->g, $run['color']->b);
2665            $stream->rectangle($box->geometry->x + $run['x'], $pdfY, $run['width'], $line->height);
2666            $stream->fill();
2667            $stream->restoreGraphicsState();
2668        }
2669    }
2670
2671    /**
2672     * Parse the cascaded `text-shadow` value into layer entries. Returns
2673     * an empty array when text-shadow is `none` or absent.
2674     *
2675     * @return list<array{offsetX: float, offsetY: float, color: Color}>
2676     */
2677    private function collectTextShadowLayers(Box $box, Color $fallback): array
2678    {
2679        $value = $box->style->get('text-shadow');
2680        if ($value === null
2681            || ($value instanceof Keyword && strtolower($value->name) === 'none')
2682        ) {
2683            return [];
2684        }
2685        $layers = [];
2686        $items = $value instanceof \Phpdftk\Css\Value\ValueList
2687            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
2688            ? $value->values
2689            : [$value];
2690        foreach ($items as $item) {
2691            $components = $item instanceof \Phpdftk\Css\Value\ValueList ? $item->values : [$item];
2692            $lengths = [];
2693            $color = null;
2694            foreach ($components as $c) {
2695                if ($c instanceof \Phpdftk\Css\Value\Length) {
2696                    $lengths[] = $c->value;
2697                } elseif ($c instanceof Color) {
2698                    $color = $c;
2699                }
2700            }
2701            if (count($lengths) < 2) {
2702                continue;
2703            }
2704            $layers[] = [
2705                'offsetX' => $lengths[0],
2706                'offsetY' => $lengths[1],
2707                'color' => $color ?? $fallback,
2708            ];
2709        }
2710        return $layers;
2711    }
2712
2713    private function paintLine(
2714        Box $box,
2715        LineBox $line,
2716        ContentStream $stream,
2717        Color $color,
2718        float $offsetX = 0.0,
2719        float $offsetY = 0.0,
2720    ): void {
2721        if ($line->fragments === []) {
2722            return;
2723        }
2724        $stream->saveGraphicsState();
2725        $stream->setFillColorRGB($color->r, $color->g, $color->b);
2726        $stream->beginText();
2727        $activeColor = $color;
2728        foreach ($line->fragments as $fragment) {
2729            // Inline-level `color` override: when the fragment carries its
2730            // own colour (typically `<a>` inheriting blue from the UA
2731            // stylesheet inside a black `<p>`), reseat the fill colour.
2732            $fragColor = $fragment->textColor ?? $color;
2733            if ($fragColor !== $activeColor) {
2734                $stream->setFillColorRGB($fragColor->r, $fragColor->g, $fragColor->b);
2735                $activeColor = $fragColor;
2736            }
2737            $this->paintFragment($box, $line, $fragment, $stream, $fragColor, $offsetX, $offsetY);
2738        }
2739        // Reset rendering mode in case the last fragment left it set.
2740        $stream->setTextRenderingMode(0);
2741        $stream->endText();
2742        $stream->restoreGraphicsState();
2743    }
2744
2745    /**
2746     * Paint text-decoration lines (underline / overline / line-through) for
2747     * every fragment whose parent style sets `text-decoration-line` to a
2748     * non-`none` value.
2749     *
2750     * Position approximation per CSS Text Decoration 3 §3:
2751     *  - underline:   baseline + 0.15 × fontSize
2752     *  - overline:    baseline − ascent (top of em box)
2753     *  - line-through: baseline − 0.3 × fontSize (~x-height middle)
2754     * Thickness: `fontSize / 14`.
2755     *
2756     * The fallback approximations stand in until `phpdftk/font-parser`
2757     * exposes the OS/2 sTypoUnderlinePosition / underlineThickness fields.
2758     */
2759    private function paintTextDecorations(Box $box, LineBox $line, ContentStream $stream, Color $color): void
2760    {
2761        $blockLines = $this->textDecorationLines($box);
2762        $decoColor = $this->textDecorationColor($box, $color);
2763        foreach ($line->fragments as $fragment) {
2764            // CSS Text Decoration 4 §2: a fragment's effective decoration
2765            // is the union of inherited (from inline ancestors) + block-
2766            // level lines. Block-level wins for color since the value
2767            // doesn't inherit through inlines.
2768            $lines = array_values(array_unique(array_merge($blockLines, $fragment->decorationLines)));
2769            if ($lines === []) {
2770                continue;
2771            }
2772            $shapedRun = $fragment->shapedRun;
2773            if ($shapedRun->glyphs === [] && $fragment->width <= 0.0) {
2774                continue;
2775            }
2776            $fontSize = $shapedRun->fontSizePt;
2777            $font = $shapedRun->font;
2778            $unitsPerEm = max(1, $font->unitsPerEm);
2779            $ascent = ($font->ascent / $unitsPerEm) * $fontSize;
2780            // Real OS/2 underline metrics when available; fall back to the
2781            // 1G.3 approximation otherwise.
2782            $underlineOffset = $font->underlinePosition !== null
2783                ? -($font->underlinePosition / $unitsPerEm) * $fontSize
2784                : 0.15 * $fontSize;
2785            $thickness = $font->underlineThickness !== null
2786                ? max(0.5, ($font->underlineThickness / $unitsPerEm) * $fontSize)
2787                : max(0.5, $fontSize / 14.0);
2788            // CSS Text Decoration 4 §4 — `text-decoration-thickness`
2789            // explicit Length / Percentage overrides the font metric.
2790            // `auto` defers to the metric above.
2791            $explicitThickness = $this->resolveDecorationThickness($box, $fontSize);
2792            if ($explicitThickness !== null) {
2793                $thickness = max(0.5, $explicitThickness);
2794            }
2795            // `text-underline-offset` shifts the underline ONLY (not
2796            // overline or line-through). Positive values push the line
2797            // further below the baseline.
2798            $explicitUnderlineOffset = $this->resolveUnderlineOffset($box, $fontSize);
2799            $x = $box->geometry->x + $fragment->x;
2800            $width = $fragment->width;
2801            $baselineY = $box->geometry->y + $line->y + $ascent;
2802            $style = $this->textDecorationStyle($box);
2803            // Per CSS Text Decoration 4 §3, the decoration colour follows
2804            // the *originating* element's `text-decoration-color` (when
2805            // explicitly set) — fall back to the fragment's `color` (so an
2806            // inline `<a>` with cascaded `color: blue` paints a blue
2807            // underline) and finally to the block-level value resolved at
2808            // the outer paint context.
2809            $effectiveColor = $fragment->decorationColor
2810                ?? $fragment->textColor
2811                ?? $decoColor;
2812            foreach ($lines as $lineKind) {
2813                $offsetY = match ($lineKind) {
2814                    'underline' => $underlineOffset + ($explicitUnderlineOffset ?? 0.0),
2815                    'overline' => -$ascent,
2816                    'line-through' => -0.3 * $fontSize,
2817                    default => 0.0,
2818                };
2819                $layoutY = $baselineY + $offsetY;
2820                $pdfY = $this->pageHeight - $layoutY - $thickness;
2821                $this->emitDecorationStyled($stream, $x, $pdfY, $width, $thickness, $effectiveColor, $style);
2822            }
2823        }
2824    }
2825
2826    /**
2827     * Emit one text-decoration line in the given style. `solid` is one
2828     * rect; `double` is two parallel rects with a small gap; `dashed`
2829     * and `dotted` emit a series of segment rects; `wavy` strokes a
2830     * cubic-Bezier-approximated sine wave at the decoration position.
2831     */
2832    private function emitDecorationStyled(
2833        ContentStream $stream,
2834        float $x,
2835        float $pdfY,
2836        float $width,
2837        float $thickness,
2838        Color $color,
2839        string $style,
2840    ): void {
2841        if ($style === 'wavy') {
2842            $this->emitWavyDecoration($stream, $x, $pdfY, $width, $thickness, $color);
2843            return;
2844        }
2845        $stream->saveGraphicsState();
2846        $stream->setFillColorRGB($color->r, $color->g, $color->b);
2847        switch ($style) {
2848            case 'double':
2849                $gap = max(0.5, $thickness);
2850                $stream->rectangle($x, $pdfY, $width, $thickness);
2851                $stream->rectangle($x, $pdfY - $gap - $thickness, $width, $thickness);
2852                $stream->fill();
2853                break;
2854            case 'dashed':
2855                $segment = max(2.0, $thickness * 3);
2856                $gap = max(1.5, $thickness * 2);
2857                for ($cx = $x; $cx < $x + $width; $cx += $segment + $gap) {
2858                    $w = min($segment, $x + $width - $cx);
2859                    $stream->rectangle($cx, $pdfY, $w, $thickness);
2860                }
2861                $stream->fill();
2862                break;
2863            case 'dotted':
2864                $dotSize = max(1.0, $thickness);
2865                $gap = $dotSize * 1.2;
2866                for ($cx = $x; $cx < $x + $width; $cx += $dotSize + $gap) {
2867                    $w = min($dotSize, $x + $width - $cx);
2868                    $stream->rectangle($cx, $pdfY, $w, $thickness);
2869                }
2870                $stream->fill();
2871                break;
2872            default: // solid
2873                $stream->rectangle($x, $pdfY, $width, $thickness);
2874                $stream->fill();
2875        }
2876        $stream->restoreGraphicsState();
2877    }
2878
2879    /**
2880     * Stroke a sine-wave-shaped text decoration line, approximated by
2881     * cubic Bezier curves. Two Beziers per period (one half-cycle up,
2882     * one half-cycle down). The wave's period is `thickness × 6` and
2883     * amplitude is `thickness × 0.7` — these tune the look to match
2884     * the wavy spell-check underlines that browsers render.
2885     */
2886    private function emitWavyDecoration(
2887        ContentStream $stream,
2888        float $x,
2889        float $pdfY,
2890        float $width,
2891        float $thickness,
2892        Color $color,
2893    ): void {
2894        if ($width <= 0.0 || $thickness <= 0.0) {
2895            return;
2896        }
2897        $period = max(4.0, $thickness * 6.0);
2898        $amp = max(1.0, $thickness * 0.7);
2899        $strokeWidth = max(0.5, $thickness * 0.7);
2900        $stream->saveGraphicsState();
2901        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
2902        $stream->setLineWidth($strokeWidth);
2903        // Centerline of the wave sits at $pdfY + thickness/2 so the
2904        // visible band stays within the decoration's allocated band.
2905        $centerY = $pdfY + $thickness / 2.0;
2906        $stream->moveTo($x, $centerY);
2907        $halfPeriod = $period / 2.0;
2908        // Bezier control offset for a sine half-cycle (well-known
2909        // approximation: control points at 1/3 and 2/3 of the half).
2910        $cx1Offset = $halfPeriod / 3.0;
2911        $cx2Offset = ($halfPeriod * 2.0) / 3.0;
2912        $end = $x + $width;
2913        $curX = $x;
2914        $up = true;
2915        while ($curX < $end) {
2916            $segmentEnd = min($curX + $halfPeriod, $end);
2917            $controlY = $up ? $centerY + $amp : $centerY - $amp;
2918            $stream->curveTo(
2919                $curX + $cx1Offset,
2920                $controlY,
2921                $curX + $cx2Offset,
2922                $controlY,
2923                $segmentEnd,
2924                $centerY,
2925            );
2926            $curX = $segmentEnd;
2927            $up = !$up;
2928        }
2929        $stream->stroke();
2930        $stream->restoreGraphicsState();
2931    }
2932
2933    /**
2934     * Resolve CSS Text Decoration 4 §4 `text-decoration-thickness`.
2935     * Returns the resolved pixel value when an explicit Length or
2936     * Percentage is set (percentage is relative to the font size per
2937     * CSS UI 4 §6); returns null when the value is `auto` so the font
2938     * metric stays in effect.
2939     */
2940    private function resolveDecorationThickness(Box $box, float $fontSize): ?float
2941    {
2942        $value = $box->style->get('text-decoration-thickness');
2943        if ($value instanceof \Phpdftk\Css\Value\Length) {
2944            return $value->value;
2945        }
2946        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2947            return $value->value / 100.0 * $fontSize;
2948        }
2949        return null;
2950    }
2951
2952    /**
2953     * Resolve CSS Text Decoration 4 §4.2 `text-underline-offset`.
2954     * Positive values push the underline further below the baseline;
2955     * `auto` (null) defers to the font-metric default.
2956     */
2957    private function resolveUnderlineOffset(Box $box, float $fontSize): ?float
2958    {
2959        $value = $box->style->get('text-underline-offset');
2960        if ($value instanceof \Phpdftk\Css\Value\Length) {
2961            return $value->value;
2962        }
2963        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2964            return $value->value / 100.0 * $fontSize;
2965        }
2966        return null;
2967    }
2968
2969    private function textDecorationStyle(Box $box): string
2970    {
2971        $value = $box->style->get('text-decoration-style');
2972        if ($value instanceof Keyword) {
2973            $name = strtolower($value->name);
2974            if (in_array($name, ['solid', 'double', 'dashed', 'dotted', 'wavy'], true)) {
2975                return $name;
2976            }
2977        }
2978        return 'solid';
2979    }
2980
2981    /** @return list<string> */
2982    private function textDecorationLines(Box $box): array
2983    {
2984        $value = $box->style->get('text-decoration-line');
2985        if ($value === null) {
2986            return [];
2987        }
2988        $items = $value instanceof \Phpdftk\Css\Value\ValueList ? $value->values : [$value];
2989        $out = [];
2990        foreach ($items as $item) {
2991            if (!$item instanceof Keyword) {
2992                continue;
2993            }
2994            $lower = strtolower($item->name);
2995            if ($lower === 'none' || $lower === 'blink') {
2996                continue;
2997            }
2998            if (in_array($lower, ['underline', 'overline', 'line-through'], true)) {
2999                $out[] = $lower;
3000            }
3001        }
3002        return $out;
3003    }
3004
3005    private function textDecorationColor(Box $box, Color $fallback): Color
3006    {
3007        $value = $box->style->get('text-decoration-color');
3008        return $value instanceof Color ? $value : $fallback;
3009    }
3010
3011    private function paintFragment(
3012        Box $box,
3013        LineBox $line,
3014        InlineFragment $fragment,
3015        ContentStream $stream,
3016        Color $color,
3017        float $offsetX = 0.0,
3018        float $offsetY = 0.0,
3019    ): void {
3020        $shapedRun = $fragment->shapedRun;
3021        if ($shapedRun->glyphs === []) {
3022            return;
3023        }
3024        $font = $shapedRun->font;
3025        $ascent = ($font->ascent / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt;
3026        $x = $box->geometry->x + $fragment->x + $offsetX;
3027        // CSS Inline 3 §4.5 vertical-align — `baselineShift` is negative
3028        // for `super`, positive for `sub`. Add it in layout-Y space, then
3029        // flip to PDF-Y.
3030        $baselineY = $box->geometry->y + $line->y + $ascent + $offsetY + $fragment->baselineShift;
3031        $pdfY = $this->pageHeight - $baselineY;
3032
3033        // Pick the RegisteredFont matching this fragment's shaped font.
3034        // The map is keyed by OpenType postScriptName; the shaping context
3035        // chose the font, so this lookup just hands the painter the right
3036        // PDF Tf resource. Falls back to defaultFont when the fragment's
3037        // font wasn't registered (e.g., the resolver returned defaultFont
3038        // and there's no alt map).
3039        $registered = $this->registeredFonts[$font->postScriptName] ?? $this->defaultFont;
3040        if ($registered === null) {
3041            // No registered Tf resource matches this fragment's font and
3042            // no default font is set either. Emitting a `Tf` op with a
3043            // bare postScriptName yields garbage in the viewer (some
3044            // fall back to an arbitrary font, others render nothing) —
3045            // both cases regress reftests that previously produced an
3046            // empty page. Skip the fragment instead.
3047            return;
3048        }
3049        $stream->setFont($registered, $shapedRun->fontSizePt);
3050        // Fake-italic via a 12° skew in the Tm `c` slot (≈ tan(12°) = 0.213).
3051        // CSS Fonts 4 §6.4.1 lets browsers synthesise oblique from regular
3052        // when no real italic face is registered; this is the same trick.
3053        $skew = $fragment->isItalic ? 0.213 : 0.0;
3054        // CSS Writing Modes 4 §5 — for vertical writing modes,
3055        // rotate the text matrix 90° clockwise so the line's
3056        // horizontal advance (text-space x) becomes a vertical
3057        // descent in PDF (page-space -y). Anchor at the line's
3058        // TOP-LEFT corner — `applyVerticalLineShift` placed every
3059        // line at y = 0 and shifted fragments along x to stack
3060        // lines as parallel columns right-to-left for vrl; the
3061        // anchor here matches that placement.
3062        $wm = WritingMode::fromStyle($box->style);
3063        if ($wm->isVertical()) {
3064            // The 90°-clockwise rotation maps the glyph's ascent to
3065            // +deviceX (physical right / line-over) and its descent to
3066            // -deviceX (physical left / line-under) for BOTH vertical-lr
3067            // and vertical-rl. The alphabetic drawing baseline therefore
3068            // sits a `descent` in from the column's line-under (left)
3069            // edge, centred inside the column's cross-size (its line box
3070            // extent) with symmetric half-leading — so the em box lands
3071            // centred in the column rather than flush to the left edge.
3072            $descent = (abs($font->descent) / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt;
3073            $halfLeading = max(0.0, ($line->height - ($ascent + $descent)) / 2.0);
3074            $columnX = $box->geometry->x + $fragment->x + $offsetX + $halfLeading + $descent;
3075            $columnTopLayoutY = $box->geometry->y + $line->y + $offsetY;
3076            $columnTopPdfY = $this->pageHeight - $columnTopLayoutY;
3077            $stream->setTextMatrix(0.0, -1.0, 1.0, 0.0, $columnX, $columnTopPdfY);
3078        } else {
3079            // Tm reseats the text matrix at each fragment's left baseline,
3080            // which is simpler than tracking incremental Td offsets between
3081            // fragments.
3082            $stream->setTextMatrix(1, 0, $skew, 1, $x, $pdfY);
3083        }
3084        // Fake-bold via text rendering mode 2 (fill + stroke). Stroke
3085        // contributes ≈ fontSize × 0.04 of extra thickness — visually close
3086        // to the design-weight increment for bold. Match the stroke color
3087        // to the cascaded fill color so the bold outline doesn't bleed in a
3088        // different hue. Always re-emit the Tr so a non-bold fragment that
3089        // follows a bold one resets to fill-only.
3090        if ($color->a <= 0.0) {
3091            // CSS Color 4 — fully-transparent text (`color: transparent`,
3092            // alpha 0) paints no marks. `setFillColorRGB` drops alpha, so a
3093            // transparent colour reaches here as opaque black (rgb 0,0,0)
3094            // and would fill the glyphs solid. Switch to PDF text rendering
3095            // mode 3 (invisible): the glyphs still emit — so the text stays
3096            // extractable and contributes to the tagged-PDF structure — but
3097            // they leave no visible ink, matching how browsers print
3098            // transparent text. This is an extremely common WPT idiom
3099            // (Ahem "filler" text under `color: transparent`).
3100            $stream->setTextRenderingMode(3);
3101        } elseif ($fragment->isBold) {
3102            $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
3103            $stream->setLineWidth($shapedRun->fontSizePt * 0.04);
3104            $stream->setTextRenderingMode(2);
3105        } else {
3106            $stream->setTextRenderingMode(0);
3107        }
3108
3109        // Per-font GID translation: CFF subsetting renumbers glyphs in the
3110        // embedded font, so the shaper's full-font GIDs must be mapped to
3111        // the subset GIDs before emission. Each registered font has its
3112        // own gid map.
3113        $gidMap = $registered instanceof WriterFont
3114            ? $registered->getOldToNewGidMap()
3115            : [];
3116        $unitsPerEm = max(1, $font->unitsPerEm);
3117        $fontSize = $shapedRun->fontSizePt;
3118
3119        // Build a TJ array if the shaper's advances diverge from the font's
3120        // natural hmtx widths — that gap is the kern adjustment to encode.
3121        // Otherwise emit a plain Tj for the whole run.
3122        $items = [];
3123        $hex = '';
3124        $hasKern = false;
3125        foreach ($shapedRun->glyphs as $g) {
3126            $emitted = $gidMap[$g->glyphId] ?? $g->glyphId;
3127            $hex .= sprintf('%04X', $emitted);
3128
3129            $natural = ($font->glyphWidths[$g->glyphId] ?? 0) / $unitsPerEm * $fontSize;
3130            $delta = $natural - $g->advanceX; // positive = shaper pulled the glyph in (kern)
3131            // CSS Fonts 4 §3.5: `font-size: 0` is legal — the glyphs
3132            // still emit, but at zero advance. Skip the kern fixup so
3133            // we don't divide by zero; nothing to nudge in PDF text
3134            // space when each glyph already advances 0.
3135            $kern = $fontSize > 0.0 ? $delta * 1000.0 / $fontSize : 0.0;
3136            if (abs($kern) >= 0.5) {
3137                $items[] = $hex;
3138                $items[] = $this->snapKern($kern);
3139                $hex = '';
3140                $hasKern = true;
3141            }
3142        }
3143        if ($hex !== '') {
3144            $items[] = $hex;
3145        }
3146
3147        if ($hasKern) {
3148            $stream->showTextArrayHex($items);
3149        } else {
3150            $stream->showTextHex(implode('', array_filter($items, 'is_string')));
3151        }
3152
3153        // Inline `<a href>` — record the fragment's rect for /Link emission.
3154        // We only collect on the "real text" pass (offsetX / offsetY == 0)
3155        // so multi-layer text-shadow doesn't multiply the link count.
3156        if ($fragment->href !== null && $offsetX === 0.0 && $offsetY === 0.0) {
3157            $descent = abs($font->descent) / max(1, $unitsPerEm) * $fontSize;
3158            $this->collectedLinks[] = [
3159                'href' => $fragment->href,
3160                'llx' => $x,
3161                'lly' => $pdfY - $descent,
3162                'urx' => $x + $fragment->width,
3163                'ury' => $pdfY + $ascent,
3164                'title' => $fragment->linkTitle,
3165            ];
3166        }
3167    }
3168
3169    /**
3170     * Block-level `<a href>` — emit a single link rect covering the box's
3171     * border box. Inline `<a>` is handled inside {@see paintFragment()}.
3172     */
3173    private function collectBlockLinkRect(Box $box): void
3174    {
3175        if ($box->element === null
3176            || strtolower($box->element->localName) !== 'a'
3177        ) {
3178            return;
3179        }
3180        $href = $box->element->getAttribute('href');
3181        if ($href === null || $href === '') {
3182            return;
3183        }
3184        // Inline `<a>` already produces per-fragment rects via paintFragment;
3185        // skip when there's nothing to do at the block level.
3186        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox)
3187            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AnonymousBlockBox)
3188            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)
3189        ) {
3190            return;
3191        }
3192        $g = $box->geometry;
3193        if ($g->width <= 0.0 || $g->outerHeight() <= 0.0) {
3194            return;
3195        }
3196        $llx = $g->x;
3197        $urx = $g->x + $g->width;
3198        $ury = $this->pageHeight - $g->y;
3199        $lly = $this->pageHeight - ($g->y + $g->outerHeight());
3200        $this->collectedLinks[] = [
3201            'href' => $href,
3202            'llx' => $llx,
3203            'lly' => $lly,
3204            'urx' => $urx,
3205            'ury' => $ury,
3206            'title' => $box->element->getAttribute('title'),
3207        ];
3208    }
3209
3210    /**
3211     * Round to the nearest 0.1 unit so the emitted PDF stays compact.
3212     * PDF readers don't visually distinguish sub-tenth-unit kerns.
3213     */
3214    private function snapKern(float $kern): float|int
3215    {
3216        $rounded = round($kern, 1);
3217        return $rounded == (int) $rounded ? (int) $rounded : $rounded;
3218    }
3219
3220    private function paintBackground(Box $box, ContentStream $stream): void
3221    {
3222        // Inline-level backgrounds (InlineBox, TextBox, LineBreakBox) are
3223        // painted per-fragment by {@see paintInlineBackgrounds()}; their
3224        // own geometry is meaningless for block-style background painting
3225        // (layout doesn't size them as a single rect). AtomicInlineBox /
3226        // BlockBox / AnonymousBlockBox keep the block-style fill.
3227        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
3228            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
3229            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
3230        ) {
3231            return;
3232        }
3233        // CSS Backgrounds 3 §3.11.2 — the background-source box (root,
3234        // or the body when the root is transparent) was already painted
3235        // across the entire canvas before the tree walk; skip its own
3236        // per-box paint to avoid double-fill.
3237        if ($box === $this->propagatedBgBox) {
3238            return;
3239        }
3240        $color = $this->resolveColorWithCurrentColor(
3241            $box->style->get('background-color'),
3242            $box,
3243        );
3244        // CSS Backgrounds 3 §2.1 — `background-image` may resolve to a
3245        // comma-separated list of images (layers). The first-listed image
3246        // paints on top; later images paint below. Build the layer list
3247        // and reject any non-paintable entries (e.g. `none`).
3248        $bgImage = $box->style->get('background-image');
3249        $layers = $this->extractBackgroundLayers($bgImage);
3250        $hasColor = $color instanceof Color && $color->a > 0.0;
3251        $hasAnyLayer = $layers !== [];
3252        if (!$hasColor && !$hasAnyLayer) {
3253            return;
3254        }
3255        $geo = $box->geometry;
3256        // CSS Backgrounds 3 §3.5 — `background-clip` controls which
3257        // box edge the background paint extends to. `border-box`
3258        // (initial) reaches the outer border edge; `padding-box`
3259        // stops at the inner border edge; `content-box` stays inside
3260        // padding. We honour all three keywords.
3261        $clip = $this->resolveBackgroundClip($box);
3262        switch ($clip) {
3263            case 'content-box':
3264                $x = $geo->x;
3265                $top = $geo->y;
3266                $width = $geo->width;
3267                $height = $geo->height;
3268                break;
3269            case 'padding-box':
3270                $x = $geo->x - $geo->paddingLeft;
3271                $top = $geo->y - $geo->paddingTop;
3272                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight;
3273                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom;
3274                break;
3275            default: // 'border-box' or 'border-area' — both use border-box dims as the bounding rect
3276                $x = $geo->x - $geo->paddingLeft - $geo->borderLeft;
3277                $top = $geo->y - $geo->paddingTop - $geo->borderTop;
3278                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
3279                    + $geo->borderLeft + $geo->borderRight;
3280                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
3281                    + $geo->borderTop + $geo->borderBottom;
3282        }
3283        if ($hasColor) {
3284            $radii = $this->borderRadii($box);
3285            // CSS Backgrounds 4 — `background-clip: border-area`
3286            // paints only on the border ring. Emit border-box rect
3287            // ∪ padding-box rect with the even-odd fill rule so the
3288            // inner padding-box is left unfilled (a ring of bg).
3289            // Radii are ignored on this branch — rounded-corner
3290            // ring support is a follow-up.
3291            if ($clip === 'border-area') {
3292                $padX = $geo->x - $geo->paddingLeft;
3293                $padTop = $geo->y - $geo->paddingTop;
3294                $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
3295                $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
3296                $outerPdfY = $this->pageHeight - $top - $height;
3297                $padPdfY = $this->pageHeight - $padTop - $padHeight;
3298                $stream->saveGraphicsState();
3299                $stream->setFillColorRGB($color->r, $color->g, $color->b);
3300                $stream->rectangle($x, $outerPdfY, $width, $height);
3301                $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight);
3302                $stream->fillEvenOdd();
3303                $stream->restoreGraphicsState();
3304            } elseif (array_sum($radii) > 0.0) {
3305                $this->emitRoundedFill($stream, $x, $top, $width, $height, $radii, $color);
3306            } else {
3307                $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
3308            }
3309        }
3310        // All three image-class properties resolve against the same
3311        // bg-origin / bg-size / bg-position trio. Hoist once so the
3312        // gradient branches can reuse the rects computed for the
3313        // raster path.
3314        $needBgImageProps = $hasAnyLayer && $width > 0.0 && $height > 0.0;
3315        // CSS Backgrounds 4 §3.5 — `border-area` paints bg-image
3316        // ONLY inside the border ring. Wrap the image-paint cluster
3317        // in a graphics state + even-odd clip path so paint inside
3318        // the padding-box is masked off. Rectangle radii are still
3319        // a follow-up (the rounded-ring case needs Bezier path
3320        // intersection beyond the rect-only clip below).
3321        $needBorderAreaClip = $needBgImageProps && $clip === 'border-area';
3322        if ($needBorderAreaClip) {
3323            $padX = $geo->x - $geo->paddingLeft;
3324            $padTop = $geo->y - $geo->paddingTop;
3325            $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
3326            $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
3327            $outerPdfYClip = $this->pageHeight - $top - $height;
3328            $padPdfYClip = $this->pageHeight - $padTop - $padHeight;
3329            $stream->saveGraphicsState();
3330            $stream->rectangle($x, $outerPdfYClip, $width, $height);
3331            $stream->rectangle($padX, $padPdfYClip, $padWidth, $padHeight);
3332            $stream->clipEvenOdd();
3333            $stream->endPath();
3334        }
3335        if ($needBgImageProps) {
3336            // Per CSS 2.1 §14.2.1, when fewer values are supplied than
3337            // images the values cycle. Split each property into a comma
3338            // list and index modulo its length.
3339            $sizeList = $this->extractCommaList($box->style->get('background-size'));
3340            $positionList = $this->extractCommaList($box->style->get('background-position'));
3341            $repeatList = $this->extractCommaList($box->style->get('background-repeat'));
3342            $originRect = $this->backgroundOriginRect(
3343                $box,
3344                $this->resolveBackgroundOrigin($box),
3345            );
3346            // CSS Backgrounds 3 §3.10 — first-listed image is topmost;
3347            // walk the layer list in reverse so the topmost ends up
3348            // painted last.
3349            $count = count($layers);
3350            for ($i = $count - 1; $i >= 0; $i--) {
3351                $layer = $layers[$i];
3352                $sizeValue = $sizeList === [] ? null : $sizeList[$i % count($sizeList)];
3353                $positionValue = $positionList === [] ? null : $positionList[$i % count($positionList)];
3354                $repeatValue = $repeatList === [] ? null : $repeatList[$i % count($repeatList)];
3355                if ($layer instanceof \Phpdftk\Css\Value\Url) {
3356                    $this->paintBackgroundImage(
3357                        $layer,
3358                        $stream,
3359                        $x,
3360                        $top,
3361                        $width,
3362                        $height,
3363                        $sizeValue,
3364                        $positionValue,
3365                        $repeatValue,
3366                        $originRect,
3367                    );
3368                } elseif ($layer instanceof \Phpdftk\Css\Value\LinearGradient) {
3369                    $useTilePath = !$this->isDefaultGradientSize($sizeValue)
3370                        && $this->isNoRepeat($repeatValue);
3371                    if ($useTilePath) {
3372                        $tile = $this->computeGradientTileRect($sizeValue, $positionValue, $originRect);
3373                        $this->paintLinearGradient(
3374                            $layer,
3375                            $stream,
3376                            $tile['x'],
3377                            $tile['top'],
3378                            $tile['w'],
3379                            $tile['h'],
3380                            [$x, $top, $width, $height],
3381                        );
3382                    } else {
3383                        $this->paintLinearGradient($layer, $stream, $x, $top, $width, $height);
3384                    }
3385                } elseif ($layer instanceof \Phpdftk\Css\Value\RadialGradient) {
3386                    $this->paintRadialGradient($layer, $stream, $x, $top, $width, $height);
3387                } elseif (($imgArg = $this->imageFunctionColorArg($layer)) !== null) {
3388                    $imgColor = $this->resolveColorWithCurrentColor($imgArg, $box);
3389                    if ($imgColor instanceof Color) {
3390                        $this->paintColorImage($stream, $imgColor, $sizeValue, $positionValue, $repeatValue, $x, $top, $width, $height, $originRect);
3391                    }
3392                }
3393            }
3394        }
3395        if ($needBorderAreaClip) {
3396            $stream->restoreGraphicsState();
3397        }
3398    }
3399
3400    /**
3401     * Flatten a `background-image` value into the list of paintable
3402     * layers (Url / LinearGradient / RadialGradient). A bare value
3403     * becomes a single-element list; a comma-separated `ValueList`
3404     * is expanded. `none` keywords and other unsupported entries
3405     * are skipped.
3406     *
3407     * @return list<\Phpdftk\Css\Value\Url|\Phpdftk\Css\Value\LinearGradient|\Phpdftk\Css\Value\RadialGradient|\Phpdftk\Css\Value\CssFunction>
3408     */
3409    private function extractBackgroundLayers(mixed $value): array
3410    {
3411        if ($value instanceof \Phpdftk\Css\Value\ValueList
3412            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
3413        ) {
3414            $layers = [];
3415            foreach ($value->values as $v) {
3416                if ($v instanceof \Phpdftk\Css\Value\Url
3417                    || $v instanceof \Phpdftk\Css\Value\LinearGradient
3418                    || $v instanceof \Phpdftk\Css\Value\RadialGradient
3419                    || ($v instanceof \Phpdftk\Css\Value\CssFunction
3420                        && $this->imageFunctionColorArg($v) !== null)
3421                ) {
3422                    $layers[] = $v;
3423                }
3424            }
3425            return $layers;
3426        }
3427        if ($value instanceof \Phpdftk\Css\Value\Url
3428            || $value instanceof \Phpdftk\Css\Value\LinearGradient
3429            || $value instanceof \Phpdftk\Css\Value\RadialGradient
3430            || ($value instanceof \Phpdftk\Css\Value\CssFunction
3431                && $this->imageFunctionColorArg($value) !== null)
3432        ) {
3433            return [$value];
3434        }
3435        return [];
3436    }
3437
3438    private function imageFunctionColorArg(mixed $value): ?\Phpdftk\Css\Value\Value
3439    {
3440        if (!($value instanceof \Phpdftk\Css\Value\CssFunction)
3441            || strtolower($value->name) !== 'image'
3442        ) {
3443            return null;
3444        }
3445        foreach ($value->arguments as $arg) {
3446            if ($arg instanceof \Phpdftk\Css\Value\Url) {
3447                return null; // url-primary image() — not a solid colour.
3448            }
3449            if ($arg instanceof Color) {
3450                return $arg;
3451            }
3452            // `image(currentcolor)` resolves against the element's `color`
3453            // at paint time (via resolveColorWithCurrentColor).
3454            if ($arg instanceof Keyword && strtolower($arg->name) === 'currentcolor') {
3455                return $arg;
3456            }
3457        }
3458        return null;
3459    }
3460
3461    /**
3462     * @param array{x: float, top: float, width: float, height: float} $originRect
3463     */
3464    private function paintColorImage(
3465        ContentStream $stream,
3466        Color $color,
3467        ?\Phpdftk\Css\Value\Value $sizeValue,
3468        ?\Phpdftk\Css\Value\Value $positionValue,
3469        ?\Phpdftk\Css\Value\Value $repeatValue,
3470        float $x,
3471        float $top,
3472        float $width,
3473        float $height,
3474        array $originRect,
3475    ): void {
3476        if ($color->a <= 0.0) {
3477            return;
3478        }
3479        $ow = $originRect['width'];
3480        $oh = $originRect['height'];
3481        $size = $this->resolveBackgroundSize($sizeValue, '', $ow, $oh);
3482        $tw = $size['w'];
3483        $th = $size['h'];
3484        if ($tw <= 0.0 || $th <= 0.0) {
3485            return;
3486        }
3487        if ($positionValue !== null) {
3488            $pos = $this->resolveBackgroundPosition($positionValue, $tw, $th, $ow, $oh);
3489            $offX = $pos['offsetX'];
3490            $offY = $pos['offsetY'];
3491        } else {
3492            $offX = $size['offsetX'];
3493            $offY = $size['offsetY'];
3494        }
3495        $repeat = $this->repeatAxes($repeatValue);
3496        $rx = $repeat['x'] ? $x : $originRect['x'] + $offX;
3497        $rw = $repeat['x'] ? $width : $tw;
3498        $ry = $repeat['y'] ? $top : $originRect['top'] + $offY;
3499        $rh = $repeat['y'] ? $height : $th;
3500        $stream->saveGraphicsState();
3501        $stream->rectangle($x, $this->pageHeight - $top - $height, $width, $height);
3502        $stream->clip();
3503        $stream->endPath();
3504        $this->emitRect($stream, $rx, $ry, $rw, $rh, fill: $color);
3505        $stream->restoreGraphicsState();
3506    }
3507
3508    /**
3509     * Split a CSS property value into a list of per-layer values. A
3510     * comma `ValueList` is exploded into its components; any other
3511     * value is wrapped in a single-element list. Null inputs return
3512     * an empty list so the caller can detect "no value supplied".
3513     *
3514     * @return list<\Phpdftk\Css\Value\Value>
3515     */
3516    private function extractCommaList(mixed $value): array
3517    {
3518        if (!$value instanceof \Phpdftk\Css\Value\Value) {
3519            return [];
3520        }
3521        if ($value instanceof \Phpdftk\Css\Value\ValueList
3522            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
3523        ) {
3524            return array_values($value->values);
3525        }
3526        return [$value];
3527    }
3528
3529    /**
3530     * Paint a CSS `radial-gradient([<shape> <size>] [at <position>], <stops>)`
3531     * as the box's background. Phase-1 simplification: only the first
3532     * and last stops are honoured (PDF's basic ShadingType3 is two-stop),
3533     * the box centre is used when no `at <position>` is supplied, and
3534     * `circle` shapes default to half the box's smaller side while
3535     * `ellipse` shapes get half the box's dimensions per axis.
3536     *
3537     * Because PDF's radial shading is a *circular* primitive, ellipse
3538     * gradients are approximated by scaling the user-space matrix so a
3539     * unit circle becomes an ellipse — the gradient still expands
3540     * outward proportionally.
3541     */
3542    private function paintRadialGradient(
3543        \Phpdftk\Css\Value\RadialGradient $gradient,
3544        ContentStream $stream,
3545        float $x,
3546        float $top,
3547        float $width,
3548        float $height,
3549    ): void {
3550        if ($this->writer === null || $gradient->stops === []) {
3551            return;
3552        }
3553        $pdfY = $this->pageHeight - $top - $height;
3554        // Centre: default to the box centre when no `at <position>` is
3555        // supplied. Author-supplied length values resolve relative to
3556        // the box's content rect.
3557        $cx = $x + ($gradient->centerX !== null ? $gradient->centerX->value : $width / 2);
3558        $cy = $pdfY + ($height - ($gradient->centerY !== null ? $gradient->centerY->value : $height / 2));
3559        // Radii: prefer author lengths, otherwise default to the
3560        // farthest-corner distance for circles (PDF's two-stop primitive
3561        // assumes a single outer radius; we use the larger axis).
3562        $rx = $gradient->sizeX !== null ? $gradient->sizeX->value : $width / 2;
3563        $ry = $gradient->sizeY !== null ? $gradient->sizeY->value : $height / 2;
3564        // For radial, the gradient line is from centre to the ending
3565        // shape — its length is the outer radius (the larger axis).
3566        $stopList = $this->resolveGradientStops($gradient->stops, max($rx, $ry));
3567        // ShadingType3 takes inner+outer concentric circles. Phase-1 has
3568        // a single outer radius (inner = 0); scale the user-space matrix
3569        // for elliptical aspect when sizeX != sizeY.
3570        try {
3571            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
3572            $pattern = $doc->addRadialGradientStops(
3573                new \Phpdftk\Geometry\Point(0, 0),
3574                0.0,
3575                new \Phpdftk\Geometry\Point(0, 0),
3576                max($rx, $ry),
3577                $stopList,
3578            );
3579        } catch (\Throwable) {
3580            return;
3581        }
3582        $patternName = $this->page?->useGradient($pattern);
3583        if ($patternName === null) {
3584            return;
3585        }
3586        $stream->saveGraphicsState();
3587        // Clip to the box rect, then translate to the centre and scale
3588        // for elliptical shapes so the unit-radius gradient covers the
3589        // right footprint.
3590        $stream->rectangle($x, $pdfY, $width, $height);
3591        $stream->clip();
3592        $stream->endPath();
3593        $scaleX = $rx / max($rx, $ry);
3594        $scaleY = $ry / max($rx, $ry);
3595        $stream->concatMatrix($scaleX, 0.0, 0.0, $scaleY, $cx, $cy);
3596        $stream->setFillColorSpace('Pattern');
3597        $stream->setFillColor($patternName);
3598        // Paint over a rect big enough to cover the largest possible
3599        // gradient extent (in the un-scaled space the gradient sits at
3600        // origin with radius `max(rx, ry)`, so a square of side 2*max
3601        // covers it).
3602        $extent = max($rx, $ry);
3603        $stream->rectangle(-$extent, -$extent, 2 * $extent, 2 * $extent);
3604        $stream->fill();
3605        $stream->restoreGraphicsState();
3606    }
3607
3608    /**
3609     * Normalise CSS gradient stops to PDF `{offset, rgb}` tuples per
3610     * CSS Images 3 §3.5.1: unspecified positions distribute evenly
3611     * between adjacent positioned stops; the first stop defaults to
3612     * offset 0 and the last to 1. After normalisation, offsets are
3613     * monotonically non-decreasing in [0, 1].
3614     *
3615     * @param list<\Phpdftk\Css\Value\GradientStop> $stops
3616     * @param float $gradientLineLength Length of the gradient line in
3617     *   user units. Used to convert `<length>` stop positions to
3618     *   fractional [0, 1] offsets. Pass 0 to skip length-position
3619     *   resolution (degrades length-positioned stops to "no authored
3620     *   position" — the spec's interpolation algorithm fills them in).
3621     * @return list<array{offset: float, rgb: array{float, float, float}}>
3622     */
3623    private function resolveGradientStops(array $stops, float $gradientLineLength = 0.0): array
3624    {
3625        $count = count($stops);
3626        if ($count === 0) {
3627            return [];
3628        }
3629        // Step 1: pull positions where authored. `<percentage>` →
3630        // [0,1] fraction directly. `<length>` divides by the gradient
3631        // line length to get a fraction; when the line length is
3632        // unknown (0), length-positioned stops fall through to the
3633        // interpolation step like unset positions.
3634        $offsets = array_fill(0, $count, null);
3635        foreach ($stops as $i => $s) {
3636            if ($s->position instanceof \Phpdftk\Css\Value\Percentage) {
3637                $offsets[$i] = max(0.0, min(1.0, $s->position->value / 100.0));
3638            } elseif ($s->position instanceof \Phpdftk\Css\Value\Length && $gradientLineLength > 0.0) {
3639                $offsets[$i] = max(0.0, min(1.0, $s->position->value / $gradientLineLength));
3640            }
3641        }
3642        // Step 2: anchor unset endpoints at 0/1.
3643        if ($offsets[0] === null) {
3644            $offsets[0] = 0.0;
3645        }
3646        if ($offsets[$count - 1] === null) {
3647            $offsets[$count - 1] = 1.0;
3648        }
3649        // Step 3: monotonic clamp — each stop's offset must be ≥ the
3650        // previous stop's offset.
3651        $prev = 0.0;
3652        foreach ($offsets as $i => $o) {
3653            if ($o !== null) {
3654                $offsets[$i] = max($o, $prev);
3655                $prev = $offsets[$i];
3656            }
3657        }
3658        // Step 4: linearly interpolate runs of unset offsets between
3659        // adjacent anchored stops.
3660        $i = 0;
3661        while ($i < $count) {
3662            if ($offsets[$i] !== null) {
3663                $i++;
3664                continue;
3665            }
3666            $start = $i - 1;
3667            $end = $i;
3668            while ($end < $count && $offsets[$end] === null) {
3669                $end++;
3670            }
3671            // $start ≥ 0 (anchored), $end < $count (anchored at last).
3672            $startOffset = $offsets[$start];
3673            $endOffset = $offsets[$end];
3674            $span = $endOffset - $startOffset;
3675            $gap = $end - $start;
3676            for ($j = $start + 1; $j < $end; $j++) {
3677                $offsets[$j] = $startOffset + $span * ($j - $start) / $gap;
3678            }
3679            $i = $end;
3680        }
3681        // Step 5: emit tuples.
3682        $out = [];
3683        foreach ($stops as $i => $s) {
3684            $out[] = [
3685                'offset' => (float) $offsets[$i],
3686                'rgb' => [$s->color->r, $s->color->g, $s->color->b],
3687            ];
3688        }
3689        return $out;
3690    }
3691
3692    /**
3693     * Resolve the stop list of a `repeating-linear-gradient` to one
3694     * cycle of in-cycle offsets ∈ [0, 1], the cycle length in absolute
3695     * pixels along the gradient line, and the first stop's absolute
3696     * pixel position. Returns `null` when the cycle is degenerate
3697     * (zero-length or fewer than two stops) — callers fall through to
3698     * the non-repeating path.
3699     *
3700     * Mirrors the position-resolution algorithm in
3701     * {@see resolveGradientStops()} but keeps positions in absolute
3702     * px (length stops can exceed the gradient line length when an
3703     * author writes `… 100px` on a 50-px ray) rather than clamping to
3704     * [0, 1].
3705     *
3706     * @param  list<\Phpdftk\Css\Value\GradientStop> $stops
3707     * @return array{list<array{offset: float, rgb: array{float, float, float}}>, float, float}|null
3708     */
3709    private function resolveRepeatingLinearCycle(array $stops, float $gradientLineLength): ?array
3710    {
3711        $count = count($stops);
3712        if ($count < 2) {
3713            return null;
3714        }
3715        $positions = array_fill(0, $count, null);
3716        foreach ($stops as $i => $s) {
3717            if ($s->position instanceof \Phpdftk\Css\Value\Length) {
3718                $positions[$i] = $s->position->value;
3719            } elseif ($s->position instanceof \Phpdftk\Css\Value\Percentage) {
3720                $positions[$i] = $s->position->value / 100.0 * $gradientLineLength;
3721            }
3722        }
3723        if ($positions[0] === null) {
3724            $positions[0] = 0.0;
3725        }
3726        if ($positions[$count - 1] === null) {
3727            $positions[$count - 1] = $gradientLineLength;
3728        }
3729        // Monotonic clamp — each explicit position must be ≥ prior.
3730        $prev = -INF;
3731        foreach ($positions as $i => $p) {
3732            if ($p !== null) {
3733                $positions[$i] = max($p, $prev);
3734                $prev = $positions[$i];
3735            }
3736        }
3737        // Interpolate runs of unset positions between anchored stops.
3738        $i = 0;
3739        while ($i < $count) {
3740            if ($positions[$i] !== null) {
3741                $i++;
3742                continue;
3743            }
3744            $start = $i - 1;
3745            $end = $i;
3746            while ($end < $count && $positions[$end] === null) {
3747                $end++;
3748            }
3749            $span = $positions[$end] - $positions[$start];
3750            $gap = $end - $start;
3751            for ($j = $start + 1; $j < $end; $j++) {
3752                $positions[$j] = $positions[$start] + $span * ($j - $start) / $gap;
3753            }
3754            $i = $end;
3755        }
3756        $firstPos = (float) $positions[0];
3757        $lastPos = (float) $positions[$count - 1];
3758        $cycleLength = $lastPos - $firstPos;
3759        if ($cycleLength <= 0.0) {
3760            return null;
3761        }
3762        $inCycle = [];
3763        foreach ($stops as $i => $s) {
3764            $inCycle[] = [
3765                'offset' => ((float) $positions[$i] - $firstPos) / $cycleLength,
3766                'rgb' => [$s->color->r, $s->color->g, $s->color->b],
3767            ];
3768        }
3769        return [$inCycle, $cycleLength, $firstPos];
3770    }
3771
3772    /**
3773     * Extend the gradient axis to cover the clip rect's projection onto
3774     * the gradient line in whole cycles, and emit a stop list that
3775     * replicates the in-cycle stops once per cycle across the extended
3776     * [0, 1] domain.
3777     *
3778     * @param  array{list<array{offset: float, rgb: array{float, float, float}}>, float, float} $cycle
3779     * @return array{float, float, float, float, list<array{offset: float, rgb: array{float, float, float}}>}
3780     */
3781    private function buildRepeatingLinearAxis(
3782        array $cycle,
3783        float $startPdfX,
3784        float $startPdfY,
3785        float $dx,
3786        float $dy,
3787        float $clipX,
3788        float $clipPdfY,
3789        float $clipWidth,
3790        float $clipHeight,
3791    ): array {
3792        [$inCycleStops, $cycleLength, $firstPos] = $cycle;
3793        $tMin = INF;
3794        $tMax = -INF;
3795        foreach (
3796            [
3797                [$clipX,             $clipPdfY],
3798                [$clipX + $clipWidth, $clipPdfY],
3799                [$clipX,             $clipPdfY + $clipHeight],
3800                [$clipX + $clipWidth, $clipPdfY + $clipHeight],
3801            ] as [$px, $py]
3802        ) {
3803            $t = ($px - $startPdfX) * $dx + ($py - $startPdfY) * $dy;
3804            if ($t < $tMin) {
3805                $tMin = $t;
3806            }
3807            if ($t > $tMax) {
3808                $tMax = $t;
3809            }
3810        }
3811        $kMin = (int) floor(($tMin - $firstPos) / $cycleLength);
3812        $kMax = (int) ceil(($tMax - $firstPos) / $cycleLength) - 1;
3813        if ($kMax < $kMin) {
3814            $kMax = $kMin;
3815        }
3816        // Defensive cap. A clip-extent / cycle ratio over 500 means
3817        // either a wildly short cycle or a huge clip rect; either way
3818        // the result is visually indistinguishable past that count and
3819        // not worth the pattern bytes.
3820        $maxCycles = 500;
3821        $cycles = $kMax - $kMin + 1;
3822        if ($cycles > $maxCycles) {
3823            $kMax = $kMin + $maxCycles - 1;
3824            $cycles = $maxCycles;
3825        }
3826        $extStartPos = $firstPos + $kMin * $cycleLength;
3827        $extEndPos = $firstPos + ($kMax + 1) * $cycleLength;
3828        $extStartX = $startPdfX + $extStartPos * $dx;
3829        $extStartY = $startPdfY + $extStartPos * $dy;
3830        $extEndX = $startPdfX + $extEndPos * $dx;
3831        $extEndY = $startPdfY + $extEndPos * $dy;
3832        $stopList = [];
3833        for ($k = 0; $k < $cycles; $k++) {
3834            foreach ($inCycleStops as $s) {
3835                $stopList[] = [
3836                    'offset' => ($k + $s['offset']) / $cycles,
3837                    'rgb' => $s['rgb'],
3838                ];
3839            }
3840        }
3841        return [$extStartX, $extStartY, $extEndX, $extEndY, $stopList];
3842    }
3843
3844    /**
3845     * Paint a CSS `linear-gradient(<angle>|to <side>, <stops>)` as the
3846     * box's background. Phase-1 simplification: only the first and last
3847     * stop colours are honoured (PDF's basic shading dictionary is
3848     * two-stop). The gradient line orientation comes from the CSS angle
3849     * (CSS direction: 0deg = upward, 90deg = rightward, 180deg = down,
3850     * 270deg = leftward; angles increase clockwise).
3851     */
3852    /**
3853     * Paint a CSS `linear-gradient(...)` as a box background.
3854     *
3855     * The (tileX, tileTop, tileWidth, tileHeight) rect is the
3856     * **gradient's positioning + sizing area** — gradient line and
3857     * stop offsets resolve against it (CSS Images 3 §3.1, §3.5.1).
3858     * `clipRect`, when supplied, scopes the actual paint to a
3859     * different area (e.g. the background-clip rect when bg-size
3860     * specifies an explicit tile smaller than the box). Defaults to
3861     * the tile rect so callers that pass only one rect get the
3862     * previous behaviour.
3863     *
3864     * @param array{0: float, 1: float, 2: float, 3: float}|null $clipRect
3865     *   Layout-space rect [x, top, width, height]. Null = same as tile.
3866     */
3867    private function paintLinearGradient(
3868        \Phpdftk\Css\Value\LinearGradient $gradient,
3869        ContentStream $stream,
3870        float $tileX,
3871        float $tileTop,
3872        float $tileWidth,
3873        float $tileHeight,
3874        ?array $clipRect = null,
3875    ): void {
3876        if ($this->writer === null || $gradient->stops === []) {
3877            return;
3878        }
3879        if ($tileWidth <= 0.0 || $tileHeight <= 0.0) {
3880            return;
3881        }
3882        [$clipX, $clipTop, $clipWidth, $clipHeight] = $clipRect
3883            ?? [$tileX, $tileTop, $tileWidth, $tileHeight];
3884        $tilePdfY = $this->pageHeight - $tileTop - $tileHeight;
3885        $clipPdfY = $this->pageHeight - $clipTop - $clipHeight;
3886        // CSS angle convention: 0deg points up, increases clockwise. The
3887        // gradient line passes through the centre of the *tile*. Compute
3888        // its start and end points on the tile's edge per CSS Images 3 §3.1.
3889        $angle = fmod($gradient->angleDeg, 360.0);
3890        if ($angle < 0.0) {
3891            $angle += 360.0;
3892        }
3893        $rad = deg2rad($angle);
3894        $cx = $tileX + $tileWidth / 2;
3895        $cy = $tilePdfY + $tileHeight / 2;
3896        // Gradient line half-length so the endpoints sit on the tile
3897        // boundary corners (CSS spec): l/2 = |W sin θ| + |H cos θ| / 2
3898        $sin = sin($rad);
3899        $cos = cos($rad);
3900        $halfLen = (abs($tileWidth * $sin) + abs($tileHeight * $cos)) / 2;
3901        // Full line length is twice the half — what `<length>` stops
3902        // resolve against (CSS Images 3 §3.5.1).
3903        $lineLength = $halfLen * 2;
3904        // The CSS convention rotates the gradient line such that 0deg
3905        // points UP (towards the box top). In PDF space the y-axis
3906        // grows upward already (after our flip), so "up" is +y.
3907        $dx = $sin;
3908        $dy = $cos;
3909        $startPdfX = $cx - $dx * $halfLen;
3910        $startPdfY = $cy - $dy * $halfLen;
3911        $endPdfX = $cx + $dx * $halfLen;
3912        $endPdfY = $cy + $dy * $halfLen;
3913        // CSS Images 4 §6.4 — `repeating-linear-gradient` replays the
3914        // stop list at `(lastStopPos - firstStopPos)` intervals along
3915        // the gradient ray, infinitely. We approximate that by
3916        // extending the axis to cover the clip projection in whole
3917        // cycles and feeding the shading a stop list that replicates
3918        // the in-cycle stops once per cycle.
3919        $stopList = null;
3920        if ($gradient->repeating) {
3921            $cycle = $this->resolveRepeatingLinearCycle($gradient->stops, $lineLength);
3922            if ($cycle !== null) {
3923                $extended = $this->buildRepeatingLinearAxis(
3924                    $cycle,
3925                    $startPdfX,
3926                    $startPdfY,
3927                    $dx,
3928                    $dy,
3929                    $clipX,
3930                    $clipPdfY,
3931                    $clipWidth,
3932                    $clipHeight,
3933                );
3934                [$startPdfX, $startPdfY, $endPdfX, $endPdfY, $stopList] = $extended;
3935            }
3936        }
3937        $stopList ??= $this->resolveGradientStops($gradient->stops, $lineLength);
3938        try {
3939            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
3940            $pattern = $doc->addLinearGradientStops(
3941                new \Phpdftk\Geometry\Point($startPdfX, $startPdfY),
3942                new \Phpdftk\Geometry\Point($endPdfX, $endPdfY),
3943                $stopList,
3944            );
3945        } catch (\Throwable) {
3946            return;
3947        }
3948        $stream->saveGraphicsState();
3949        // Clip first to the bg-clip rect, then fill at the (typically
3950        // smaller) tile rect. When tile == clip the two are identical
3951        // and the behaviour is byte-for-byte the same as before.
3952        $stream->rectangle($clipX, $clipPdfY, $clipWidth, $clipHeight);
3953        $stream->clip();
3954        $stream->endPath();
3955        $patternName = $this->page?->useGradient($pattern);
3956        if ($patternName !== null) {
3957            $stream->setFillColorSpace('Pattern');
3958            $stream->setFillColor($patternName);
3959            $stream->rectangle($tileX, $tilePdfY, $tileWidth, $tileHeight);
3960            $stream->fill();
3961        }
3962        $stream->restoreGraphicsState();
3963    }
3964
3965    /**
3966     * Paint a CSS `background-image: url(...)` over the box's
3967     * background-positioning area. CSS Backgrounds 3 §3.9 `background-size`
3968     * support:
3969     *   - `auto` / unset → stretch to fill (Phase-1 default; the legacy
3970     *     `100% 100%`-equivalent we shipped before).
3971     *   - `cover` → preserve aspect, scale to fully cover the box (image
3972     *     may overflow; clipped to box rect).
3973     *   - `contain` → preserve aspect, scale to fit inside the box;
3974     *     image is centred and may show background-color through the
3975     *     letterbox area.
3976     *   - `<length> <length>` → explicit width × height; centred.
3977     */
3978    /**
3979     * @param array{x: float, top: float, width: float, height: float}|null $originRect
3980     *   Positioning area per CSS Backgrounds 3 §3.4 `background-origin`.
3981     *   When null, defaults to the (x, top, width, height) clip rect —
3982     *   keeps the Phase-1 behaviour of positioning + clipping against
3983     *   the same rect. When supplied, image sizing + positioning math
3984     *   uses this rect while clipping uses the outer (clip) rect.
3985     */
3986    private function paintBackgroundImage(
3987        \Phpdftk\Css\Value\Url $url,
3988        ContentStream $stream,
3989        float $x,
3990        float $top,
3991        float $width,
3992        float $height,
3993        ?\Phpdftk\Css\Value\Value $sizeValue = null,
3994        ?\Phpdftk\Css\Value\Value $positionValue = null,
3995        ?\Phpdftk\Css\Value\Value $repeatValue = null,
3996        ?array $originRect = null,
3997    ): void {
3998        if ($this->writer === null || $this->page === null) {
3999            return;
4000        }
4001        $src = $url->url;
4002        $svgDoc = null;
4003        $name = null;
4004        if ($this->isSvgSrc($src)) {
4005            $svgDoc = $this->loadSvgDocument($src);
4006            if ($svgDoc === null) {
4007                return;
4008            }
4009        } elseif (isset($this->imageNameCache[$src])) {
4010            $name = $this->imageNameCache[$src];
4011        } else {
4012            $resolved = $this->resolveImageSrc($src);
4013            if ($resolved === null) {
4014                return;
4015            }
4016            try {
4017                $name = $this->writer->addImage($resolved, $this->page);
4018            } catch (\Throwable) {
4019                return;
4020            }
4021            $this->imageNameCache[$src] = $name;
4022        }
4023        // Positioning anchor: $originRect when supplied, else fall
4024        // back to the clip rect (Phase-1 behaviour).
4025        $originX = $originRect['x'] ?? $x;
4026        $originTop = $originRect['top'] ?? $top;
4027        $originWidth = $originRect['width'] ?? $width;
4028        $originHeight = $originRect['height'] ?? $height;
4029        // Resolve final paint rect (final size + offset within the
4030        // positioning area).
4031        $paint = $this->resolveBackgroundSize($sizeValue, $src, $originWidth, $originHeight);
4032        // CSS Backgrounds 3 §3.6 — `background-position` resolves
4033        // against the positioning area regardless of whether the tile
4034        // is smaller or larger than the area; for an oversized tile,
4035        // the position still anchors its origin (e.g. `0% 0%`
4036        // keeps the tile's top-left at the area's top-left, even if
4037        // the tile overflows). Reapply the author position whenever
4038        // it is supplied so it overrides the size resolver's default
4039        // centred offset.
4040        if ($positionValue !== null) {
4041            $pos = $this->resolveBackgroundPosition(
4042                $positionValue,
4043                $paint['w'],
4044                $paint['h'],
4045                $originWidth,
4046                $originHeight,
4047            );
4048            $paint['offsetX'] = $pos['offsetX'];
4049            $paint['offsetY'] = $pos['offsetY'];
4050        }
4051        $pdfY = $this->pageHeight - $top - $height;
4052        $stream->saveGraphicsState();
4053        // `cover` may overflow the box; clip to box rect so the overflow
4054        // doesn't bleed into adjacent boxes.
4055        $stream->rectangle($x, $pdfY, $width, $height);
4056        $stream->clip();
4057        $stream->endPath();
4058        // CSS Backgrounds 3 §3.8: `background-repeat` decides whether
4059        // to tile the image when its painted rect doesn't fill the box.
4060        // `no-repeat` paints one instance; `repeat` / `repeat-x` /
4061        // `repeat-y` tile across the relevant axes. The painter's box
4062        // clip handles edge tiles that extend past the box rect.
4063        $repeat = $this->repeatAxes($repeatValue);
4064        $repeatModes = $this->repeatModes($repeatValue);
4065        $tileW = $paint['w'];
4066        $tileH = $paint['h'];
4067        if ($tileW <= 0.0 || $tileH <= 0.0) {
4068            $stream->restoreGraphicsState();
4069            return;
4070        }
4071        // CSS Backgrounds 3 §3.7 — `round` per axis scales the tile
4072        // so a whole number of tiles fits the positioning area. Apply
4073        // before computing tile offsets so subsequent positioning + the
4074        // repeat loop see the rescaled tile dims.
4075        $tileW = $this->roundTileDim($repeatModes['x'], $tileW, $originWidth);
4076        $tileH = $this->roundTileDim($repeatModes['y'], $tileH, $originHeight);
4077        $paint['w'] = $tileW;
4078        $paint['h'] = $tileH;
4079        // Start positions: shift the anchor backwards by whole tile
4080        // widths until the leftmost / topmost tile sits at or before
4081        // the origin box (NOT the clip box — tiles anchor against
4082        // `background-origin`). With `no-repeat`, no shift happens.
4083        // Tile iteration bounds. A repeating axis tiles across the whole
4084        // PAINT/clip rect (which for a propagated root background is the
4085        // entire canvas, larger than the positioning area), anchored to
4086        // the origin rect. A non-repeating axis paints a single tile
4087        // within the origin rect. For a normal box clip == origin, so
4088        // these reduce to the origin bounds (no behaviour change).
4089        $startX = $paint['offsetX'];
4090        $farX = $originWidth;
4091        if ($repeat['x']) {
4092            $farX = $x + $width - $originX;
4093            while ($originX + $startX > $x) {
4094                $startX -= $tileW;
4095            }
4096        }
4097        $startY = $paint['offsetY'];
4098        $farY = $originHeight;
4099        if ($repeat['y']) {
4100            $farY = $top + $height - $originTop;
4101            while ($originTop + $startY > $top) {
4102                $startY -= $tileH;
4103            }
4104        }
4105        $originBottomLayoutY = $originTop + $originHeight;
4106        $originPdfBottom = $this->pageHeight - $originBottomLayoutY;
4107        $maxTiles = 4096;
4108        $tileCount = 0;
4109        $offsetY = $startY;
4110        while ($offsetY < $farY) {
4111            $offsetX = $startX;
4112            while ($offsetX < $farX) {
4113                if ($tileCount >= $maxTiles) {
4114                    break 2;
4115                }
4116                $tileBottomY = $originPdfBottom + ($originHeight - $tileH - $offsetY);
4117                if ($svgDoc !== null) {
4118                    // Route the SVG draw through the caller's stream so
4119                    // it lands INSIDE the bg-clip `q ... clip ... Q`
4120                    // scope this method opened above. Without this the
4121                    // page would attach a fresh content stream and the
4122                    // SVG paint (e.g. a `cover`-overflowed 768×3072
4123                    // tile) escapes the box clip.
4124                    $this->svgRenderer()->draw(
4125                        $svgDoc,
4126                        $originX + $offsetX,
4127                        $tileBottomY,
4128                        $tileW,
4129                        $tileH,
4130                        stream: $stream,
4131                    );
4132                } else {
4133                    $stream->saveGraphicsState();
4134                    $stream->concatMatrix(
4135                        $tileW,
4136                        0.0,
4137                        0.0,
4138                        $tileH,
4139                        $originX + $offsetX,
4140                        $tileBottomY,
4141                    );
4142                    assert($name !== null);
4143                    $stream->doXObject($name);
4144                    $stream->restoreGraphicsState();
4145                }
4146                $tileCount++;
4147                if (!$repeat['x']) {
4148                    break;
4149                }
4150                $offsetX += $tileW;
4151            }
4152            if (!$repeat['y']) {
4153                break;
4154            }
4155            $offsetY += $tileH;
4156        }
4157        $stream->restoreGraphicsState();
4158    }
4159
4160    /**
4161     * Resolve a CSS `background-repeat` value to a `{x: bool, y: bool}`
4162     * pair indicating whether each axis should tile (true for all
4163     * non-`no-repeat` modes; the per-axis `round` / `space` math
4164     * folds back into the caller via {@see repeatModes}).
4165     *
4166     * Phase-1 handles the simple keyword set:
4167     *   - `repeat` (default) → both axes
4168     *   - `repeat-x` → x only
4169     *   - `repeat-y` → y only
4170     *   - `no-repeat` → neither
4171     *   - `round` / `space` → both axes (loop-active; the actual
4172     *     scale-to-fit / distribute-spacing math is layered on top
4173     *     of the loop)
4174     *   - two-value form (e.g. `repeat no-repeat`): per-axis keywords
4175     *
4176     * @return array{x: bool, y: bool}
4177     */
4178    private function repeatAxes(?\Phpdftk\Css\Value\Value $value): array
4179    {
4180        $modes = $this->repeatModes($value);
4181        return [
4182            'x' => $modes['x'] !== 'no-repeat',
4183            'y' => $modes['y'] !== 'no-repeat',
4184        ];
4185    }
4186
4187    /**
4188     * Per-axis `background-repeat` mode (the richer view repeatAxes
4189     * collapses to bools). Returns one of `repeat` / `repeat-x` (collapsed
4190     * to a single bool above) / `no-repeat` / `round` / `space` per axis.
4191     *
4192     * @return array{x: string, y: string}
4193     */
4194    private function repeatModes(?\Phpdftk\Css\Value\Value $value): array
4195    {
4196        if ($value === null) {
4197            return ['x' => 'repeat', 'y' => 'repeat'];
4198        }
4199        $items = $value instanceof \Phpdftk\Css\Value\ValueList
4200            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
4201                ? $value->values
4202                : [$value];
4203        if (count($items) === 2
4204            && $items[0] instanceof \Phpdftk\Css\Value\Keyword
4205            && $items[1] instanceof \Phpdftk\Css\Value\Keyword
4206        ) {
4207            return [
4208                'x' => strtolower($items[0]->name),
4209                'y' => strtolower($items[1]->name),
4210            ];
4211        }
4212        if ($items[0] instanceof \Phpdftk\Css\Value\Keyword) {
4213            return match (strtolower($items[0]->name)) {
4214                'repeat-x' => ['x' => 'repeat', 'y' => 'no-repeat'],
4215                'repeat-y' => ['x' => 'no-repeat', 'y' => 'repeat'],
4216                'no-repeat' => ['x' => 'no-repeat', 'y' => 'no-repeat'],
4217                'round' => ['x' => 'round', 'y' => 'round'],
4218                'space' => ['x' => 'space', 'y' => 'space'],
4219                default => ['x' => 'repeat', 'y' => 'repeat'],
4220            };
4221        }
4222        return ['x' => 'repeat', 'y' => 'repeat'];
4223    }
4224
4225    /**
4226     * CSS Backgrounds 3 §3.7 `round` per-axis rescale: scale the
4227     * tile so a whole number of tiles fits the positioning area,
4228     * preserving aspect when possible. Returns the rescaled tile
4229     * size; passthrough when the mode isn't `round` or the natural
4230     * tile dim is non-positive.
4231     */
4232    private function roundTileDim(string $mode, float $tileDim, float $originDim): float
4233    {
4234        if ($mode !== 'round' || $tileDim <= 0.0 || $originDim <= 0.0) {
4235            return $tileDim;
4236        }
4237        // CSS spec: number of tiles = round(originDim / tileDim).
4238        // At least 1 — a tile larger than the box still emits once.
4239        $n = max(1, (int) round($originDim / $tileDim));
4240        return $originDim / $n;
4241    }
4242
4243    /**
4244     * Resolve a CSS `background-position` value to a top-left offset of
4245     * the image rect within the background-positioning area (the box's
4246     * rect). Per CSS Backgrounds 3 §3.7, position offsets are
4247     * interpolated such that `0% 0%` puts the image's top-left at the
4248     * box's top-left and `100% 100%` puts the image's bottom-right at
4249     * the box's bottom-right (i.e. `offset = (box - image) × percent`).
4250     *
4251     * Phase-1 surface:
4252     *   - 1 keyword (`center` / `top` / `bottom` / `left` / `right`) → centred on the missing axis
4253     *   - 2 values (keyword | length | percentage), one per axis
4254     *   - Lengths offset directly; percentages use the spec formula.
4255     * Edge syntax (`right 10px bottom 20px`, 4-value form) lands later.
4256     *
4257     * @return array{offsetX: float, offsetY: float}
4258     */
4259    private function resolveBackgroundPosition(
4260        \Phpdftk\Css\Value\Value $value,
4261        float $imageWidth,
4262        float $imageHeight,
4263        float $boxWidth,
4264        float $boxHeight,
4265    ): array {
4266        $items = $value instanceof \Phpdftk\Css\Value\ValueList
4267            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
4268                ? $value->values
4269                : [$value];
4270        // 50% default applies only when entering the single-keyword
4271        // branch below: per CSS Backgrounds 3 §3.6, a single keyword
4272        // (e.g. `top`, `left`) pins one axis and centres the other.
4273        // The two-value / empty path enters `else` and lets
4274        // `axisOffsetFromValue` resolve each side from its own value
4275        // (or its null-default of 0%, the spec's initial value).
4276        $xPercent = 0.5;
4277        $yPercent = 0.5;
4278        $xLength = null;
4279        $yLength = null;
4280        // Single keyword: maps to one axis and centres the other.
4281        if (count($items) === 1 && $items[0] instanceof \Phpdftk\Css\Value\Keyword) {
4282            $kw = strtolower($items[0]->name);
4283            // Vertical-only keywords pin y; horizontal-only pin x.
4284            switch ($kw) {
4285                case 'top': $yPercent = 0.0;
4286                    break;
4287                case 'bottom': $yPercent = 1.0;
4288                    break;
4289                case 'left': $xPercent = 0.0;
4290                    break;
4291                case 'right': $xPercent = 1.0;
4292                    break;
4293                case 'center': default: break;
4294            }
4295        } else {
4296            // Two-value form: first is x, second is y.
4297            $xItem = $items[0] ?? null;
4298            $yItem = $items[1] ?? null;
4299            // CSS Backgrounds 3 §3.6 — when the author specifies exactly
4300            // ONE value (e.g. `background-position: 25%` or `-0px`), the
4301            // second value is `center` (50%), not the unspecified initial
4302            // `0%`. An EMPTY list (count 0) is the unspecified initial and
4303            // keeps `0% 0%`; a single keyword is handled above.
4304            $singleValue = count($items) === 1;
4305            $xAxis = $this->axisOffsetFromValue($xItem, isHorizontal: true);
4306            if ($xAxis['percent'] !== null) {
4307                $xPercent = $xAxis['percent'];
4308            }
4309            if ($xAxis['length'] !== null) {
4310                $xLength = $xAxis['length'];
4311            }
4312            if ($singleValue) {
4313                $yPercent = 0.5;
4314            } else {
4315                $yAxis = $this->axisOffsetFromValue($yItem, isHorizontal: false);
4316                if ($yAxis['percent'] !== null) {
4317                    $yPercent = $yAxis['percent'];
4318                }
4319                if ($yAxis['length'] !== null) {
4320                    $yLength = $yAxis['length'];
4321                }
4322            }
4323        }
4324        $offsetX = $xLength ?? ($boxWidth - $imageWidth) * $xPercent;
4325        $offsetY = $yLength ?? ($boxHeight - $imageHeight) * $yPercent;
4326        return ['offsetX' => $offsetX, 'offsetY' => $offsetY];
4327    }
4328
4329    /**
4330     * Classify a single `background-position` axis value into a
4331     * `{percent?, length?}` pair. Keywords (`top`/`bottom`/`left`/
4332     * `right`/`center`) become percentages; explicit `<length>` /
4333     * `<percentage>` values come through as-is.
4334     *
4335     * @return array{percent: ?float, length: ?float}
4336     */
4337    private function axisOffsetFromValue(
4338        ?\Phpdftk\Css\Value\Value $value,
4339        bool $isHorizontal,
4340    ): array {
4341        // CSS Backgrounds 3 §3.6 — `background-position` initial value
4342        // is `0% 0%` (top-left). A missing axis falls back to that
4343        // initial; the previous default of 50% (centre) misrouted any
4344        // explicit-tile case (e.g. `background-size: 12px auto`
4345        // without an explicit position) to centre instead of top-left.
4346        if ($value === null) {
4347            return ['percent' => 0.0, 'length' => null];
4348        }
4349        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
4350            $kw = strtolower($value->name);
4351            $percent = match ($kw) {
4352                'left', 'top' => 0.0,
4353                'right', 'bottom' => 1.0,
4354                'center' => 0.5,
4355                default => 0.5,
4356            };
4357            return ['percent' => $percent, 'length' => null];
4358        }
4359        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
4360            return ['percent' => $value->value / 100.0, 'length' => null];
4361        }
4362        if ($value instanceof \Phpdftk\Css\Value\Length) {
4363            return ['percent' => null, 'length' => $value->value];
4364        }
4365        // CSS Values 4 §4.2: bare `0` is treated as `0` in any
4366        // dimensional context, so `background-position: 0 0` resolves
4367        // to top-left anchor (not the default centre).
4368        if (($value instanceof \Phpdftk\Css\Value\Number
4369            || $value instanceof \Phpdftk\Css\Value\Integer)
4370            && (float) $value->value === 0.0
4371        ) {
4372            return ['percent' => null, 'length' => 0.0];
4373        }
4374        return ['percent' => 0.5, 'length' => null];
4375    }
4376
4377    /**
4378     * Compute the final paint rect for a CSS `background-size` value.
4379     * Returns the width / height (in points) and the offset (top-left
4380     * corner) within the containing background-positioning area. Phase 1
4381     * always centres `contain`-sized images; `background-position`
4382     * support lands later with the full Backgrounds 3 §3.7 grammar.
4383     *
4384     * @return array{w: float, h: float, offsetX: float, offsetY: float}
4385     */
4386    private function resolveBackgroundSize(
4387        ?\Phpdftk\Css\Value\Value $sizeValue,
4388        string $src,
4389        float $boxWidth,
4390        float $boxHeight,
4391    ): array {
4392        // Default / unset / `auto`: CSS Backgrounds 3 §3.9 — when both
4393        // axes are `auto` and the image has intrinsic dimensions, use
4394        // those dimensions; only fall back to box dims when the image
4395        // has no intrinsic info.
4396        $isAuto = $sizeValue === null
4397            || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword
4398                && strtolower($sizeValue->name) === 'auto');
4399        if ($isAuto) {
4400            // Per CSS Backgrounds 3 §3.9 — both auto and the image
4401            // has full intrinsic w+h (raster, or SVG with both axes
4402            // either fixed or derived from viewBox) → use those.
4403            $intrinsic = $this->intrinsicSize($src);
4404            if ($intrinsic !== null && $intrinsic[0] > 0 && $intrinsic[1] > 0) {
4405                return [
4406                    'w' => (float) $intrinsic[0],
4407                    'h' => (float) $intrinsic[1],
4408                    'offsetX' => 0.0,
4409                    'offsetY' => 0.0,
4410                ];
4411            }
4412            // Partial intrinsic (SVG with one fixed dim + no
4413            // viewBox): missing axes fall back to the bg-positioning
4414            // area dimension (CSS Images 3 §5.2 default object size).
4415            $partial = $this->intrinsicSizePartial($src);
4416            $w = $partial['w'];
4417            $h = $partial['h'];
4418            if ($w !== null || $h !== null) {
4419                return [
4420                    'w' => $w ?? $boxWidth,
4421                    'h' => $h ?? $boxHeight,
4422                    'offsetX' => 0.0,
4423                    'offsetY' => 0.0,
4424                ];
4425            }
4426            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
4427        }
4428        $keyword = $sizeValue instanceof \Phpdftk\Css\Value\Keyword
4429            ? strtolower($sizeValue->name)
4430            : null;
4431        if ($keyword === 'cover' || $keyword === 'contain') {
4432            $intrinsic = $this->intrinsicSize($src);
4433            if ($intrinsic === null) {
4434                // Fallback to stretch when we can't read natural size.
4435                return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
4436            }
4437            [$natW, $natH] = $intrinsic;
4438            // Extreme viewBox aspect ratios can collapse one axis to
4439            // zero (CSS Backgrounds 3 §3.9 considers the ratio
4440            // well-defined, but the derived dimension rounds to 0).
4441            // `contain` resolves cleanly via INF on the impossible
4442            // axis — min picks the finite scale and the zero side
4443            // stays zero — but `cover` would otherwise produce INF
4444            // dimensions. For cover with a degenerate axis, degrade
4445            // to stretch so the box is at least covered with finite
4446            // dimensions.
4447            if ($keyword === 'cover' && ($natW === 0 || $natH === 0)) {
4448                return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
4449            }
4450            $scaleW = $natW > 0 ? $boxWidth / $natW : INF;
4451            $scaleH = $natH > 0 ? $boxHeight / $natH : INF;
4452            $scale = $keyword === 'cover' ? max($scaleW, $scaleH) : min($scaleW, $scaleH);
4453            $finalW = $natW * $scale;
4454            $finalH = $natH * $scale;
4455            return [
4456                'w' => $finalW,
4457                'h' => $finalH,
4458                'offsetX' => ($boxWidth - $finalW) / 2,
4459                'offsetY' => ($boxHeight - $finalH) / 2,
4460            ];
4461        }
4462        // `<length> <length>` — explicit dimensions in a 2-element
4463        // space-separated ValueList. Per CSS Backgrounds 3 §3.9, when
4464        // one component is `auto`:
4465        //   • image has intrinsic ratio → derive from the other side
4466        //   • image has no intrinsic ratio → use 100% of the
4467        //     corresponding bg-positioning area dimension
4468        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
4469            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
4470        ) {
4471            $w = $sizeValue->values[0] ?? null;
4472            $h = $sizeValue->values[1] ?? null;
4473            $explicitW = $this->backgroundSizeAxisLength($w, $boxWidth);
4474            $explicitH = $this->backgroundSizeAxisLength($h, $boxHeight);
4475            [$finalW, $finalH] = $this->resolveAutoSizePair(
4476                $explicitW,
4477                $explicitH,
4478                $src,
4479                $boxWidth,
4480                $boxHeight,
4481            );
4482            return [
4483                'w' => $finalW,
4484                'h' => $finalH,
4485                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
4486                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
4487            ];
4488        }
4489        // Single-value `background-size`: a bare `<length>` or
4490        // `<percentage>` sets the width; the second axis defaults to
4491        // `auto` per CSS Backgrounds 3 §3.9 (derive from intrinsic
4492        // ratio, or fall back to the bg-positioning-area height).
4493        $singleW = $this->backgroundSizeAxisLength($sizeValue, $boxWidth);
4494        if ($singleW !== null) {
4495            [$finalW, $finalH] = $this->resolveAutoSizePair(
4496                $singleW,
4497                null,
4498                $src,
4499                $boxWidth,
4500                $boxHeight,
4501            );
4502            return [
4503                'w' => $finalW,
4504                'h' => $finalH,
4505                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
4506                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
4507            ];
4508        }
4509        return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
4510    }
4511
4512    /**
4513     * Resolve one axis of `background-size` from a single value to a
4514     * concrete pixel length. Lengths come through as-is; percentages
4515     * resolve against the bg-positioning area axis (CSS Backgrounds 3
4516     * §3.9). Any other value (including `auto` keywords) returns null
4517     * so the caller can route to the intrinsic-ratio path.
4518     */
4519    private function backgroundSizeAxisLength(
4520        ?\Phpdftk\Css\Value\Value $value,
4521        float $axisExtent,
4522    ): ?float {
4523        if ($value instanceof \Phpdftk\Css\Value\Length) {
4524            return $value->value;
4525        }
4526        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
4527            return $value->value / 100.0 * $axisExtent;
4528        }
4529        return null;
4530    }
4531
4532    /**
4533     * Return true when `background-repeat` paints a *single tile*
4534     * (or close to it). Used to gate the gradient tile-rect path —
4535     * single-tile semantics only make sense when the gradient
4536     * actually paints once.
4537     *
4538     * Honoured as single-tile:
4539     *   • `no-repeat` — one tile, exact spec
4540     *   • `space` — degenerate to no-repeat when only one tile
4541     *     fits, which is the common case for small tiles in small
4542     *     boxes. Tests in this cluster use `space` with positioned
4543     *     tiles where exactly one tile fits — matches `no-repeat`
4544     *     visually until we ship a real `space` distributor.
4545     */
4546    private function isNoRepeat(?\Phpdftk\Css\Value\Value $repeatValue): bool
4547    {
4548        if ($repeatValue instanceof \Phpdftk\Css\Value\Keyword) {
4549            $kw = strtolower($repeatValue->name);
4550            return $kw === 'no-repeat' || $kw === 'space';
4551        }
4552        if ($repeatValue instanceof \Phpdftk\Css\Value\ValueList
4553            && $repeatValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
4554        ) {
4555            $allNoRepeat = $repeatValue->values !== [];
4556            foreach ($repeatValue->values as $v) {
4557                if (!$v instanceof \Phpdftk\Css\Value\Keyword) {
4558                    $allNoRepeat = false;
4559                    break;
4560                }
4561                $kw = strtolower($v->name);
4562                if ($kw !== 'no-repeat' && $kw !== 'space') {
4563                    $allNoRepeat = false;
4564                    break;
4565                }
4566            }
4567            return $allNoRepeat;
4568        }
4569        return false;
4570    }
4571
4572    /**
4573     * Return true when the `background-size` value resolves to the
4574     * "fill the box" default. Treated as default:
4575     *   • null (property unset)
4576     *   • single keyword `auto` / unknown
4577     *   • two-value `auto auto`
4578     * Anything explicit (length, percentage, `cover`, `contain`,
4579     * `auto <length>`, `<length> auto`) is non-default and the
4580     * gradient renders at the resolved tile rect.
4581     */
4582    private function isDefaultGradientSize(?\Phpdftk\Css\Value\Value $sizeValue): bool
4583    {
4584        if ($sizeValue === null) {
4585            return true;
4586        }
4587        if ($sizeValue instanceof \Phpdftk\Css\Value\Keyword) {
4588            return strtolower($sizeValue->name) === 'auto';
4589        }
4590        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
4591            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
4592        ) {
4593            $allAuto = true;
4594            foreach ($sizeValue->values as $v) {
4595                $isAutoKw = $v instanceof \Phpdftk\Css\Value\Keyword
4596                    && strtolower($v->name) === 'auto';
4597                if (!$isAutoKw) {
4598                    $allAuto = false;
4599                    break;
4600                }
4601            }
4602            return $allAuto && $sizeValue->values !== [];
4603        }
4604        return false;
4605    }
4606
4607    /**
4608     * Compute the rect a CSS gradient tile occupies given the
4609     * background-size + background-position values and the
4610     * background-positioning area (`background-origin` rect).
4611     *
4612     * Gradients have no intrinsic size and no intrinsic ratio, so
4613     * CSS Backgrounds 3 §3.9 reduces to:
4614     *   • both auto / unset / unknown keyword → 100% × 100%
4615     *   • cover / contain                     → 100% × 100%
4616     *   • &lt;length&gt; auto                       → length × 100% height
4617     *   • auto &lt;length&gt;                       → 100% width × length
4618     *   • &lt;length&gt; &lt;length&gt;                   → explicit pair
4619     * Position resolves the tile's offset within the positioning
4620     * area (CSS Backgrounds 3 §3.6 — keywords / percentages anchor;
4621     * lengths are direct offsets).
4622     *
4623     * @param array{x: float, top: float, width: float, height: float} $originRect
4624     * @return array{x: float, top: float, w: float, h: float}
4625     */
4626    private function computeGradientTileRect(
4627        ?\Phpdftk\Css\Value\Value $sizeValue,
4628        ?\Phpdftk\Css\Value\Value $positionValue,
4629        array $originRect,
4630    ): array {
4631        $originWidth = $originRect['width'];
4632        $originHeight = $originRect['height'];
4633        [$tileW, $tileH] = $this->resolveGradientTileSize(
4634            $sizeValue,
4635            $originWidth,
4636            $originHeight,
4637        );
4638        // resolveBackgroundPosition takes (image-w, image-h, box-w, box-h).
4639        // The gradient *tile* is the "image" being positioned within the
4640        // bg-positioning area (the "box"). When bg-position is null /
4641        // empty, the helper already defaults to 50%/50% — matches the
4642        // existing paintBackgroundImage handling.
4643        $offsets = $positionValue === null
4644            ? ['offsetX' => max(0.0, ($originWidth - $tileW) / 2),
4645                'offsetY' => max(0.0, ($originHeight - $tileH) / 2)]
4646            : $this->resolveBackgroundPosition(
4647                $positionValue,
4648                $tileW,
4649                $tileH,
4650                $originWidth,
4651                $originHeight,
4652            );
4653        return [
4654            'x' => $originRect['x'] + $offsets['offsetX'],
4655            'top' => $originRect['top'] + $offsets['offsetY'],
4656            'w' => $tileW,
4657            'h' => $tileH,
4658        ];
4659    }
4660
4661    /**
4662     * Resolve a CSS `background-size` value to a concrete (w, h)
4663     * for a gradient (no intrinsic dimensions, no intrinsic ratio).
4664     * See {@see computeGradientTileRect} for the matrix this
4665     * implements.
4666     *
4667     * @return array{0: float, 1: float}
4668     */
4669    private function resolveGradientTileSize(
4670        ?\Phpdftk\Css\Value\Value $sizeValue,
4671        float $originWidth,
4672        float $originHeight,
4673    ): array {
4674        if ($sizeValue === null) {
4675            return [$originWidth, $originHeight];
4676        }
4677        if ($sizeValue instanceof \Phpdftk\Css\Value\Keyword) {
4678            $kw = strtolower($sizeValue->name);
4679            // `cover` / `contain` need an intrinsic ratio to do
4680            // anything useful; without one they degrade to stretch.
4681            return [$originWidth, $originHeight];
4682        }
4683        if ($sizeValue instanceof \Phpdftk\Css\Value\Length) {
4684            // Single length sets width; height = auto = 100% of
4685            // positioning area (no intrinsic ratio for gradients).
4686            return [$sizeValue->value, $originHeight];
4687        }
4688        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
4689            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
4690        ) {
4691            $w = $sizeValue->values[0] ?? null;
4692            $h = $sizeValue->values[1] ?? null;
4693            $tileW = $w instanceof \Phpdftk\Css\Value\Length ? $w->value : $originWidth;
4694            $tileH = $h instanceof \Phpdftk\Css\Value\Length ? $h->value : $originHeight;
4695            // Percentage tile dims resolve against the positioning area.
4696            if ($w instanceof \Phpdftk\Css\Value\Percentage) {
4697                $tileW = $originWidth * ($w->value / 100.0);
4698            }
4699            if ($h instanceof \Phpdftk\Css\Value\Percentage) {
4700                $tileH = $originHeight * ($h->value / 100.0);
4701            }
4702            return [$tileW, $tileH];
4703        }
4704        return [$originWidth, $originHeight];
4705    }
4706
4707    /**
4708     * Resolve a two-component `background-size` where one or both
4709     * sides may be `auto`. Implements CSS Backgrounds 3 §3.9 auto
4710     * resolution: intrinsic ratio derives the missing side; if no
4711     * ratio, auto resolves to 100% of the positioning area.
4712     *
4713     * @return array{0: float, 1: float}
4714     */
4715    private function resolveAutoSizePair(
4716        ?float $explicitW,
4717        ?float $explicitH,
4718        string $src,
4719        float $boxWidth,
4720        float $boxHeight,
4721    ): array {
4722        if ($explicitW !== null && $explicitH !== null) {
4723            return [$explicitW, $explicitH];
4724        }
4725        // Prefer the FULL intrinsic (raster, or SVG with both axes
4726        // either fixed or derivable from viewBox). Only fall to the
4727        // partial helper when no complete intrinsic is available —
4728        // e.g. an SVG with just one fixed axis and no viewBox.
4729        $intrinsic = $this->intrinsicSize($src);
4730        $hasFullIntrinsic = $intrinsic !== null && $intrinsic[0] > 0 && $intrinsic[1] > 0;
4731        if ($hasFullIntrinsic) {
4732            $natW = (float) $intrinsic[0];
4733            $natH = (float) $intrinsic[1];
4734            if ($explicitW === null && $explicitH === null) {
4735                return [$natW, $natH];
4736            }
4737            if ($explicitW !== null) {
4738                return [$explicitW, $explicitW * ($natH / $natW)];
4739            }
4740            assert($explicitH !== null);
4741            return [$explicitH * ($natW / $natH), $explicitH];
4742        }
4743        $partial = $this->intrinsicSizePartial($src);
4744        $intW = $partial['w'];
4745        $intH = $partial['h'];
4746        if ($explicitW === null && $explicitH === null) {
4747            return [$intW ?? $boxWidth, $intH ?? $boxHeight];
4748        }
4749        if ($explicitW !== null) {
4750            return [$explicitW, $intH ?? $boxHeight];
4751        }
4752        assert($explicitH !== null);
4753        return [$intW ?? $boxWidth, $explicitH];
4754    }
4755
4756    /**
4757     * Partial intrinsic dimensions of an image. Unlike
4758     * {@see intrinsicSize}, this returns nullable per-axis values
4759     * plus a `hasRatio` flag so callers (notably the `bg-size: auto`
4760     * branches of {@see resolveBackgroundSize} / {@see resolveAutoSizePair})
4761     * can honour SVGs that declare only one of width / height /
4762     * viewBox per CSS Backgrounds 3 §3.9. For raster images the
4763     * shape is always full (both axes set, hasRatio derived).
4764     *
4765     * @return array{w: ?float, h: ?float, hasRatio: bool}
4766     */
4767    private function intrinsicSizePartial(string $src): array
4768    {
4769        if ($this->isSvgSrc($src)) {
4770            $svg = $this->loadSvgDocument($src);
4771            if ($svg === null) {
4772                return ['w' => null, 'h' => null, 'hasRatio' => false];
4773            }
4774            $w = self::parseSvgLengthAttribute($svg->widthAttribute());
4775            $h = self::parseSvgLengthAttribute($svg->heightAttribute());
4776            $viewBox = $svg->viewBox();
4777            // A ratio comes from any of: viewBox, fixed width+height,
4778            // or viewBox alone — anything that gives both axes
4779            // mathematically. The earlier `intrinsicSvgSize` already
4780            // backfills missing axes from ratio, so by the time
4781            // we're here for "partial intrinsic" we typically have
4782            // at most one fixed dim. Mirror the same ratio sources.
4783            $hasRatio = ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0)
4784                || ($w !== null && $h !== null && $h > 0.0);
4785            return ['w' => $w, 'h' => $h, 'hasRatio' => $hasRatio];
4786        }
4787        $intrinsic = $this->intrinsicSize($src);
4788        if ($intrinsic === null) {
4789            return ['w' => null, 'h' => null, 'hasRatio' => false];
4790        }
4791        return [
4792            'w' => (float) $intrinsic[0],
4793            'h' => (float) $intrinsic[1],
4794            'hasRatio' => $intrinsic[0] > 0 && $intrinsic[1] > 0,
4795        ];
4796    }
4797
4798    /**
4799     * Read the intrinsic pixel dimensions of an image referenced by an
4800     * `<img src>` or `background-image: url(...)` value. Tolerates both
4801     * `data:image/...` URIs and resolved local-file paths via the
4802     * painter's existing `resolveImageSrc`. Returns null when the bytes
4803     * can't be read or parsed.
4804     *
4805     * @return array{int, int}|null
4806     */
4807    private function intrinsicSize(string $src): ?array
4808    {
4809        if ($this->isSvgSrc($src)) {
4810            $svg = $this->loadSvgDocument($src);
4811            if ($svg === null) {
4812                return null;
4813            }
4814            return $this->intrinsicSvgSize($svg);
4815        }
4816        try {
4817            if (str_starts_with($src, 'data:image/')) {
4818                if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $src, $m) !== 1) {
4819                    return null;
4820                }
4821                $payload = $m[2] === 'base64,'
4822                    ? base64_decode($m[3], strict: true)
4823                    : urldecode($m[3]);
4824                if ($payload === false || $payload === '') {
4825                    return null;
4826                }
4827                $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload);
4828            } else {
4829                $resolved = $this->resolveImageSrc($src);
4830                if ($resolved === null) {
4831                    return null;
4832                }
4833                $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved);
4834            }
4835        } catch (\Throwable) {
4836            return null;
4837        }
4838        return [$info->width, $info->height];
4839    }
4840
4841    /**
4842     * Detect SVG background sources: `.svg` URLs and `data:image/svg+xml`
4843     * URIs. Case-insensitive on the extension to match CSS / file-system
4844     * conventions.
4845     */
4846    private function isSvgSrc(string $src): bool
4847    {
4848        if (str_starts_with(strtolower($src), 'data:image/svg+xml')) {
4849            return true;
4850        }
4851        $path = parse_url($src, PHP_URL_PATH);
4852        if (!is_string($path) || $path === '') {
4853            $path = $src;
4854        }
4855        return strtolower((string) pathinfo($path, PATHINFO_EXTENSION)) === 'svg';
4856    }
4857
4858    /**
4859     * Parse an SVG background source into an `SvgDocument`, memoising by
4860     * `$src` so each unique URL is read + parsed once per Painter. Returns
4861     * `null` on read / parse failure (the caller paints nothing — same
4862     * fallback as raster sources that fail to parse).
4863     */
4864    private function loadSvgDocument(string $src): ?\Phpdftk\Svg\SvgDocument
4865    {
4866        if (array_key_exists($src, $this->svgDocumentCache)) {
4867            $cached = $this->svgDocumentCache[$src];
4868            return $cached === false ? null : $cached;
4869        }
4870        $bytes = null;
4871        try {
4872            if (str_starts_with($src, 'data:')) {
4873                $bytes = $this->decodeSvgDataUri($src);
4874            } else {
4875                $resolved = $this->resolveImageSrc($src);
4876                if ($resolved !== null) {
4877                    $bytes = \Phpdftk\Filesystem\LocalFilesystem::readFile($resolved, 'SVG background-image');
4878                }
4879            }
4880            if ($bytes === null || $bytes === '') {
4881                $this->svgDocumentCache[$src] = false;
4882                return null;
4883            }
4884            $doc = (new \Phpdftk\Svg\Parser())->parse($bytes);
4885        } catch (\Throwable) {
4886            $this->svgDocumentCache[$src] = false;
4887            return null;
4888        }
4889        $this->svgDocumentCache[$src] = $doc;
4890        return $doc;
4891    }
4892
4893    private function decodeSvgDataUri(string $src): ?string
4894    {
4895        if (preg_match('~^data:image/svg\+xml(?:;[^,]*)?,(.*)$~is', $src, $m) !== 1) {
4896            return null;
4897        }
4898        $payload = $m[1];
4899        if (str_contains(strtolower($src), ';base64,')) {
4900            $decoded = base64_decode($payload, strict: true);
4901            return $decoded === false ? null : $decoded;
4902        }
4903        return rawurldecode($payload);
4904    }
4905
4906    /**
4907     * Derive an intrinsic pixel size from an `<svg>` element per
4908     * CSS Images 3 §5.2:
4909     *   - both width + height set in absolute units    → use them
4910     *   - only width  + intrinsic aspect ratio         → (w, w/aspect)
4911     *   - only height + intrinsic aspect ratio         → (h*aspect, h)
4912     *   - viewBox only                                 → viewBox dims
4913     *   - nothing useful                               → null
4914     *
4915     * Returns null when the SVG has no intrinsic dimensions AND no
4916     * intrinsic ratio. The caller (`resolveBackgroundSize` / friends)
4917     * then falls back to the background-positioning area for `cover`
4918     * / `contain` (CSS Backgrounds 3 §3.9 — "no intrinsic ratio →
4919     * 100% × 100% of the positioning area"). Earlier this method
4920     * returned `[300, 150]` (the CSS Images 3 §5.3 default object
4921     * size), but that constant misroutes the cover/contain math for
4922     * SVGs without intrinsic information.
4923     *
4924     * @return array{int, int}|null
4925     */
4926    private function intrinsicSvgSize(\Phpdftk\Svg\SvgDocument $svg): ?array
4927    {
4928        $w = self::parseSvgLengthAttribute($svg->widthAttribute());
4929        $h = self::parseSvgLengthAttribute($svg->heightAttribute());
4930        $viewBox = $svg->viewBox();
4931        $aspect = null;
4932        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
4933            $aspect = $viewBox[2] / $viewBox[3];
4934        } elseif ($w !== null && $h !== null && $h > 0.0) {
4935            $aspect = $w / $h;
4936        }
4937        if ($w !== null && $h !== null) {
4938            return [max(1, (int) round($w)), max(1, (int) round($h))];
4939        }
4940        // Derived-from-aspect: keep the clamp on the *explicit* side
4941        // (so a real `width="8"` survives rounding), but let the
4942        // derived side fall to zero when an extreme viewBox aspect
4943        // (e.g. `2147483647:1`) makes the other dimension
4944        // mathematically negligible. CSS Backgrounds 3 §3.9 then
4945        // resolves `contain` to a zero-extent tile — matching the
4946        // browser-visible "renders as empty" outcome that the
4947        // `tall--contain--height` / `wide--contain--height` reftests
4948        // expect for SVGs with extreme viewBox aspect ratios.
4949        if ($w !== null && $aspect !== null && $aspect > 0.0) {
4950            return [max(1, (int) round($w)), max(0, (int) round($w / $aspect))];
4951        }
4952        if ($h !== null && $aspect !== null && $aspect > 0.0) {
4953            return [max(0, (int) round($h * $aspect)), max(1, (int) round($h))];
4954        }
4955        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
4956            return [max(1, (int) round($viewBox[2])), max(1, (int) round($viewBox[3]))];
4957        }
4958        return null;
4959    }
4960
4961    /**
4962     * Parse an SVG length attribute like `"8"`, `"8px"`, `"50%"`. Returns
4963     * a float when the value is an absolute length (with no unit or `px`);
4964     * returns null for percentages, em / rem / vw etc. — those aren't
4965     * intrinsic for sizing purposes (CSS Images 3 §5.2).
4966     */
4967    private static function parseSvgLengthAttribute(?string $raw): ?float
4968    {
4969        if ($raw === null) {
4970            return null;
4971        }
4972        $raw = trim($raw);
4973        if ($raw === '') {
4974            return null;
4975        }
4976        if (preg_match('/^(-?\d+(?:\.\d+)?)(px)?$/', $raw, $m) !== 1) {
4977            return null;
4978        }
4979        return (float) $m[1];
4980    }
4981
4982    /**
4983     * Paint a CSS `border-image` 9-slice grid in place of the per-side
4984     * border colours/styles (CSS Backgrounds 3 §6). Returns true when
4985     * a border-image was painted; the caller skips legacy border
4986     * painting in that case.
4987     *
4988     * Phase-1 scope:
4989     *   • `border-image-source`: `url(...)` raster (PNG/JPG) only
4990     *   • `border-image-slice`: a single number (px from each edge)
4991     *     or `<n> fill` — single-percentage and four-value variants
4992     *     are a follow-up
4993     *   • `border-image-width`: implicit (use the box's border-width)
4994     *   • `border-image-outset`: implicit (zero)
4995     *   • `border-image-repeat`: `stretch` (initial), `repeat`, `round`
4996     *
4997     * Anything outside that surface falls through to the legacy
4998     * paint-borders path.
4999     */
5000    private function paintBorderImage(Box $box, ContentStream $stream): bool
5001    {
5002        if ($this->writer === null || $this->page === null) {
5003            return false;
5004        }
5005        $source = $box->style->get('border-image-source');
5006        if (!$source instanceof \Phpdftk\Css\Value\Url) {
5007            return false;
5008        }
5009        // Raster only for the first cut — SVG border-image needs the
5010        // SvgRenderer slicing path, deferred.
5011        if ($this->isSvgSrc($source->url)) {
5012            return false;
5013        }
5014        if (isset($this->imageNameCache[$source->url])) {
5015            $name = $this->imageNameCache[$source->url];
5016        } else {
5017            $resolved = $this->resolveImageSrc($source->url);
5018            if ($resolved === null) {
5019                return false;
5020            }
5021            try {
5022                $name = $this->writer->addImage($resolved, $this->page);
5023            } catch (\Throwable) {
5024                return false;
5025            }
5026            $this->imageNameCache[$source->url] = $name;
5027        }
5028        $intrinsic = $this->intrinsicSize($source->url);
5029        if ($intrinsic === null) {
5030            return false;
5031        }
5032        [$srcW, $srcH] = $intrinsic;
5033        if ($srcW <= 0 || $srcH <= 0) {
5034            return false;
5035        }
5036        // Slice dimensions: 1–4 values in `<number>` / `<length>` /
5037        // `<percentage>`, applied to top/right/bottom/left in the
5038        // standard "TRBL fill-in" rule (CSS Backgrounds 3 §6.4.3).
5039        // Horizontal slices (top, bottom) resolve their percentages
5040        // against the image's height; vertical slices (left, right)
5041        // resolve against width.
5042        $sliceValue = $box->style->get('border-image-slice');
5043        $slices = $this->resolveBorderImageSliceSides($sliceValue, (float) $srcW, (float) $srcH);
5044        if ($slices === null) {
5045            return false;
5046        }
5047        [$st, $sr, $sb, $sl] = $slices;
5048        $st = min($st, (float) $srcH / 2.0);
5049        $sb = min($sb, (float) $srcH / 2.0);
5050        $sl = min($sl, (float) $srcW / 2.0);
5051        $sr = min($sr, (float) $srcW / 2.0);
5052        if ($st <= 0.0 && $sr <= 0.0 && $sb <= 0.0 && $sl <= 0.0) {
5053            return false;
5054        }
5055
5056        $repeatMode = $this->parseBorderImageRepeat($box->style->get('border-image-repeat'));
5057
5058        // Destination border area: the box's border-box rect.
5059        $geo = $box->geometry;
5060        $bx = $geo->x - $geo->paddingLeft - $geo->borderLeft;
5061        $by = $geo->y - $geo->paddingTop - $geo->borderTop;
5062        $bw = $geo->paddingLeft + $geo->width + $geo->paddingRight
5063            + $geo->borderLeft + $geo->borderRight;
5064        $bh = $geo->paddingTop + $geo->height + $geo->paddingBottom
5065            + $geo->borderTop + $geo->borderBottom;
5066        $bt = $geo->borderTop;
5067        $br = $geo->borderRight;
5068        $bb = $geo->borderBottom;
5069        $bl = $geo->borderLeft;
5070        if ($bw <= 0.0 || $bh <= 0.0) {
5071            return false;
5072        }
5073
5074        // 9 destination + source rects. Source coords use the SVG/PNG
5075        // convention: (0, 0) top-left, y grows down. Destination coords
5076        // here are layout-space (Y down too); we convert to PDF
5077        // (Y up) inside `emitImageSlice`.
5078        $midSrcW = max(0.0, (float) $srcW - $sl - $sr);
5079        $midSrcH = max(0.0, (float) $srcH - $st - $sb);
5080        $midDstW = max(0.0, $bw - $bl - $br);
5081        $midDstH = max(0.0, $bh - $bt - $bb);
5082
5083        // Corners — always stretched (no tile / round per spec §6.3).
5084        $this->emitImageSlice($stream, $name, $srcW, $srcH, 0.0, 0.0, $sl, $st, $bx, $by, $bl, $bt);
5085        $this->emitImageSlice($stream, $name, $srcW, $srcH, (float) $srcW - $sr, 0.0, $sr, $st, $bx + $bw - $br, $by, $br, $bt);
5086        $this->emitImageSlice($stream, $name, $srcW, $srcH, 0.0, (float) $srcH - $sb, $sl, $sb, $bx, $by + $bh - $bb, $bl, $bb);
5087        $this->emitImageSlice($stream, $name, $srcW, $srcH, (float) $srcW - $sr, (float) $srcH - $sb, $sr, $sb, $bx + $bw - $br, $by + $bh - $bb, $br, $bb);
5088
5089        // Edges — apply repeat mode to the tile axis only.
5090        if ($midSrcW > 0.0 && $midDstW > 0.0 && $bt > 0.0) {
5091            $this->emitImageEdge(
5092                $stream,
5093                $name,
5094                $srcW,
5095                $srcH,
5096                $sl,
5097                0.0,
5098                $midSrcW,
5099                $st,
5100                $bx + $bl,
5101                $by,
5102                $midDstW,
5103                $bt,
5104                $repeatMode,
5105                horizontal: true,
5106            );
5107        }
5108        if ($midSrcW > 0.0 && $midDstW > 0.0 && $bb > 0.0) {
5109            $this->emitImageEdge(
5110                $stream,
5111                $name,
5112                $srcW,
5113                $srcH,
5114                $sl,
5115                (float) $srcH - $sb,
5116                $midSrcW,
5117                $sb,
5118                $bx + $bl,
5119                $by + $bh - $bb,
5120                $midDstW,
5121                $bb,
5122                $repeatMode,
5123                horizontal: true,
5124            );
5125        }
5126        if ($midSrcH > 0.0 && $midDstH > 0.0 && $bl > 0.0) {
5127            $this->emitImageEdge(
5128                $stream,
5129                $name,
5130                $srcW,
5131                $srcH,
5132                0.0,
5133                $st,
5134                $sl,
5135                $midSrcH,
5136                $bx,
5137                $by + $bt,
5138                $bl,
5139                $midDstH,
5140                $repeatMode,
5141                horizontal: false,
5142            );
5143        }
5144        if ($midSrcH > 0.0 && $midDstH > 0.0 && $br > 0.0) {
5145            $this->emitImageEdge(
5146                $stream,
5147                $name,
5148                $srcW,
5149                $srcH,
5150                (float) $srcW - $sr,
5151                $st,
5152                $sr,
5153                $midSrcH,
5154                $bx + $bw - $br,
5155                $by + $bt,
5156                $br,
5157                $midDstH,
5158                $repeatMode,
5159                horizontal: false,
5160            );
5161        }
5162        return true;
5163    }
5164
5165    /**
5166     * Emit a single image slice — the source rect (sx, sy, sw, sh) in
5167     * source pixel coords lands at destination rect (dx, dy, dw, dh)
5168     * in layout coords. Stretched (no tiling) — used for corners.
5169     */
5170    private function emitImageSlice(
5171        ContentStream $stream,
5172        string $imageName,
5173        int $srcW,
5174        int $srcH,
5175        float $sx,
5176        float $sy,
5177        float $sw,
5178        float $sh,
5179        float $dx,
5180        float $dy,
5181        float $dw,
5182        float $dh,
5183    ): void {
5184        if ($sw <= 0.0 || $sh <= 0.0 || $dw <= 0.0 || $dh <= 0.0) {
5185            return;
5186        }
5187        // PDF y is up; image unit-y=0 is bottom (after the y-flip
5188        // implicit in addImage). Source rect's bottom in source-px:
5189        // `srcH - (sy + sh)`. Place the FULL image such that this
5190        // bottom-of-slice falls on PDF dy.
5191        $pdfDy = $this->pageHeight - $dy - $dh;
5192        $sliceBotSrc = (float) $srcH - $sy - $sh;
5193        $scaleX = $dw * (float) $srcW / $sw;
5194        $scaleY = $dh * (float) $srcH / $sh;
5195        $tx = $dx - $dw * $sx / $sw;
5196        $ty = $pdfDy - $dh * $sliceBotSrc / $sh;
5197        $stream->saveGraphicsState();
5198        $stream->rectangle($dx, $pdfDy, $dw, $dh);
5199        $stream->clip();
5200        $stream->endPath();
5201        $stream->concatMatrix($scaleX, 0.0, 0.0, $scaleY, $tx, $ty);
5202        $stream->doXObject($imageName);
5203        $stream->restoreGraphicsState();
5204    }
5205
5206    /**
5207     * Emit an edge slice (top/right/bottom/left) of a border-image.
5208     * `horizontal: true` tiles along the X axis; false tiles along Y.
5209     * `repeatMode`: 'stretch' (default) — single stretched draw;
5210     * 'repeat' — tile at natural size from the leading edge; 'round'
5211     * — scale-to-fit-whole-tiles.
5212     */
5213    private function emitImageEdge(
5214        ContentStream $stream,
5215        string $imageName,
5216        int $srcW,
5217        int $srcH,
5218        float $sx,
5219        float $sy,
5220        float $sw,
5221        float $sh,
5222        float $dx,
5223        float $dy,
5224        float $dw,
5225        float $dh,
5226        string $repeatMode,
5227        bool $horizontal,
5228    ): void {
5229        if ($repeatMode === 'stretch') {
5230            $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx, $dy, $dw, $dh);
5231            return;
5232        }
5233        // Tile dim along the variable axis; the other dim is fixed
5234        // (border thickness).
5235        $tileLen = $horizontal ? $dh * $sw / $sh : $dw * $sh / $sw;
5236        $axisDest = $horizontal ? $dw : $dh;
5237        if ($tileLen <= 0.0 || $axisDest <= 0.0) {
5238            return;
5239        }
5240        if ($repeatMode === 'round') {
5241            $n = max(1, (int) round($axisDest / $tileLen));
5242            $tileLen = $axisDest / $n;
5243        }
5244        // Iterate tiles. Clip to the destination edge so partial
5245        // tiles get cropped cleanly.
5246        $pdfDy = $this->pageHeight - $dy - $dh;
5247        $stream->saveGraphicsState();
5248        $stream->rectangle($dx, $pdfDy, $dw, $dh);
5249        $stream->clip();
5250        $stream->endPath();
5251        $maxTiles = 4096;
5252        $count = 0;
5253        if ($horizontal) {
5254            $cursor = 0.0;
5255            while ($cursor < $dw && $count++ < $maxTiles) {
5256                $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx + $cursor, $dy, $tileLen, $dh);
5257                $cursor += $tileLen;
5258            }
5259        } else {
5260            $cursor = 0.0;
5261            while ($cursor < $dh && $count++ < $maxTiles) {
5262                $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx, $dy + $cursor, $dw, $tileLen);
5263                $cursor += $tileLen;
5264            }
5265        }
5266        $stream->restoreGraphicsState();
5267    }
5268
5269    private function parseBorderImageSliceNumber(?\Phpdftk\Css\Value\Value $value): ?float
5270    {
5271        if ($value instanceof \Phpdftk\Css\Value\Number
5272            || $value instanceof \Phpdftk\Css\Value\Integer
5273        ) {
5274            return (float) $value->value;
5275        }
5276        if ($value instanceof \Phpdftk\Css\Value\Length) {
5277            return $value->value;
5278        }
5279        // Single number wrapped in a ValueList with an optional `fill`
5280        // keyword — accept the leading number; the `fill` middle paint
5281        // is a follow-up.
5282        if ($value instanceof \Phpdftk\Css\Value\ValueList && $value->values !== []) {
5283            return $this->parseBorderImageSliceNumber($value->values[0]);
5284        }
5285        return null;
5286    }
5287
5288    /**
5289     * Resolve a single `border-image-slice` component (number, length
5290     * or percentage). Percentages resolve against the supplied axis
5291     * extent per CSS Backgrounds 3 §6.4.3. Returns null when the value
5292     * isn't a slice-shape we recognise.
5293     */
5294    private function resolveBorderImageSliceComponent(
5295        ?\Phpdftk\Css\Value\Value $value,
5296        float $axisExtent,
5297    ): ?float {
5298        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
5299            return $value->value / 100.0 * $axisExtent;
5300        }
5301        return $this->parseBorderImageSliceNumber($value);
5302    }
5303
5304    /**
5305     * Expand the 1–4-value `border-image-slice` shorthand into the
5306     * per-side `[top, right, bottom, left]` tuple. Drops a trailing
5307     * `fill` keyword (handled separately when middle-fill paint
5308     * lands).
5309     *
5310     * @return array{0:float, 1:float, 2:float, 3:float}|null
5311     */
5312    private function resolveBorderImageSliceSides(
5313        ?\Phpdftk\Css\Value\Value $value,
5314        float $srcW,
5315        float $srcH,
5316    ): ?array {
5317        $components = [];
5318        if ($value instanceof \Phpdftk\Css\Value\ValueList
5319            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
5320        ) {
5321            foreach ($value->values as $v) {
5322                if ($v instanceof \Phpdftk\Css\Value\Keyword
5323                    && strtolower($v->name) === 'fill'
5324                ) {
5325                    continue;
5326                }
5327                $components[] = $v;
5328            }
5329        } elseif ($value !== null) {
5330            $components[] = $value;
5331        }
5332        if ($components === []) {
5333            return null;
5334        }
5335        // Horizontal slices (top, bottom) resolve `%` against srcH;
5336        // vertical (right, left) against srcW.
5337        $sides = [
5338            'top' => $this->resolveBorderImageSliceComponent($components[0], $srcH),
5339        ];
5340        if (isset($components[1])) {
5341            $sides['right'] = $this->resolveBorderImageSliceComponent($components[1], $srcW);
5342        } else {
5343            $sides['right'] = $sides['top'];
5344        }
5345        if (isset($components[2])) {
5346            $sides['bottom'] = $this->resolveBorderImageSliceComponent($components[2], $srcH);
5347        } else {
5348            $sides['bottom'] = $sides['top'];
5349        }
5350        if (isset($components[3])) {
5351            $sides['left'] = $this->resolveBorderImageSliceComponent($components[3], $srcW);
5352        } else {
5353            $sides['left'] = $sides['right'];
5354        }
5355        foreach ($sides as $side) {
5356            if ($side === null) {
5357                return null;
5358            }
5359        }
5360        return [$sides['top'], $sides['right'], $sides['bottom'], $sides['left']];
5361    }
5362
5363    private function parseBorderImageRepeat(?\Phpdftk\Css\Value\Value $value): string
5364    {
5365        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
5366            $kw = strtolower($value->name);
5367            return match ($kw) {
5368                'repeat' => 'repeat',
5369                'round' => 'round',
5370                'space' => 'repeat', // approximate — true space distribution is a follow-up
5371                'stretch' => 'stretch',
5372                default => 'stretch',
5373            };
5374        }
5375        if ($value instanceof \Phpdftk\Css\Value\ValueList && $value->values !== []) {
5376            return $this->parseBorderImageRepeat($value->values[0]);
5377        }
5378        return 'stretch';
5379    }
5380
5381    private function svgRenderer(): \Phpdftk\SvgToPdf\SvgRenderer
5382    {
5383        if ($this->svgRenderer === null) {
5384            assert($this->page !== null && $this->writer !== null);
5385            $this->svgRenderer = new \Phpdftk\SvgToPdf\SvgRenderer($this->page, $this->writer);
5386        }
5387        return $this->svgRenderer;
5388    }
5389
5390    private function inlineSvgAdapter(): \Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter
5391    {
5392        if ($this->inlineSvgAdapter === null) {
5393            $this->inlineSvgAdapter = new \Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter();
5394        }
5395        return $this->inlineSvgAdapter;
5396    }
5397
5398    private function inlineMathmlAdapter(): \Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter
5399    {
5400        if ($this->inlineMathmlAdapter === null) {
5401            $this->inlineMathmlAdapter = new \Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter();
5402        }
5403        return $this->inlineMathmlAdapter;
5404    }
5405
5406    private function mathmlRenderer(): \Phpdftk\MathmlToPdf\MathmlRenderer
5407    {
5408        if ($this->mathmlRenderer === null) {
5409            assert($this->page !== null && $this->writer !== null);
5410            $this->mathmlRenderer = new \Phpdftk\MathmlToPdf\MathmlRenderer($this->page, $this->writer);
5411        }
5412        return $this->mathmlRenderer;
5413    }
5414
5415    /**
5416     * Resolve the math-font {@see OpenTypeData} for `$box` by
5417     * reading the cascaded `font-family` and looking it up in
5418     * `$fontDataByFamily`. Returns null when no family matches,
5419     * the matching font has no MATH table, or the cascade hasn't
5420     * resolved a usable family name.
5421     *
5422     * Used by paintInlineMath to thread the cascade-loaded font
5423     * (typically via `@font-face`) into the MathmlRenderer so its
5424     * MATH-table constants (FractionRuleThickness, axis height,
5425     * etc.) drive layout. Without this hook MathmlRenderer falls
5426     * back to its tracer-bullet defaults regardless of what
5427     * `font-family` the cascade resolved.
5428     */
5429    private function mathFontDataFor(
5430        \Phpdftk\HtmlToPdf\Box\AtomicInlineBox $box,
5431    ): ?\Phpdftk\FontParser\FontFaceData {
5432        if ($this->fontDataByFamily === []) {
5433            return null;
5434        }
5435        $family = $box->style->get('font-family');
5436        $names = [];
5437        if ($family instanceof \Phpdftk\Css\Value\StringValue) {
5438            $names[] = $family->value;
5439        } elseif ($family instanceof \Phpdftk\Css\Value\Keyword) {
5440            $names[] = $family->name;
5441        } elseif ($family instanceof \Phpdftk\Css\Value\ValueList) {
5442            foreach ($family->values as $entry) {
5443                if ($entry instanceof \Phpdftk\Css\Value\StringValue) {
5444                    $names[] = $entry->value;
5445                } elseif ($entry instanceof \Phpdftk\Css\Value\Keyword) {
5446                    $names[] = $entry->name;
5447                }
5448            }
5449        }
5450        foreach ($names as $name) {
5451            $key = strtolower(trim($name));
5452            $data = $this->fontDataByFamily[$key] ?? null;
5453            if ($data !== null
5454                && $data->mathTable !== null
5455                && $data->mathTable->hasMathConstants()
5456            ) {
5457                return $data;
5458            }
5459        }
5460        return null;
5461    }
5462
5463    /**
5464     * Build a fresh {@see MathmlRenderer} for `$box` with its
5465     * math-font data threaded in. Falls back to the cached default
5466     * renderer when no MATH-table font matches the cascade -
5467     * keeps the common "no math font" path zero-cost.
5468     *
5469     * Intentionally retained but not yet wired into the paint path:
5470     * switching to the per-element renderer regresses
5471     * `painting-stretchy-operator-001` and `frac-default-padding`
5472     * (see the call site in {@see paintMathml}). Kept as the #105
5473     * math-font-handoff substrate.
5474     *
5475     * @phpstan-ignore method.unused
5476     */
5477    private function mathmlRendererFor(
5478        \Phpdftk\HtmlToPdf\Box\AtomicInlineBox $box,
5479    ): \Phpdftk\MathmlToPdf\MathmlRenderer {
5480        $fontData = $this->mathFontDataFor($box);
5481        // The MathML renderer needs CFF outlines for its math-table-
5482        // driven glyph variants. TrueType math fonts exist (`STIXTwoMath`
5483        // ships both), but the math path is wired through the OT-CFF
5484        // subset/embed flow today. Drop back to the cached default
5485        // renderer for non-CFF data — the math layout still runs, just
5486        // without the size-variant / assembly-part substitution layer.
5487        if (!$fontData instanceof \Phpdftk\FontParser\OpenTypeData) {
5488            return $this->mathmlRenderer();
5489        }
5490        assert($this->page !== null && $this->writer !== null);
5491        return new \Phpdftk\MathmlToPdf\MathmlRenderer(
5492            $this->page,
5493            $this->writer,
5494            mathFontData: $fontData,
5495        );
5496    }
5497
5498    /**
5499     * Paint an inline `<math>` HTML element by adapting its subtree
5500     * into a typed MathmlDocument and handing the result to the
5501     * MathmlRenderer.
5502     *
5503     * Sizing precedence mirrors the inline-SVG painter (CSS Display
5504     * §3.5):
5505     *
5506     *   1. Box geometry from the CSS-cascaded `width` / `height` —
5507     *      only populated when InlineLayout actually laid the box
5508     *      out (see #39).
5509     *   2. Cascade values read directly (covers the no-font case
5510     *      where InlineLayout returns early).
5511     *
5512     * MathML doesn't have a viewBox or intrinsic-attribute shortcut
5513     * the way SVG does — when nothing in the cascade declares a
5514     * size, math content has an intrinsic size derived from its
5515     * glyph metrics. For the tracer-bullet renderer we default to
5516     * a one-line strip the same height as the renderer's default
5517     * font (12 pt × 1 line ≈ 14 pt) and an arbitrary width band
5518     * (200 pt) so a sized-from-glyphs <math> still produces output.
5519     * Real intrinsic sizing lands once MathmlRenderer learns to
5520     * measure its own glyphs (separate follow-up).
5521     *
5522     * A parse failure here is swallowed and the MathML silently
5523     * skipped — one malformed inline expression shouldn't poison
5524     * the whole page render.
5525     */
5526    private function paintInlineMath(
5527        \Phpdftk\Html\Dom\Element $element,
5528        Box $box,
5529        ContentStream $stream,
5530    ): void {
5531        $geo = $box->geometry;
5532        $width = $geo->width;
5533        $height = $geo->height;
5534        // Layer 2: read directly from the cascade when layout left
5535        // geometry zero. Same fix the SVG painter applies for the
5536        // no-font case (#39).
5537        if ($width <= 0.0) {
5538            $cascaded = $box->style->get('width');
5539            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
5540                $width = $cascaded->value;
5541            }
5542        }
5543        if ($height <= 0.0) {
5544            $cascaded = $box->style->get('height');
5545            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
5546                $height = $cascaded->value;
5547            }
5548        }
5549        // Parse the inline MathML before deriving any intrinsic
5550        // size so we can ask MathmlRenderer for its natural
5551        // dimensions when the cascade left both axes zero. Layer
5552        // 3 (intrinsic) sits between the cascade-explicit values
5553        // and the typographic fallback.
5554        try {
5555            $mathDoc = $this->inlineMathmlAdapter()->adapt($element);
5556        } catch (\Throwable) {
5557            return;
5558        }
5559        // Resolve the CSS-cascaded font size for the <math> element
5560        // so em-relative children (mpadded height="3em", mspace
5561        // depth="2em", ...) measure against the right base. WPT
5562        // fixtures expect CSS default of 16px (== 16pt under
5563        // html-to-pdf's 1px = 1pt cascade); the painter falls back
5564        // to MathmlRenderer::DEFAULT_FONT_SIZE when nothing is set.
5565        $fontSize = $this->dominantFontSize($box);
5566        if ($width <= 0.0 || $height <= 0.0) {
5567            [$intrinsicW, $intrinsicH] = $this->mathmlRenderer()
5568                ->intrinsicSize($mathDoc, $fontSize);
5569            if ($width <= 0.0 && $intrinsicW > 0.0) {
5570                $width = $intrinsicW;
5571            }
5572            if ($height <= 0.0 && $intrinsicH > 0.0) {
5573                $height = $intrinsicH;
5574            }
5575        }
5576        // Final fallback: a typographically sensible default sized
5577        // strip. 14 pt tall is one line of 12 pt math + a sliver
5578        // of leading; 200 pt wide is wider than any common single-
5579        // expression token sequence but the renderer just stops
5580        // emitting glyphs when content runs out.
5581        if ($height <= 0.0) {
5582            $height = 14.0;
5583        }
5584        if ($width <= 0.0) {
5585            $width = 200.0;
5586        }
5587        // CSS position: absolute / fixed on inline foreign content
5588        // is not currently honoured by InlineLayout (which always
5589        // places these along the line box). For the WPT MathML
5590        // fixtures that use `<math style="position: absolute;
5591        // top: 0; left: 0;">` to anchor the math to the page edge,
5592        // override geo->x / geo->y with the cascaded left / top
5593        // when present. Treats left / top as absolute page
5594        // coordinates - good enough when the math sits inside a
5595        // top-level positioned div (the common WPT pattern); a
5596        // proper containing-block calculation lives behind a
5597        // bigger layout fix.
5598        [$layoutX, $layoutY] = $this->resolveInlineAbsoluteOrigin(
5599            $box,
5600            $geo->x,
5601            $geo->y,
5602        );
5603        $pdfY = $this->pageHeight - $layoutY - $height;
5604        // Use the cached default MathmlRenderer. Math-font handoff
5605        // via `mathmlRendererFor($box)` (#105 substrate) stays
5606        // gated: even with the per-element CSS cascade now
5607        // projecting through (#107 + this PR's font-size hook),
5608        // switching renderers regresses two tests that pass under
5609        // the default-renderer path (painting-stretchy-operator-001
5610        // and frac-default-padding). Both expose latent gaps
5611        // (stretchy operator variant selection that fills the
5612        // container; fraction-padding metrics that match the
5613        // browser) which the math-font handoff makes visible but
5614        // doesn't yet address.
5615        $renderer = $this->mathmlRenderer();
5616        $ascentPt = $renderer->intrinsicAscent($mathDoc, $fontSize);
5617        $renderer->draw(
5618            $mathDoc,
5619            $layoutX,
5620            $pdfY,
5621            $width,
5622            $height,
5623            stream: $stream,
5624            fontSize: $fontSize,
5625            ascentPt: $ascentPt,
5626        );
5627    }
5628
5629    /**
5630     * Resolve the layout-space origin for an inline foreign
5631     * element when CSS `position` is `absolute` / `fixed`.
5632     * Returns `[x, y]` in layout (top-down) coordinates.
5633     *
5634     * Falls back to the box's layout-derived geometry when the
5635     * position keyword isn't a positioned form. When it IS
5636     * positioned, reads cascaded `left` / `top` Length values and
5637     * treats them as absolute page coordinates - sufficient for
5638     * the common case where the foreign content is inside the
5639     * initial containing block (or close enough that the
5640     * containing-block resolution from BlockLayout has already
5641     * shifted ancestors into position).
5642     *
5643     * @return array{0: float, 1: float}
5644     */
5645    private function resolveInlineAbsoluteOrigin(
5646        Box $box,
5647        float $defaultX,
5648        float $defaultY,
5649    ): array {
5650        $position = $box->style->get('position');
5651        if (!($position instanceof \Phpdftk\Css\Value\Keyword)) {
5652            return [$defaultX, $defaultY];
5653        }
5654        $keyword = strtolower($position->name);
5655        if ($keyword !== 'absolute' && $keyword !== 'fixed') {
5656            return [$defaultX, $defaultY];
5657        }
5658        $left = $box->style->get('left');
5659        $top = $box->style->get('top');
5660        $x = $left instanceof \Phpdftk\Css\Value\Length
5661            ? $left->value
5662            : $defaultX;
5663        $y = $top instanceof \Phpdftk\Css\Value\Length
5664            ? $top->value
5665            : $defaultY;
5666        return [$x, $y];
5667    }
5668
5669    /**
5670     * Paint an inline `<svg>` HTML element by adapting its subtree
5671     * into a typed SvgDocument and handing the result to the existing
5672     * SvgRenderer.
5673     *
5674     * Dimensions: prefer the box's resolved geometry (CSS-cascaded
5675     * `width` / `height` win over intrinsic). If geometry is zero
5676     * because the cascade left dimensions unresolved, fall back to
5677     * the `<svg width="…" height="…">` attributes (treated as CSS
5678     * pixels) — same precedence the inline-SVG sizing algorithm uses
5679     * in CSS Display §3.5.
5680     *
5681     * A parse failure here is swallowed and the SVG silently skipped
5682     * — one malformed inline-SVG shouldn't poison the whole page
5683     * render. The pdf consumer sees an empty box where the SVG would
5684     * have been; the rest of the document is unaffected.
5685     */
5686    /**
5687     * Paint an `<img src="*.svg">` (or `data:image/svg+xml`)
5688     * replaced-element by loading the external SVG and rendering it
5689     * into the box's CSS-laid-out geometry.
5690     *
5691     * Unlike `paintInlineSvg`, the SVG document here is an external
5692     * resource: the box's geometry is already resolved by the layout
5693     * engine from the cascade plus the intrinsic dimensions reported
5694     * by `SvgParser`. We just clip to the box rect, apply
5695     * `object-fit` / `object-position`, and delegate to the SvgRenderer.
5696     */
5697    private function paintImgSvg(
5698        \Phpdftk\Html\Dom\Element $element,
5699        Box $box,
5700        ContentStream $stream,
5701        string $src,
5702    ): void {
5703        if ($this->writer === null || $this->page === null) {
5704            return;
5705        }
5706        $geo = $box->geometry;
5707        if ($geo->width <= 0.0) {
5708            return;
5709        }
5710        $svgDoc = $this->loadSvgDocument($src);
5711        if ($svgDoc === null) {
5712            return;
5713        }
5714        $height = $geo->height > 0.0 ? $geo->height : $geo->width;
5715        // CSS Images 3 §5.3 — `object-fit` decides the painted SVG's
5716        // scale within the box; `object-position` decides where the
5717        // slack sits. Defaults: `fill` + centre.
5718        $fit = $this->objectFitKeyword($box);
5719        $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height);
5720        $positionValue = $box->style->get('object-position');
5721        if ($positionValue !== null
5722            && ($rect['w'] !== $geo->width || $rect['h'] !== $height)
5723        ) {
5724            $pos = $this->resolveBackgroundPosition(
5725                $positionValue,
5726                $rect['w'],
5727                $rect['h'],
5728                $geo->width,
5729                $height,
5730            );
5731            $rect['offsetX'] = $pos['offsetX'];
5732            $rect['offsetY'] = $pos['offsetY'];
5733        }
5734        $pdfY = $this->pageHeight - $geo->y - $height;
5735        $stream->saveGraphicsState();
5736        // Clip to the box rect so `cover` overflow doesn't bleed into
5737        // sibling boxes — same posture as `paintImage` for raster.
5738        $stream->rectangle($geo->x, $pdfY, $geo->width, $height);
5739        $stream->clip();
5740        $stream->endPath();
5741        try {
5742            $this->svgRenderer()->draw(
5743                $svgDoc,
5744                $geo->x + $rect['offsetX'],
5745                $pdfY + ($height - $rect['h'] - $rect['offsetY']),
5746                $rect['w'],
5747                $rect['h'],
5748                stream: $stream,
5749            );
5750        } catch (\Throwable) {
5751            // Swallow paint failures so one malformed SVG doesn't kill
5752            // the document render. Mirrors the raster path's catch.
5753        }
5754        $stream->restoreGraphicsState();
5755    }
5756
5757    private function paintInlineSvg(
5758        \Phpdftk\Html\Dom\Element $element,
5759        Box $box,
5760        ContentStream $stream,
5761    ): void {
5762        $geo = $box->geometry;
5763        $width = $geo->width;
5764        $height = $geo->height;
5765        // Sizing precedence for the inline SVG:
5766        //   1. Box geometry from CSS-cascaded width/height — only
5767        //      populated when InlineLayout actually laid the box out.
5768        //   2. Cascade values read directly (covers the no-font case
5769        //      where InlineLayout returns early before tokenisation,
5770        //      but the cascade still has Length values from author CSS).
5771        //   3. The svg element's own `width` / `height` attributes,
5772        //      parsed as CSS pixels.
5773        // Without precedence #2 a document with `#s { width: 50pt }`
5774        // and no embedded font would render the SVG at zero size.
5775        // Precedence #3 catches the bare `<svg width="80" height="60">`
5776        // case where neither CSS nor the cascade has a Length.
5777        if ($width <= 0.0) {
5778            $cascaded = $box->style->get('width');
5779            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
5780                $width = $cascaded->value;
5781            }
5782        }
5783        if ($height <= 0.0) {
5784            $cascaded = $box->style->get('height');
5785            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
5786                $height = $cascaded->value;
5787            }
5788        }
5789        if ($width <= 0.0) {
5790            $attr = $element->getAttribute('width');
5791            if ($attr !== null) {
5792                $width = $this->parseSvgLength($attr);
5793            }
5794        }
5795        if ($height <= 0.0) {
5796            $attr = $element->getAttribute('height');
5797            if ($attr !== null) {
5798                $height = $this->parseSvgLength($attr);
5799            }
5800        }
5801        // Final fallback: intrinsic dimensions from the viewBox's
5802        // width/height columns. SVG 2 §8.2 — when neither CSS nor a
5803        // width/height attr declares a size, the viewBox supplies the
5804        // intrinsic aspect ratio AND, in browsers, an intrinsic
5805        // pixel size for replaced-element layout (third + fourth
5806        // viewBox values treated as CSS pixels). The viewBox parser
5807        // lives in `ViewportElement::viewBox()` but we duplicate the
5808        // tiny extraction here to avoid forcing the SVG parser to
5809        // run on a zero-size SVG we'd otherwise have dropped.
5810        if ($width <= 0.0 || $height <= 0.0) {
5811            $vb = $this->parseViewBox($element->getAttribute('viewBox'));
5812            if ($vb !== null) {
5813                if ($width <= 0.0) {
5814                    $width = $vb[0] * 0.75;
5815                }
5816                if ($height <= 0.0) {
5817                    $height = $vb[1] * 0.75;
5818                }
5819            }
5820        }
5821        if ($width <= 0.0 || $height <= 0.0) {
5822            return;
5823        }
5824        try {
5825            $svgDoc = $this->inlineSvgAdapter()->adapt($element);
5826        } catch (\Throwable) {
5827            return;
5828        }
5829        // PDF y-axis runs bottom-up; the box geometry's `y` is the
5830        // top edge in CSS coords, so we flip relative to pageHeight.
5831        $pdfY = $this->pageHeight - $geo->y - $height;
5832        $this->svgRenderer()->draw(
5833            $svgDoc,
5834            $geo->x,
5835            $pdfY,
5836            $width,
5837            $height,
5838            stream: $stream,
5839        );
5840    }
5841
5842    /**
5843     * Pull the width/height columns out of an SVG `viewBox` attribute
5844     * for the intrinsic-sizing fallback. Returns `[width, height]` in
5845     * CSS pixels (viewBox values are unitless user-space coordinates,
5846     * which in the absence of any other sizing input are interpreted
5847     * as CSS pixels per SVG 2 §8.2). Returns null on parse failure.
5848     *
5849     * @return array{0: float, 1: float}|null
5850     */
5851    private function parseViewBox(?string $raw): ?array
5852    {
5853        if ($raw === null) {
5854            return null;
5855        }
5856        $parts = preg_split('/[\s,]+/', trim($raw)) ?: [];
5857        if (count($parts) !== 4) {
5858            return null;
5859        }
5860        foreach ($parts as $p) {
5861            if (!is_numeric($p)) {
5862                return null;
5863            }
5864        }
5865        $w = (float) $parts[2];
5866        $h = (float) $parts[3];
5867        if ($w <= 0.0 || $h <= 0.0) {
5868            return null;
5869        }
5870        return [$w, $h];
5871    }
5872
5873    /**
5874     * Tiny SVG-length parser used by the inline-SVG fallback path.
5875     * Strips an optional `px` / `pt` suffix and clamps to a non-
5876     * negative float. We accept only `px` and `pt` for now — any
5877     * other unit (cm, em, %) returns 0 and lets the dimension lookup
5878     * fall through to "skip".
5879     */
5880    private function parseSvgLength(string $value): float
5881    {
5882        $trimmed = trim($value);
5883        if ($trimmed === '') {
5884            return 0.0;
5885        }
5886        if (preg_match('/^([\d.]+)\s*(px|pt)?$/i', $trimmed, $m) !== 1) {
5887            return 0.0;
5888        }
5889        $n = (float) $m[1];
5890        if ($n <= 0.0) {
5891            return 0.0;
5892        }
5893        // `pt` arrives as PDF points already; `px` and the bare form
5894        // are CSS pixels at 96 dpi, which is 0.75 pt per px.
5895        $unit = isset($m[2]) ? strtolower($m[2]) : 'px';
5896        return $unit === 'pt' ? $n : $n * 0.75;
5897    }
5898
5899    private function paintBorders(Box $box, ContentStream $stream): void
5900    {
5901        // CSS Backgrounds 3 §6 — when `border-image-source` is set and
5902        // successfully loaded, it REPLACES the per-side border paint.
5903        // We delegate to the 9-slice painter and skip the legacy
5904        // border-colour/style rendering for this box.
5905        if ($this->paintBorderImage($box, $stream)) {
5906            return;
5907        }
5908        $geo = $box->geometry;
5909        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft;
5910        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop;
5911        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
5912            + $geo->borderLeft + $geo->borderRight;
5913        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
5914            + $geo->borderTop + $geo->borderBottom;
5915
5916        // Rounded-uniform-border fast path: all four sides share width +
5917        // colour + style, and any radius is set → emit one stroked
5918        // rounded path. Mixed-width/colour borders or no radius fall back
5919        // to the per-side rectangle path (still straight corners).
5920        $radii = $this->borderRadii($box);
5921        if (array_sum($radii) > 0.0 && $this->bordersAreUniform($box)) {
5922            $width = $geo->borderTop;
5923            if ($width > 0.0) {
5924                $this->emitRoundedStroke(
5925                    $stream,
5926                    $outerX + $width / 2,
5927                    $outerY + $width / 2,
5928                    $outerWidth - $width,
5929                    $outerHeight - $width,
5930                    $radii,
5931                    $this->borderColor($box, 'top'),
5932                    $width,
5933                );
5934                return;
5935            }
5936        }
5937
5938        if ($geo->borderTop > 0.0 && $this->borderIsVisible($box, 'top')) {
5939            $this->paintBorderSide(
5940                $stream,
5941                $this->borderStyleName($box, 'top'),
5942                $this->borderColor($box, 'top'),
5943                $outerX,
5944                $outerY,
5945                $outerWidth,
5946                $geo->borderTop,
5947                side: 'top',
5948            );
5949        }
5950        if ($geo->borderBottom > 0.0 && $this->borderIsVisible($box, 'bottom')) {
5951            $this->paintBorderSide(
5952                $stream,
5953                $this->borderStyleName($box, 'bottom'),
5954                $this->borderColor($box, 'bottom'),
5955                $outerX,
5956                $outerY + $outerHeight - $geo->borderBottom,
5957                $outerWidth,
5958                $geo->borderBottom,
5959                side: 'bottom',
5960            );
5961        }
5962        if ($geo->borderLeft > 0.0 && $this->borderIsVisible($box, 'left')) {
5963            $this->paintBorderSide(
5964                $stream,
5965                $this->borderStyleName($box, 'left'),
5966                $this->borderColor($box, 'left'),
5967                $outerX,
5968                $outerY,
5969                $geo->borderLeft,
5970                $outerHeight,
5971                side: 'left',
5972            );
5973        }
5974        if ($geo->borderRight > 0.0 && $this->borderIsVisible($box, 'right')) {
5975            $this->paintBorderSide(
5976                $stream,
5977                $this->borderStyleName($box, 'right'),
5978                $this->borderColor($box, 'right'),
5979                $outerX + $outerWidth - $geo->borderRight,
5980                $outerY,
5981                $geo->borderRight,
5982                $outerHeight,
5983                side: 'right',
5984            );
5985        }
5986    }
5987
5988    /**
5989     * Paint one border side honouring `border-style`. `axis` flags
5990     * whether the rect runs horizontally (top / bottom) or vertically
5991     * (left / right) so the `double` decomposition knows which
5992     * dimension to split into thirds.
5993     *
5994     *  - `solid`: one filled rect (the original behaviour).
5995     *  - `double` (CSS Backgrounds 3 §5): two parallel bands each
5996     *    `thickness/3` thick with a `thickness/3` gap. When the
5997     *    thickness is too small to split (< 3 units), falls back to
5998     *    solid so the border doesn't disappear into a hairline.
5999     *  - `dashed` / `dotted`: stroke a line at the centerline of the
6000     *    side with a PDF dash pattern. Dashed uses 3w-on / 2w-off;
6001     *    dotted uses 1w-on / 1w-off (PDF rounds dotted patterns to
6002     *    square caps).
6003     *  - Other style keywords (`groove`, `ridge`, `inset`, `outset`):
6004     *    Phase-1 fallback to solid.
6005     */
6006    private function paintBorderSide(
6007        ContentStream $stream,
6008        string $styleName,
6009        Color $color,
6010        float $x,
6011        float $y,
6012        float $width,
6013        float $height,
6014        string $side,
6015    ): void {
6016        $axis = ($side === 'top' || $side === 'bottom') ? 'horizontal' : 'vertical';
6017        // CSS Backgrounds 3 §5.2 — 3D-effect styles. `inset` darkens
6018        // top + left; `outset` lightens them; `groove` and `ridge`
6019        // split per side as if etched / raised.
6020        if (in_array($styleName, ['inset', 'outset', 'groove', 'ridge'], true)) {
6021            $color = $this->resolve3dBorderColor($styleName, $color, $side);
6022        }
6023        if ($styleName === 'dashed' || $styleName === 'dotted') {
6024            $thickness = $axis === 'horizontal' ? $height : $width;
6025            $this->paintDashedDottedSide(
6026                $stream,
6027                $styleName,
6028                $color,
6029                $x,
6030                $y,
6031                $width,
6032                $height,
6033                $axis,
6034                $thickness,
6035            );
6036            return;
6037        }
6038        if ($styleName === 'double') {
6039            $thickness = $axis === 'horizontal' ? $height : $width;
6040            if ($thickness >= 3.0) {
6041                $third = $thickness / 3.0;
6042                if ($axis === 'horizontal') {
6043                    // Two horizontal bands stacked vertically.
6044                    $this->emitRect($stream, $x, $y, $width, $third, fill: $color);
6045                    $this->emitRect($stream, $x, $y + 2 * $third, $width, $third, fill: $color);
6046                } else {
6047                    // Two vertical bands stacked horizontally.
6048                    $this->emitRect($stream, $x, $y, $third, $height, fill: $color);
6049                    $this->emitRect($stream, $x + 2 * $third, $y, $third, $height, fill: $color);
6050                }
6051                return;
6052            }
6053        }
6054        $this->emitRect($stream, $x, $y, $width, $height, fill: $color);
6055    }
6056
6057    /**
6058     * Stroke one side of a border as a dashed / dotted line at the
6059     * centerline of the side, at line-width = thickness. CSS
6060     * Backgrounds 3 §5 says the dash / dot geometry is
6061     * implementation-defined; we follow the Chromium + WebKit
6062     * convention:
6063     *   - dashed: dash-length = 2w, gap = w (period 3w, butt cap).
6064     *   - dotted: round-capped point-dashes spaced 2w apart, so each
6065     *     "dot" renders as a circle of diameter = thickness. The
6066     *     stroke is inset by half the thickness on each end so the
6067     *     leading dot sits at the centre-line crossing of the two
6068     *     meeting borders (matching the corner geometry browsers
6069     *     emit).
6070     * The period for both styles is rounded to fit the edge in whole
6071     * cycles so dashes / dots stay symmetric across the corner; this
6072     * is what closes the residual ~14% pixel-AE vs Chromium on the
6073     * dashed / dotted fixtures (issue #27).
6074     */
6075    private function paintDashedDottedSide(
6076        ContentStream $stream,
6077        string $styleName,
6078        Color $color,
6079        float $x,
6080        float $y,
6081        float $width,
6082        float $height,
6083        string $axis,
6084        float $thickness,
6085    ): void {
6086        if ($thickness <= 0.0) {
6087            return;
6088        }
6089        $edgeLength = $axis === 'horizontal' ? $width : $height;
6090        if ($edgeLength <= 0.0) {
6091            return;
6092        }
6093        $stream->saveGraphicsState();
6094        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
6095        $stream->setLineWidth($thickness);
6096
6097        if ($styleName === 'dotted') {
6098            // Round-capped point-dash → each "on" of length 0 paints a
6099            // full-thickness circle. Inset by thickness/2 so the first
6100            // and last dot land on the corner of the two borders'
6101            // centre-lines.
6102            $insetEdge = max($edgeLength - $thickness, 0.0);
6103            $targetPeriod = $thickness * 2.0;
6104            $count = max(1, (int) round($insetEdge / $targetPeriod));
6105            $period = $count > 0 ? $insetEdge / $count : $targetPeriod;
6106            $stream->setLineCap(1);
6107            $stream->setDashPattern([0.0, $period], 0);
6108            [$startX, $startY, $endX, $endY] = $this->dashedSideEndpoints(
6109                $axis,
6110                $x,
6111                $y,
6112                $width,
6113                $height,
6114                $thickness / 2.0,
6115            );
6116        } else {
6117            // Dashed — dash:gap = 2:1, period rounded to whole cycles.
6118            $targetPeriod = $thickness * 3.0;
6119            $count = max(1, (int) round($edgeLength / $targetPeriod));
6120            $period = $edgeLength / $count;
6121            $dash = $period * 2.0 / 3.0;
6122            $gap = $period - $dash;
6123            $stream->setDashPattern([$dash, $gap], 0);
6124            [$startX, $startY, $endX, $endY] = $this->dashedSideEndpoints(
6125                $axis,
6126                $x,
6127                $y,
6128                $width,
6129                $height,
6130                0.0,
6131            );
6132        }
6133        $stream->moveTo($startX, $startY);
6134        $stream->lineTo($endX, $endY);
6135        $stream->stroke();
6136        $stream->restoreGraphicsState();
6137    }
6138
6139    /**
6140     * Compute the PDF-space stroke endpoints for one dashed / dotted
6141     * border side, optionally inset by `$inset` units along the axis
6142     * (so the leading dot of a dotted edge sits at the corner of the
6143     * two borders' centre-lines rather than poking past it).
6144     *
6145     * @return array{float, float, float, float}
6146     */
6147    private function dashedSideEndpoints(
6148        string $axis,
6149        float $x,
6150        float $y,
6151        float $width,
6152        float $height,
6153        float $inset,
6154    ): array {
6155        if ($axis === 'horizontal') {
6156            $midPdfY = $this->pageHeight - ($y + $height / 2.0);
6157            return [$x + $inset, $midPdfY, $x + $width - $inset, $midPdfY];
6158        }
6159        $midX = $x + $width / 2.0;
6160        $topPdfY = $this->pageHeight - $y - $inset;
6161        $bottomPdfY = $this->pageHeight - ($y + $height) + $inset;
6162        return [$midX, $topPdfY, $midX, $bottomPdfY];
6163    }
6164
6165    /**
6166     * Resolve the per-side colour for CSS Backgrounds 3 §5.2 3D-style
6167     * borders. The light source is conventionally top-left:
6168     *
6169     *  - `inset`  → top + left use a darker variant (carved-in look).
6170     *  - `outset` → bottom + right use a darker variant (raised look).
6171     *  - `groove` → top + left darker, bottom + right lighter (etched in).
6172     *  - `ridge`  → top + left lighter, bottom + right darker (raised ridge).
6173     *
6174     * "Darker" multiplies each RGB channel by 0.5; "lighter" lightens
6175     * toward white by 30%. These match common browser approximations.
6176     */
6177    private function resolve3dBorderColor(string $styleName, Color $base, string $side): Color
6178    {
6179        $isTopLeft = $side === 'top' || $side === 'left';
6180        $darken = static function (Color $c): Color {
6181            return new Color($c->r * 0.5, $c->g * 0.5, $c->b * 0.5, $c->a, $c->space);
6182        };
6183        $lighten = static function (Color $c): Color {
6184            return new Color(
6185                $c->r + (1.0 - $c->r) * 0.3,
6186                $c->g + (1.0 - $c->g) * 0.3,
6187                $c->b + (1.0 - $c->b) * 0.3,
6188                $c->a,
6189                $c->space,
6190            );
6191        };
6192        return match ($styleName) {
6193            'inset' => $isTopLeft ? $darken($base) : $base,
6194            'outset' => $isTopLeft ? $base : $darken($base),
6195            'groove' => $isTopLeft ? $darken($base) : $lighten($base),
6196            'ridge' => $isTopLeft ? $lighten($base) : $darken($base),
6197            default => $base,
6198        };
6199    }
6200
6201    private function borderStyleName(Box $box, string $side): string
6202    {
6203        $value = $box->style->get("border-$side-style");
6204        if (!$value instanceof Keyword) {
6205            return 'none';
6206        }
6207        return strtolower($value->name);
6208    }
6209
6210    /**
6211     * Uniform borders: same width / colour / visible-style on all 4 sides.
6212     * Enables the rounded-stroke fast path; mixed borders fall back to
6213     * straight per-side rectangles.
6214     */
6215    private function bordersAreUniform(Box $box): bool
6216    {
6217        $g = $box->geometry;
6218        if (abs($g->borderTop - $g->borderRight) > 0.001
6219            || abs($g->borderTop - $g->borderBottom) > 0.001
6220            || abs($g->borderTop - $g->borderLeft) > 0.001
6221        ) {
6222            return false;
6223        }
6224        $colorTop = $this->borderColor($box, 'top');
6225        foreach (['right', 'bottom', 'left'] as $side) {
6226            if (!$this->borderIsVisible($box, $side)) {
6227                return false;
6228            }
6229            $c = $this->borderColor($box, $side);
6230            if ($c->r !== $colorTop->r || $c->g !== $colorTop->g || $c->b !== $colorTop->b) {
6231                return false;
6232            }
6233        }
6234        return $this->borderIsVisible($box, 'top');
6235    }
6236
6237    /**
6238     * Stroke a rounded-rectangle path. `x,topY,width,height` describe the
6239     * path's centreline (so the stroke straddles both inside and outside);
6240     * radii are clamped per spec. Used for uniform-border rendering when
6241     * border-radius is set.
6242     *
6243     * @param array{float, float, float, float} $radii
6244     */
6245    private function emitRoundedStroke(
6246        ContentStream $stream,
6247        float $x,
6248        float $topY,
6249        float $width,
6250        float $height,
6251        array $radii,
6252        Color $color,
6253        float $lineWidth,
6254    ): void {
6255        $maxR = min($width, $height) / 2.0;
6256        [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => max(0.0, min($r, $maxR)), $radii);
6257        $k = 0.5522847498;
6258        $bottomPdfY = $this->pageHeight - $topY - $height;
6259        $topPdfY = $this->pageHeight - $topY;
6260        $stream->saveGraphicsState();
6261        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
6262        $stream->setLineWidth($lineWidth);
6263        $stream->moveTo($x + $rtl, $topPdfY);
6264        $stream->lineTo($x + $width - $rtr, $topPdfY);
6265        if ($rtr > 0.0) {
6266            $stream->curveTo(
6267                $x + $width - $rtr + $rtr * $k,
6268                $topPdfY,
6269                $x + $width,
6270                $topPdfY - $rtr + $rtr * $k,
6271                $x + $width,
6272                $topPdfY - $rtr,
6273            );
6274        }
6275        $stream->lineTo($x + $width, $bottomPdfY + $rbr);
6276        if ($rbr > 0.0) {
6277            $stream->curveTo(
6278                $x + $width,
6279                $bottomPdfY + $rbr - $rbr * $k,
6280                $x + $width - $rbr + $rbr * $k,
6281                $bottomPdfY,
6282                $x + $width - $rbr,
6283                $bottomPdfY,
6284            );
6285        }
6286        $stream->lineTo($x + $rbl, $bottomPdfY);
6287        if ($rbl > 0.0) {
6288            $stream->curveTo(
6289                $x + $rbl - $rbl * $k,
6290                $bottomPdfY,
6291                $x,
6292                $bottomPdfY + $rbl - $rbl * $k,
6293                $x,
6294                $bottomPdfY + $rbl,
6295            );
6296        }
6297        $stream->lineTo($x, $topPdfY - $rtl);
6298        if ($rtl > 0.0) {
6299            $stream->curveTo(
6300                $x,
6301                $topPdfY - $rtl + $rtl * $k,
6302                $x + $rtl - $rtl * $k,
6303                $topPdfY,
6304                $x + $rtl,
6305                $topPdfY,
6306            );
6307        }
6308        $stream->closePath();
6309        $stream->stroke();
6310        $stream->restoreGraphicsState();
6311    }
6312
6313    /**
6314     * Paint CSS UI 3 §4 `outline`. Outlines don't take part in layout —
6315     * they're drawn just outside the border-box at `outline-offset`. We
6316     * only paint the visible outline-style values (everything except
6317     * `none` / `hidden`); `outline-width` and `outline-color` follow the
6318     * cascade.
6319     */
6320    private function paintOutline(Box $box, ContentStream $stream): void
6321    {
6322        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
6323            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
6324            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
6325        ) {
6326            return;
6327        }
6328        $style = $box->style->get('outline-style');
6329        if (!$style instanceof Keyword) {
6330            return;
6331        }
6332        $styleName = strtolower($style->name);
6333        if ($styleName === 'none' || $styleName === 'hidden') {
6334            return;
6335        }
6336        $widthValue = $box->style->get('outline-width');
6337        $width = match (true) {
6338            $widthValue instanceof \Phpdftk\Css\Value\Length => max(0.0, $widthValue->value),
6339            // CSS Backgrounds 3 §4.4 keyword resolution.
6340            $widthValue instanceof \Phpdftk\Css\Value\Keyword => match (strtolower($widthValue->name)) {
6341                'thin' => 1.0,
6342                'medium' => 3.0,
6343                'thick' => 5.0,
6344                default => 0.0,
6345            },
6346            default => 0.0,
6347        };
6348        if ($width <= 0.0) {
6349            return;
6350        }
6351        $offsetValue = $box->style->get('outline-offset');
6352        $offset = $offsetValue instanceof \Phpdftk\Css\Value\Length ? $offsetValue->value : 0.0;
6353        $colorValue = $box->style->get('outline-color');
6354        $color = $colorValue instanceof Color ? $colorValue : ($box->style->get('color') instanceof Color
6355            ? $box->style->get('color')
6356            : new Color(0, 0, 0, 1));
6357
6358        $geo = $box->geometry;
6359        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft - $offset - $width / 2;
6360        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop - $offset - $width / 2;
6361        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
6362            + $geo->borderLeft + $geo->borderRight + 2 * $offset + $width;
6363        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
6364            + $geo->borderTop + $geo->borderBottom + 2 * $offset + $width;
6365        $pdfY = $this->pageHeight - $outerY - $outerHeight;
6366        $stream->saveGraphicsState();
6367        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
6368        $stream->setLineWidth($width);
6369        // CSS Outline 3 §5 styles. `dashed` / `dotted` map onto PDF
6370        // line-dash patterns; `double` paints two concentric strokes
6371        // each `width/3` thick separated by a `width/3` gap; the rest
6372        // (`groove` / `ridge` / `inset` / `outset`) fall back to solid.
6373        if ($styleName === 'double' && $width >= 3.0) {
6374            $third = $width / 3.0;
6375            $stream->setLineWidth($third);
6376            // Outer ring: path centred between the outline's outer
6377            // edge and (outer edge + third). The stroke straddles the
6378            // path by ±third/2, so the outer face sits on the outline
6379            // outer edge.
6380            $stream->rectangle(
6381                $outerX + $third / 2,
6382                $pdfY + $third / 2,
6383                $outerWidth - $third,
6384                $outerHeight - $third,
6385            );
6386            $stream->stroke();
6387            // Inner ring: path centred two-thirds in from the outer.
6388            $stream->rectangle(
6389                $outerX + 2.5 * $third,
6390                $pdfY + 2.5 * $third,
6391                $outerWidth - 5 * $third,
6392                $outerHeight - 5 * $third,
6393            );
6394            $stream->stroke();
6395            $stream->restoreGraphicsState();
6396            return;
6397        }
6398        switch ($styleName) {
6399            case 'dashed':
6400                $stream->setDashPattern([$width * 3, $width * 2], 0);
6401                break;
6402            case 'dotted':
6403                $stream->setDashPattern([$width, $width * 1.5], 0);
6404                break;
6405                // 'groove' / 'ridge' / 'inset' / 'outset' fall back
6406                // to solid for Phase 1.
6407        }
6408        $stream->rectangle($outerX, $pdfY, $outerWidth, $outerHeight);
6409        $stream->stroke();
6410        $stream->restoreGraphicsState();
6411    }
6412
6413    /**
6414     * Stroke `column-rule` between adjacent columns inside a multi-column
6415     * container (CSS Multi-column 1 §3). Each rule is centred in its
6416     * column-gap, spans the container's content-area height, and honours
6417     * `column-rule-style` for `solid` / `dashed` / `dotted`. No-op when
6418     * the box isn't a multi-column container, the rule has zero width, or
6419     * the style is `none` / `hidden`.
6420     */
6421    private function paintColumnRules(Box $box, ContentStream $stream): void
6422    {
6423        $mc = $box->multiColumn;
6424        if ($mc === null || $mc->columnCount < 2) {
6425            return;
6426        }
6427        if ($mc->ruleWidth <= 0.0 || $mc->ruleColor === null) {
6428            return;
6429        }
6430        $styleName = $mc->ruleStyle;
6431        if ($styleName === 'none' || $styleName === 'hidden') {
6432            return;
6433        }
6434        $geo = $box->geometry;
6435        $top = $geo->y;
6436        $height = $geo->height;
6437        if ($height <= 0.0) {
6438            return;
6439        }
6440        $pdfTop = $this->pageHeight - $top;
6441        $pdfBottom = $this->pageHeight - ($top + $height);
6442        $stream->saveGraphicsState();
6443        $stream->setStrokeColorRGB($mc->ruleColor->r, $mc->ruleColor->g, $mc->ruleColor->b);
6444        $stream->setLineWidth($mc->ruleWidth);
6445        switch ($styleName) {
6446            case 'dashed':
6447                $stream->setDashPattern([$mc->ruleWidth * 3, $mc->ruleWidth * 2], 0);
6448                break;
6449            case 'dotted':
6450                $stream->setDashPattern([$mc->ruleWidth, $mc->ruleWidth * 1.5], 0);
6451                break;
6452                // Other styles (double / groove / ridge / inset / outset)
6453                // fall back to solid for Phase 1, mirroring the outline
6454                // painter's approximation.
6455        }
6456        for ($i = 0; $i < $mc->columnCount - 1; $i++) {
6457            // Centre line of the gap between column $i and $i+1.
6458            $gapCentreX = $geo->x
6459                + ($i + 1) * $mc->columnWidth
6460                + $i * $mc->columnGap
6461                + $mc->columnGap / 2.0;
6462            $stream->moveTo($gapCentreX, $pdfBottom);
6463            $stream->lineTo($gapCentreX, $pdfTop);
6464            $stream->stroke();
6465        }
6466        $stream->restoreGraphicsState();
6467    }
6468
6469    private function borderIsVisible(Box $box, string $side): bool
6470    {
6471        $style = $box->style->get("border-$side-style");
6472        if (!$style instanceof Keyword) {
6473            return false;
6474        }
6475        $lower = strtolower($style->name);
6476        if ($lower === 'none' || $lower === 'hidden') {
6477            return false;
6478        }
6479        // CSS Backgrounds 3 §4.4: a fully transparent border colour
6480        // contributes nothing visible — skipping the paint avoids drawing
6481        // a black bar where the alpha=0 value would otherwise resolve
6482        // through the DeviceRGB `rg` operator (which has no alpha).
6483        return $this->borderColor($box, $side)->a > 0.0;
6484    }
6485
6486    private function borderColor(Box $box, string $side): Color
6487    {
6488        $color = $box->style->get("border-$side-color");
6489        if ($color instanceof Color) {
6490            return $color;
6491        }
6492        // CSS Colors 4: border-color initial is currentColor, which means
6493        // the cascaded `color` property.
6494        $current = $box->style->get('color');
6495        if ($current instanceof Color) {
6496            return $current;
6497        }
6498        return new Color(0, 0, 0, 1);
6499    }
6500
6501    /**
6502     * Emit a rect in PDF coordinates (Y flipped from top-down layout space).
6503     * `topY` is the layout-space top edge; `height` is positive downward.
6504     */
6505    private function emitRect(
6506        ContentStream $stream,
6507        float $x,
6508        float $topY,
6509        float $width,
6510        float $height,
6511        Color $fill,
6512    ): void {
6513        $pdfY = $this->pageHeight - $topY - $height;
6514        $stream->saveGraphicsState();
6515        // CSS Color §10 — translucent fills (Color::a < 1) need to
6516        // composite over whatever's underneath; that's an ExtGState
6517        // dictionary in PDF with /ca for non-stroke and /CA for
6518        // stroke. Without it the painter would emit a fully-opaque
6519        // rect even when the cascaded color said e.g. `rgba(0,0,0,
6520        // 0.6)`, masking the background entirely. WPT t422-rgba-*
6521        // exercise this with checkerboards behind translucent bands.
6522        if ($fill->a < 0.999 && $this->page !== null) {
6523            $alphaName = $this->page->ensureOpacityState($fill->a, $fill->a);
6524            $stream->setGraphicsState($alphaName);
6525        }
6526        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
6527        $stream->rectangle($x, $pdfY, $width, $height);
6528        $stream->fill();
6529        $stream->restoreGraphicsState();
6530    }
6531
6532    /**
6533     * Read the box's four corner radii in pixel-equivalent units. CSS
6534     * Backgrounds 3 §6 requires each radius to be clamped to half the
6535     * shorter side; we do that here.
6536     *
6537     * @return array{float, float, float, float} [tl, tr, br, bl]
6538     */
6539    private function borderRadii(Box $box): array
6540    {
6541        $read = function (string $name) use ($box): float {
6542            $v = $box->style->get($name);
6543            return $v instanceof \Phpdftk\Css\Value\Length ? max(0.0, $v->value) : 0.0;
6544        };
6545        return [
6546            $read('border-top-left-radius'),
6547            $read('border-top-right-radius'),
6548            $read('border-bottom-right-radius'),
6549            $read('border-bottom-left-radius'),
6550        ];
6551    }
6552
6553    /**
6554     * Emit a rounded-rectangle fill path using cubic Béziers at the four
6555     * corners. Topology in layout-Y (top-down) with the painter's flip
6556     * applied at emission time. The 0.5522847498 constant is the standard
6557     * cubic-Bézier circle approximation factor.
6558     *
6559     * @param array{float, float, float, float} $radii [tl, tr, br, bl]
6560     */
6561    private function emitRoundedFill(
6562        ContentStream $stream,
6563        float $x,
6564        float $topY,
6565        float $width,
6566        float $height,
6567        array $radii,
6568        Color $fill,
6569    ): void {
6570        $maxR = min($width, $height) / 2.0;
6571        [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => min($r, $maxR), $radii);
6572        $k = 0.5522847498;
6573        // Flip to PDF coords for emission.
6574        $bottomPdfY = $this->pageHeight - $topY - $height;
6575        $topPdfY = $this->pageHeight - $topY;
6576        // Walk clockwise starting at the top-left straight edge.
6577        $stream->saveGraphicsState();
6578        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
6579        $stream->moveTo($x + $rtl, $topPdfY);
6580        $stream->lineTo($x + $width - $rtr, $topPdfY);
6581        if ($rtr > 0.0) {
6582            $stream->curveTo(
6583                $x + $width - $rtr + $rtr * $k,
6584                $topPdfY,
6585                $x + $width,
6586                $topPdfY - $rtr + $rtr * $k,
6587                $x + $width,
6588                $topPdfY - $rtr,
6589            );
6590        }
6591        $stream->lineTo($x + $width, $bottomPdfY + $rbr);
6592        if ($rbr > 0.0) {
6593            $stream->curveTo(
6594                $x + $width,
6595                $bottomPdfY + $rbr - $rbr * $k,
6596                $x + $width - $rbr + $rbr * $k,
6597                $bottomPdfY,
6598                $x + $width - $rbr,
6599                $bottomPdfY,
6600            );
6601        }
6602        $stream->lineTo($x + $rbl, $bottomPdfY);
6603        if ($rbl > 0.0) {
6604            $stream->curveTo(
6605                $x + $rbl - $rbl * $k,
6606                $bottomPdfY,
6607                $x,
6608                $bottomPdfY + $rbl - $rbl * $k,
6609                $x,
6610                $bottomPdfY + $rbl,
6611            );
6612        }
6613        $stream->lineTo($x, $topPdfY - $rtl);
6614        if ($rtl > 0.0) {
6615            $stream->curveTo(
6616                $x,
6617                $topPdfY - $rtl + $rtl * $k,
6618                $x + $rtl - $rtl * $k,
6619                $topPdfY,
6620                $x + $rtl,
6621                $topPdfY,
6622            );
6623        }
6624        $stream->closePath();
6625        $stream->fill();
6626        $stream->restoreGraphicsState();
6627    }
6628}