Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.46% covered (warning)
68.46%
3651 / 5333
22.62% covered (danger)
22.62%
57 / 252
CRAP
0.00% covered (danger)
0.00%
0 / 1
Painter
68.46% covered (warning)
68.46%
3651 / 5333
22.62% covered (danger)
22.62%
57 / 252
116363.73
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
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
19.67
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 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 / 25
0.00% covered (danger)
0.00%
0 / 1
110
 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
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
 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
 containImpliesPaintClip
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 containKeywordClips
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 paintOrderChildren
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
24
 isPositioned
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 isFloat
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 orderOf
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 zIndexOf
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 paintBox
96.30% covered (success)
96.30%
78 / 81
0.00% covered (danger)
0.00%
0 / 1
30
 paintMaskedGroup
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
6.14
 boxEntirelyOffPage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 applyBoxTransform
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 hasMotionPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 motionShape
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 motionRay
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 motionPathFunctions
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 motionPlacement
46.34% covered (danger)
46.34%
19 / 41
0.00% covered (danger)
0.00%
0 / 1
17.89
 pointAlongPolyline
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 motionShapePolyline
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
56
 shapeCentre
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 offsetPositionPoint
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 shapeRadius
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
240
 motionShapeRect
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 motionRectEdge
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 expandBoxSides
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 shapeCornerRadii
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
210
 expandCornerList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 ellipsePolyline
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 roundedRectPolyline
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
132
 svgPathPolyline
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
506
 cubicSamples
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 quadraticSamples
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 arcSamples
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
182
 pixelTranslate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 rayAngleDegrees
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 motionReferenceRect
53.57% covered (warning)
53.57%
15 / 28
0.00% covered (danger)
0.00%
0 / 1
7.50
 referenceBoxRadii
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 motionCoordBox
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
6.04
 rayOrigin
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
16.51
 rayLength
48.15% covered (danger)
48.15%
13 / 27
0.00% covered (danger)
0.00%
0 / 1
20.29
 borderBoxExtent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 raySidesLength
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
182
 resolveMotionDistance
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
12.41
 resolveMotionRotation
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
13.49
 transformOriginCss
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 motionAnchorCss
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
6.80
 resolvePositionComponent
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 composeTransformMatrix
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 individualTransformFunctions
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 transformPropertyComponents
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 individualTranslate
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
 individualScale
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
7.29
 individualRotate
8.33% covered (danger)
8.33%
3 / 36
0.00% covered (danger)
0.00%
0 / 1
188.31
 transformNumber
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 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
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 resolveTransformOriginOffsets
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
10.09
 resolveOriginComponent
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
10.98
 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
 paintImage
92.00% covered (success)
92.00%
69 / 75
0.00% covered (danger)
0.00%
0 / 1
28.40
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 hasOverflowClip
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 overflowClipMargin
57.14% covered (warning)
57.14%
16 / 28
0.00% covered (danger)
0.00%
0 / 1
20.52
 originalAxisClips
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
 paintTableColumnBackgrounds
3.70% covered (danger)
3.70%
2 / 54
0.00% covered (danger)
0.00%
0 / 1
495.37
 cellBorderBoxRect
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 collectTableCellsByColumn
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 emitOverflowClipPath
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
14
 resolveClipRect
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
10
 maskHidesElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildBoxImageMaskGsName
82.98% covered (warning)
82.98%
78 / 94
0.00% covered (danger)
0.00%
0 / 1
23.17
 buildBoxSvgMaskGsName
85.37% covered (warning)
85.37%
35 / 41
0.00% covered (danger)
0.00%
0 / 1
16.80
 resolveMaskOrigin
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveMaskClip
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 buildBoxGradientMaskGsName
91.30% covered (success)
91.30%
42 / 46
0.00% covered (danger)
0.00%
0 / 1
14.13
 linearGradientLine
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 applyClipPath
97.22% covered (success)
97.22%
70 / 72
0.00% covered (danger)
0.00%
0 / 1
19
 applyReferencedClipPath
78.79% covered (warning)
78.79%
26 / 33
0.00% covered (danger)
0.00%
0 / 1
9.77
 hostSvgDocumentFor
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
9.00
 clipTranslator
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clipReferenceBox
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
5
 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
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
14
 formatCounterMarker
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 listItemIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 paintInlineBackgrounds
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
15.03
 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
 decorationSkipSpaces
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 paintTextDecorations
89.29% covered (warning)
89.29%
50 / 56
0.00% covered (danger)
0.00%
0 / 1
25.77
 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
97.33% covered (success)
97.33%
73 / 75
0.00% covered (danger)
0.00%
0 / 1
17
 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
100.00% covered (success)
100.00%
129 / 129
100.00% covered (success)
100.00%
1 / 1
32
 extractBackgroundLayers
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 resolveBackgroundLayer
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 selectImageSetOption
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 isSupportedImageSetMimeType
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 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
 paintConicGradient
77.14% covered (warning)
77.14%
27 / 35
0.00% covered (danger)
0.00%
0 / 1
9.97
 paintRadialGradient
83.93% covered (warning)
83.93%
47 / 56
0.00% covered (danger)
0.00%
0 / 1
17.06
 fillGradientSolidStop
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 resolveGradientStops
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
18
 flattenGradientStops
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 interpolationSpaceNeedsResample
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 gradientSampleSteps
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
7.77
 gradientHasAlpha
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 alphaStopsFrom
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildGradientAlphaMaskState
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
5.08
 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
94.57% covered (success)
94.57%
87 / 92
0.00% covered (danger)
0.00%
0 / 1
14.03
 paintBackgroundImage
87.76% covered (warning)
87.76%
43 / 49
0.00% covered (danger)
0.00%
0 / 1
9.15
 drawImageTilesInto
93.75% covered (success)
93.75%
60 / 64
0.00% covered (danger)
0.00%
0 / 1
12.04
 repeatAxes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 repeatModes
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
27.42
 roundTileDim
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 tileOffsets
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
9.13
 resolveBackgroundPosition
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
19.34
 resolveEdgeOffsetPosition
62.22% covered (warning)
62.22%
28 / 45
0.00% covered (danger)
0.00%
0 / 1
62.45
 isHorizontalPositionKeyword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isVerticalPositionKeyword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 axisOffsetFromValue
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
12.17
 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
 isDefaultGradientSize
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
9.19
 paintTiledLinearGradient
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 gradientTileCells
88.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
11.18
 resolveGradientTileSize
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
11.82
 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
93.75% covered (success)
93.75%
150 / 160
0.00% covered (danger)
0.00%
0 / 1
40.39
 emitImageMiddleFill
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
11.04
 borderImageFillAxis
8.33% covered (danger)
8.33%
2 / 24
0.00% covered (danger)
0.00%
0 / 1
104.20
 borderImageSliceHasFill
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 emitImageSlice
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 emitImageEdge
70.59% covered (warning)
70.59%
24 / 34
0.00% covered (danger)
0.00%
0 / 1
22.51
 parseBorderImageSliceNumber
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
14.79
 resolveBorderImageSliceComponent
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 resolveBorderImageSliceSides
82.14% covered (warning)
82.14%
23 / 28
0.00% covered (danger)
0.00%
0 / 1
13.96
 resolveBorderImageWidth
82.50% covered (warning)
82.50%
33 / 40
0.00% covered (danger)
0.00%
0 / 1
13.91
 resolveBorderImageOutset
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
7.18
 parseBorderImageRepeatModes
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
10.92
 svgRenderer
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 documentFontBridge
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 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
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
18.92
 mathmlRendererFor
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 paintInlineMath
79.55% covered (warning)
79.55%
35 / 44
0.00% covered (danger)
0.00%
0 / 1
18.19
 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
86.96% covered (warning)
86.96%
40 / 46
0.00% covered (danger)
0.00%
0 / 1
21.98
 parseViewBox
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 parseSvgLength
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 paintBorders
80.88% covered (warning)
80.88%
55 / 68
0.00% covered (danger)
0.00%
0 / 1
14.18
 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
86.81% covered (warning)
86.81%
79 / 91
0.00% covered (danger)
0.00%
0 / 1
28.67
 paintColumnRules
81.82% covered (warning)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
11.73
 paintFragmentedColumns
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
132
 paintColumnRuns
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 paintSlicedColumnRun
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 paintGridGapRules
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
5.65
 paintFlexGapRules
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
5.65
 strokeGapRule
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
11.12
 ruleColor
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 borderIsVisible
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 borderColor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 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
 borderRadiiXY
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
3.24
 resolveRadiusComponent
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 radiiAnyPositive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 emitRoundedFill
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildRoundedRectPath
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
1
 emitCornerSegment
62.16% covered (warning)
62.16%
23 / 37
0.00% covered (danger)
0.00%
0 / 1
15.42
 cornerShapes
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
11.36
 cornerShapeName
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
18.74
 superellipseExponent
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
25.85
 reduceRadiiToBox
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 isCollapsedBorderCell
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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\ColorConverter;
10use Phpdftk\Css\Value\ColorSpace;
11use Phpdftk\Css\Value\HueInterpolation;
12use Phpdftk\Css\Value\Keyword;
13use Phpdftk\HtmlToPdf\Box\Box;
14use Phpdftk\HtmlToPdf\Layout\BoxGeometry;
15use Phpdftk\HtmlToPdf\Layout\InlineFragment;
16use Phpdftk\HtmlToPdf\Layout\LineBox;
17use Phpdftk\HtmlToPdf\Layout\MultiColumnLayout;
18use Phpdftk\Pdf\Core\Content\ContentStream;
19use Phpdftk\Pdf\Core\Font\RegisteredFont;
20use Phpdftk\Pdf\Writer\Font as WriterFont;
21use Phpdftk\Pdf\Writer\Page as WriterPage;
22use Phpdftk\Pdf\Writer\PdfWriter;
23use Phpdftk\ResourceLoader\Exception\FetchFailedException;
24use Phpdftk\ResourceLoader\Exception\SsrfBlockedException;
25use Phpdftk\ResourceLoader\ResourceLoader as HttpResourceLoader;
26
27/**
28 * Phase 1G â€” paints a laid-out box tree onto a {@see ContentStream}.
29 *
30 * The painter walks the box tree depth-first and emits PDF operators for
31 * each box's visual contributions: background colour (rect + fill), then
32 * border edges (four straight strokes one per side, honouring per-side
33 * widths and colours), then recurses into children. Text rendering uses
34 * the line-box / shaped-glyph data deposited by {@see InlineLayout};
35 * Phase 1G.1 ships background + border painting and leaves text as a
36 * follow-up that depends on `@font-face` integration (1M) â€” for now line
37 * boxes are walked but text painting is a no-op, so the painter exercises
38 * end-to-end without requiring a font registration.
39 *
40 * **Coordinate-system flip**: the layout uses PDF user-space units but
41 * with Y growing downward from the top of the page (the convention of CSS
42 * and every other layout engine). PDF's native content-stream coordinates
43 * grow upward from the bottom. The painter flips Y when emitting
44 * rectangles so consumers see PDF-correct output; the underlying box
45 * geometry stays in top-down space for layout sanity.
46 */
47final class Painter
48{
49    public function __construct(
50        private readonly float $pageHeight,
51        private readonly ?RegisteredFont $defaultFont = null,
52        private readonly ?WriterPage $page = null,
53        /**
54         * Layout-Y range this page covers. When set, the painter skips
55         * any box whose geometry sits entirely above or entirely below
56         * this range â€” a multi-page document no longer re-paints every
57         * box on every page, just the ones intersecting the current
58         * page slot.
59         */
60        private readonly ?float $pageRangeStart = null,
61        private readonly ?float $pageRangeEnd = null,
62        /**
63         * When set, the painter can register Image XObjects via the
64         * writer's `addImage` and emit `Do` for `<img>` elements whose
65         * `src` is a `data:image/{png,jpeg}` URL. When null, image
66         * painting is a no-op (the alt-text fallback still flows).
67         */
68        private readonly ?PdfWriter $writer = null,
69        /**
70         * Base directory for resolving relative `<img src>` paths against
71         * the filesystem. When null, only `data:` URLs paint.
72         */
73        private readonly ?string $baseDir = null,
74        /**
75         * Optional broader sandbox the resolved path must remain
76         * under. Defaults to `baseDir`. Set wider when relative
77         * URLs are expected to escape `baseDir` via `..` walks â€”
78         * e.g. WPT refs in `reference/` loading `../support/img.png`.
79         */
80        private readonly ?string $sandboxRoot = null,
81        /**
82         * Map of `postScriptName â†’ RegisteredFont` keyed by the font's
83         * raw PS name. Used to switch `Tf` per fragment when an inline
84         * subtree shaped against an alternate font from the `FontResolver`.
85         * Defaults to `[$defaultFont->postScriptName => $defaultFont]`
86         * when only the default is registered.
87         *
88         * @var array<string, RegisteredFont>
89         */
90        private readonly array $registeredFonts = [],
91        /**
92         * Map of `lowercase font-family â†’ OpenTypeData`. Parallel to
93         * `$registeredFonts` (which carries PDF-side handles); this
94         * carries the parsed-font side so inline foreign-content
95         * painters can hand the full font data to embedded
96         * renderers (e.g. paintInlineMath threads the math element's
97         * resolved font into MathmlRenderer so it picks up the
98         * font's MATH-table constants like FractionRuleThickness).
99         *
100         * Keyed by lowercase family name to match how Renderer
101         * builds its `$fontMap` from @font-face and
102         * RendererOptions::fontMap.
103         *
104         * @var array<string, \Phpdftk\FontParser\FontFaceData>
105         */
106        private readonly array $fontDataByFamily = [],
107        /**
108         * Page width in PDF user-space units. Used by per-axis
109         * `overflow-x` / `overflow-y` clipping to extend the clip
110         * rect across the unconstrained axis. Defaults to a value
111         * large enough that any reasonable page-width effectively
112         * disables horizontal clipping when only the Y axis clips.
113         */
114        private readonly float $pageWidth = 100000.0,
115        /**
116         * Optional `phpdftk/resource-loader` for `http(s)://`
117         * `<img src>`, `<picture><source>`, `<iframe src>` etc.
118         * hrefs. When null (the default â€” preserves existing
119         * behaviour byte-for-byte), network hrefs drop silently per
120         * the same SVG 2 Â§12.6 / image-loading no-image outcome
121         * pattern. When supplied, the loader runs (with its SSRF
122         * guard, redirect handling, body cap, and MIME sniffing)
123         * and the embedded bytes get materialised to a temp file
124         * the same way `data:` URLs do.
125         */
126        private readonly ?HttpResourceLoader $resourceLoader = null,
127        /**
128         * The document's CSS Fonts 4 matcher. Threaded in so embedded
129         * foreign content that does its own text painting - inline
130         * `<svg><text>`, SVG `background-image` - selects from the same
131         * face set as HTML text instead of falling back to its own
132         * standalone font mapping. Null leaves those painters on their
133         * built-in fallbacks.
134         */
135        private readonly ?\Phpdftk\HtmlToPdf\Layout\FontResolver $fontResolver = null,
136    ) {}
137
138    /**
139     * Track tempfile paths created for `data:` URL images so we can
140     * delete them when the Painter is destroyed.
141     *
142     * @var list<string>
143     */
144    private array $tempImagePaths = [];
145
146    /**
147     * Cache `data:` URL â†’ registered XObject resource name for the
148     * current page, so the same image used multiple times only spills
149     * + registers once.
150     *
151     * @var array<string, string>
152     */
153    private array $imageNameCache = [];
154
155    /**
156     * Cache `src` â†’ parsed SvgDocument (or `false` when parsing failed)
157     * so each unique SVG background-image is only read + parsed once.
158     *
159     * @var array<string, \Phpdftk\Svg\SvgDocument|false>
160     */
161    private array $svgDocumentCache = [];
162
163    /**
164     * Lazy-built SvgRenderer for SVG background-image painting. SVG
165     * resources (gradients, fonts) register on the page on first draw.
166     */
167    private ?\Phpdftk\SvgToPdf\SvgRenderer $svgRenderer = null;
168
169    /**
170     * Translator used to emit `clip-path: url(#id)` regions. Separate
171     * from {@see svgRenderer}'s translator â€” see {@see clipTranslator}.
172     */
173    private ?\Phpdftk\SvgToPdf\Translator $clipTranslator = null;
174
175    /**
176     * Lazy-built adapter that converts an inline-SVG HTML DOM subtree
177     * into a typed SvgDocument the renderer can paint. Caches its
178     * results by element identity so a multi-page document only pays
179     * the parse cost once per inline SVG.
180     */
181    private ?\Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter $inlineSvgAdapter = null;
182
183    /**
184     * Sibling of $inlineSvgAdapter for MathML. Both adapters share
185     * {@see \Phpdftk\HtmlToPdf\ForeignContent\DomXmlSerializer} but
186     * keep their own caches so a fixture with both inline SVG and
187     * inline MathML doesn't conflate them.
188     */
189    private ?\Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter $inlineMathmlAdapter = null;
190
191    /**
192     * Lazy-built MathML renderer. Same lifecycle as $svgRenderer â€”
193     * holds a reference to the writer + page once, registers the
194     * standard fonts on first draw.
195     */
196    private ?\Phpdftk\MathmlToPdf\MathmlRenderer $mathmlRenderer = null;
197
198    public function __destruct()
199    {
200        foreach ($this->tempImagePaths as $path) {
201            if (is_file($path)) {
202                @unlink($path);
203            }
204        }
205    }
206
207    /**
208     * Link rects in PDF coordinates collected during the most recent
209     * {@see paint()} call. Each entry is `{href, llx, lly, urx, ury,
210     * title}` with the Y-flip already applied. The Renderer reads this
211     * list to register `/Link` annotations on the current page.
212     *
213     * @var list<array{href: string, llx: float, lly: float, urx: float, ury: float, title: ?string}>
214     */
215    public array $collectedLinks = [];
216
217    /**
218     * Box whose background was propagated to the canvas this paint
219     * pass (CSS Backgrounds 3 Â§3.11.2) â€” either the root or, when the
220     * root's background is transparent and the root is an HTML
221     * document, the first body child. `paintBackground` skips this box
222     * to avoid double-painting at the box's geometry.
223     */
224    private ?Box $propagatedBgBox = null;
225
226    /**
227     * Body box whose `overflow` propagated to the root canvas this
228     * paint pass (CSS Overflow 3 Â§3.3). When the root's overflow is
229     * `visible` and the body's isn't, the body's value propagates to
230     * the root and the body's OWN overflow is treated as `visible`
231     * for paint purposes. `shouldOverflowClip` suppresses the body's
232     * descendant clip so the test/ref pair (which sets the
233     * post-propagation state on `html` directly) renders identically.
234     */
235    private ?Box $propagatedOverflowBox = null;
236
237    /**
238     * Root box that received the propagated overflow this paint pass.
239     * `axisClips` consults this to apply the body's overflow keyword
240     * to the root (instead of the root's own visible default) so the
241     * canvas gets the spec-mandated post-propagation behaviour.
242     */
243    private ?Box $propagatedOverflowRoot = null;
244
245    public function paint(Box $root, ContentStream $stream): void
246    {
247        $this->collectedLinks = [];
248        $this->imageNameCache = [];
249        // CSS Backgrounds 3 Â§3.11.2 â€” if the root has a non-transparent
250        // background, paint the entire canvas with it BEFORE walking the
251        // tree (so descendants paint on top). When the root is
252        // transparent but its body child carries a background, the body
253        // propagates to the canvas instead. The propagated box's own
254        // paint-background pass is suppressed by the propagatedBgBox check.
255        $this->paintCanvasBackgroundFromRoot($root, $stream);
256        // CSS Overflow 3 Â§3.3 â€” overflow propagation. When the root's
257        // overflow is `visible` and the body's isn't, the body's
258        // overflow value propagates to the root and the body's own
259        // overflow becomes `visible`. Both sides matter: we suppress
260        // the body's descendant clip AND apply the body's overflow
261        // keyword to the root so the root clips at its content area
262        // (which in our auto-height renderer wraps the body's outer
263        // box). `shouldOverflowClip` / `axisClips` consult the
264        // propagated-* tracking to redirect the per-axis clip.
265        $this->resolveOverflowPropagation($root);
266        $this->paintBox($root, $stream);
267        $this->propagatedBgBox = null;
268        $this->propagatedOverflowBox = null;
269        $this->propagatedOverflowRoot = null;
270    }
271
272    private function resolveOverflowPropagation(Box $root): void
273    {
274        if ($this->boxIsPaintContained($root)) {
275            return;
276        }
277        if (!$this->boxOverflowIsVisible($root)) {
278            // Root itself constrains â€” no propagation per spec; the
279            // root's overflow applies at the root and the body's
280            // overflow applies at the body normally.
281            return;
282        }
283        $body = $this->findBodyChild($root);
284        if ($body === null || $this->boxIsPaintContained($body)) {
285            return;
286        }
287        if (!$this->boxOverflowIsVisible($body)) {
288            $this->propagatedOverflowBox = $body;
289            $this->propagatedOverflowRoot = $root;
290        }
291    }
292
293    private function boxOverflowIsVisible(Box $box): bool
294    {
295        return !$this->axisClips($box, 'x') && !$this->axisClips($box, 'y');
296    }
297
298    private function paintCanvasBackgroundFromRoot(Box $root, ContentStream $stream): void
299    {
300        $source = $root;
301        // CSS Containment 3 Â§4.4 â€” `contain: paint` (or `contain:
302        // layout`, which implies paint containment in the propagation
303        // sense) on the root element creates a stacking + paint
304        // boundary, so neither the root's nor the body's background
305        // propagates to the canvas. Bail out before doing any canvas
306        // paint when the root is paint-contained.
307        if ($this->boxIsPaintContained($source)) {
308            return;
309        }
310        if (!$this->boxHasPaintableBackground($source)) {
311            // CSS Backgrounds 3 Â§3.11.2 second paragraph â€” when the root
312            // element of an HTML/XHTML document has a transparent
313            // background, the canvas uses the *first body child's*
314            // background instead, and the body itself paints
315            // transparent. Skipping the body lookup for non-HTML root
316            // elements is harmless: only HTML structure has a `<body>`.
317            $body = $this->findBodyChild($root);
318            if ($body === null
319                || !$this->boxHasPaintableBackground($body)
320                // CSS Containment 3 Â§4.4 â€” a paint-contained body does
321                // not propagate either. The body's bg paints at the
322                // body's own geometry, and the canvas stays at the
323                // initial value (transparent).
324                || $this->boxIsPaintContained($body)
325            ) {
326                return;
327            }
328            $source = $body;
329        }
330        $this->propagatedBgBox = $source;
331        // CSS Masking 1 Â§6 / Compositing 1 Â§5.2 â€” a `clip-path` on the
332        // ROOT element clips the root group, and the background the root
333        // propagates to the canvas is painted inside that group. Without
334        // this the element's own paint is clipped while the propagated
335        // background floods the whole page unclipped. The clip comes from
336        // the ROOT box even when the background itself was propagated up
337        // from the body: it is the root's group that carries it.
338        $canvasClipped = $this->applyClipPath($root, $stream);
339        $color = $this->resolveColorWithCurrentColor(
340            $source->style->get('background-color'),
341            $source,
342        );
343        $bgImage = $source->style->get('background-image');
344        $hasColor = $color instanceof Color && $color->a > 0.0;
345        $hasImage = $bgImage instanceof \Phpdftk\Css\Value\Url;
346        $hasGradient = $bgImage instanceof \Phpdftk\Css\Value\LinearGradient;
347        $hasRadial = $bgImage instanceof \Phpdftk\Css\Value\RadialGradient;
348        // The canvas rect is the entire page in PDF user-space â€” the
349        // painter's `pageHeight` is the top, `pageWidth` the right edge.
350        // Layout-Y 0 corresponds to the page top; emitRect handles the
351        // CSS-to-PDF Y flip internally.
352        if ($hasColor) {
353            $this->emitRect($stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight, fill: $color);
354        }
355        if ($hasImage) {
356            $sizeValue = $source->style->get('background-size');
357            $positionValue = $source->style->get('background-position');
358            $repeatValue = $source->style->get('background-repeat');
359            // CSS 2.1 Â§14.2 / Backgrounds 3 Â§3.11.2 â€” a propagated root
360            // background PAINTS over the whole canvas but is POSITIONED /
361            // tiled as if painted for the source element's own box (its
362            // padding box, the default `background-origin`). So the image
363            // anchors at the element's margin offset, not the page corner
364            // (e.g. `repeat-x top left` on an `html` with `margin: 1in`
365            // puts the stripe 1in down, not at y=0).
366            $this->paintBackgroundImage(
367                $bgImage,
368                $stream,
369                0.0,
370                0.0,
371                $this->pageWidth,
372                $this->pageHeight,
373                $sizeValue,
374                $positionValue,
375                $repeatValue,
376                $this->propagatedOriginRect($source),
377            );
378        }
379        if ($hasGradient) {
380            $this->paintLinearGradient($bgImage, $stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight);
381        }
382        if ($hasRadial) {
383            $this->paintRadialGradient($bgImage, $stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight);
384        }
385        if ($bgImage instanceof \Phpdftk\Css\Value\ConicGradient) {
386            $this->paintConicGradient($bgImage, $stream, 0.0, 0.0, $this->pageWidth, $this->pageHeight);
387        }
388        if ($canvasClipped) {
389            $stream->restoreGraphicsState();
390        }
391    }
392
393    /**
394     * The background positioning area for a propagated root/body
395     * background: the source element's padding box (the default
396     * `background-origin`), in layout-Y coordinates. `paintBackgroundImage`
397     * anchors `background-position` / tiling to this rect while painting
398     * over the whole canvas.
399     *
400     * @return array{x: float, top: float, width: float, height: float}
401     */
402    private function propagatedOriginRect(Box $source): array
403    {
404        $g = $source->geometry;
405        return [
406            'x' => $g->x - $g->paddingLeft,
407            'top' => $g->y - $g->paddingTop,
408            'width' => $g->paddingLeft + $g->width + $g->paddingRight,
409            'height' => $g->paddingTop + $g->height + $g->paddingBottom,
410        ];
411    }
412
413    private function boxHasPaintableBackground(Box $box): bool
414    {
415        $color = $this->resolveColorWithCurrentColor(
416            $box->style->get('background-color'),
417            $box,
418        );
419        $bgImage = $box->style->get('background-image');
420        if ($color instanceof Color && $color->a > 0.0) {
421            return true;
422        }
423        // Any recognised background-image layer (url, gradient, image(),
424        // or an image-set() wrapping one) makes the background paintable.
425        // Delegating to extractBackgroundLayers keeps this in lockstep with
426        // what the paint loop will actually draw.
427        return $this->extractBackgroundLayers($bgImage) !== [];
428    }
429
430    /**
431     * If `$value` is the `currentcolor` keyword, resolve it against
432     * the box's `color` property per CSS Color 3 Â§3.2 / CSS Color 4
433     * Â§3.6. Other values (already-typed `Color`, `null`, other
434     * keywords) pass through unchanged. The painter calls this at
435     * any property that documents `currentcolor` as a valid value
436     * (`background-color`, `border-*-color` initials, etc.).
437     */
438    private function resolveColorWithCurrentColor(?\Phpdftk\Css\Value\Value $value, Box $box): ?\Phpdftk\Css\Value\Value
439    {
440        // CSS Color 5 Â§5 â€” `light-dark(<light>, <dark>)` picks the
441        // arm matching the using element's `color-scheme`. Inspect the
442        // box's resolved `color-scheme` to decide: when the cascaded
443        // value lists `dark` (e.g. `color-scheme: dark` or `color-
444        // scheme: light dark` with dark first), pick the dark arm.
445        // Anything else (including the `normal` initial or
446        // `color-scheme: light`) falls back to the light arm â€” the
447        // spec's default.
448        if ($value instanceof \Phpdftk\Css\Value\LightDark) {
449            $value = $this->preferredLightDarkArm($box, $value);
450        }
451        if ($value instanceof \Phpdftk\Css\Value\Keyword
452            && strtolower($value->name) === 'currentcolor'
453        ) {
454            $current = $box->style->get('color');
455            if ($current instanceof \Phpdftk\Css\Value\LightDark) {
456                $current = $this->preferredLightDarkArm($box, $current);
457            }
458            $value = $current instanceof Color ? $current : null;
459        }
460        if ($value instanceof \Phpdftk\Css\Value\RelativeColor) {
461            $value = $this->resolveRelativeColor($value, $box);
462        }
463        if ($value instanceof Color && $value->space !== \Phpdftk\Css\Value\ColorSpace::sRGB) {
464            // CSS Color 4 Â§17 â€” every wide-gamut / polar / Lab-family
465            // value stores its native components on the Color struct
466            // (`color(display-p3 â€¦)` etc.). Convert to sRGB before the
467            // painter emits `rg` so PDF's DeviceRGB sees in-gamut
468            // values; out-of-gamut components clip at the boundary.
469            return \Phpdftk\Css\Value\ColorConverter::toSrgb($value);
470        }
471        return $value;
472    }
473
474    /**
475     * Pick the appropriate arm of a `light-dark()` expression based on
476     * the box's resolved `color-scheme`. CSS Color 5 Â§5 â€” the spec
477     * default is the light arm; `color-scheme: dark` (or a list whose
478     * first preferred scheme is dark) selects the dark arm.
479     */
480    private function preferredLightDarkArm(Box $box, \Phpdftk\Css\Value\LightDark $value): \Phpdftk\Css\Value\Value
481    {
482        $scheme = $box->style->get('color-scheme');
483        $isDark = false;
484        if ($scheme instanceof \Phpdftk\Css\Value\Keyword
485            && strtolower($scheme->name) === 'dark'
486        ) {
487            $isDark = true;
488        } elseif ($scheme instanceof \Phpdftk\Css\Value\ValueList) {
489            foreach ($scheme->values as $entry) {
490                if (!$entry instanceof \Phpdftk\Css\Value\Keyword) {
491                    continue;
492                }
493                $name = strtolower($entry->name);
494                if ($name === 'dark') {
495                    $isDark = true;
496                    break;
497                }
498                if ($name === 'light') {
499                    break;
500                }
501            }
502        }
503        return $isDark ? $value->dark : $value->light;
504    }
505
506    /**
507     * CSS Color 5 Â§4 relative-color resolution. Returns the resolved
508     * Color or null when the relative-color expression can't be
509     * statically evaluated.
510     *
511     * The common case the WPT relative-currentcolor cluster exercises
512     * is `<colorFn>(from currentColor c1 c2 c3)` where the components
513     * are the bare slot identifiers (`l a b`, `r g b`, â€¦) â€” that just
514     * round-trips the source through the target color space.
515     */
516    private function resolveRelativeColor(\Phpdftk\Css\Value\RelativeColor $rc, Box $box): ?Color
517    {
518        $source = $rc->source;
519        if ($source instanceof \Phpdftk\Css\Value\Keyword) {
520            $name = strtolower($source->name);
521            if ($name === 'transparent') {
522                $source = new Color(0.0, 0.0, 0.0, 0.0);
523            } elseif ($name === 'currentcolor') {
524                $current = $box->style->get('color');
525                if (!$current instanceof Color) {
526                    return null;
527                }
528                $source = $current;
529            } else {
530                return null;
531            }
532        }
533        // When every component is just its space's slot identifier
534        // (e.g. `lab(from X l a b)`), the relative-color expression
535        // is the identity round-trip â€” return the source unchanged
536        // and let the painter's sRGB toSrgb path do the final
537        // conversion.
538        $candidates = $this->relativeColorSlotIdents($rc->space);
539        foreach ($candidates as $slotIdents) {
540            if ($this->isIdentMatching($rc->component1, $slotIdents[0])
541                && $this->isIdentMatching($rc->component2, $slotIdents[1])
542                && $this->isIdentMatching($rc->component3, $slotIdents[2])
543                && $this->isAlphaSlotOrOne($rc->alpha)
544            ) {
545                return $source;
546            }
547        }
548        // Slot permutations â€” every component is a bare slot
549        // identifier from SOME valid slot trio, but the slots are
550        // shuffled (e.g. `rgb(from currentColor g r b)` from WPT
551        // relative-currentcolor-rgb-02). Map each component to the
552        // source's value for that slot and rebuild the target color.
553        foreach ($candidates as $slotIdents) {
554            $permuted = $this->resolveSlotPermutation($rc, $source, $slotIdents);
555            if ($permuted !== null) {
556                return $permuted;
557            }
558        }
559        // More general relative expressions (literal substitutions,
560        // calc() over slot identifiers, alpha overrides) aren't
561        // modelled yet â€” return null so the rule falls through to
562        // its initial.
563        return null;
564    }
565
566    /**
567     * Resolve a relative-color expression whose components are bare
568     * slot identifiers but possibly shuffled. Returns a new Color
569     * with the source's slot values rearranged, or null when any
570     * component isn't a recognised slot identifier from the given
571     * trio.
572     *
573     * @param array{0:string,1:string,2:string} $slotIdents
574     */
575    private function resolveSlotPermutation(
576        \Phpdftk\Css\Value\RelativeColor $rc,
577        Color $source,
578        array $slotIdents,
579    ): ?Color {
580        // Build slot-name â†’ source-channel-value lookup. For each
581        // syntactic slot trio we convert the sRGB-stored source into
582        // the matching color model so the slot identifiers refer to
583        // the right channels. The result is then converted back to
584        // the source's storage space.
585        if ($slotIdents === ['r', 'g', 'b']) {
586            $sourceChannels = ['r' => $source->r, 'g' => $source->g, 'b' => $source->b];
587            $rebuild = static fn(float $c1, float $c2, float $c3, float $a)
588                => new Color($c1, $c2, $c3, $a, $source->space);
589        } elseif ($slotIdents === ['h', 's', 'l']) {
590            // CSS Color 4 Â§6 â€” sRGB→HSL. The slots refer to source's
591            // HSL components; result rebuilds via hslToRgb. Hue is
592            // stored as degrees per CSS, saturation and lightness as
593            // 0–1 fractions.
594            [$h, $s, $l] = self::srgbToHsl($source->r, $source->g, $source->b);
595            $sourceChannels = ['h' => $h, 's' => $s, 'l' => $l];
596            $rebuild = static function (float $c1, float $c2, float $c3, float $a) use ($source): Color {
597                [$r, $g, $b] = self::hslToRgb($c1 / 360.0, $c2, $c3);
598                return new Color($r, $g, $b, $a, $source->space);
599            };
600        } elseif ($slotIdents === ['h', 'w', 'b']) {
601            [$h, $w, $blk] = self::srgbToHwb($source->r, $source->g, $source->b);
602            $sourceChannels = ['h' => $h, 'w' => $w, 'b' => $blk];
603            $rebuild = static function (float $c1, float $c2, float $c3, float $a) use ($source): Color {
604                [$r, $g, $b] = self::hwbToRgb($c1, $c2, $c3);
605                return new Color($r, $g, $b, $a, $source->space);
606            };
607        } else {
608            return null;
609        }
610        if (!$this->isAlphaSlotOrOne($rc->alpha)) {
611            return null;
612        }
613        $resolved = [];
614        foreach ([$rc->component1, $rc->component2, $rc->component3] as $i => $comp) {
615            // Component is EITHER a slot identifier (mapped above) OR
616            // a bare literal number â€” WPT relative-currentcolor-hsl-02
617            // uses `hsl(from currentColor 120 s l)` where the hue is
618            // a literal `120` (degrees).
619            if ($comp instanceof \Phpdftk\Css\Value\Keyword) {
620                $name = strtolower($comp->name);
621                if (!isset($sourceChannels[$name])) {
622                    return null;
623                }
624                $resolved[$i] = $sourceChannels[$name];
625                continue;
626            }
627            if ($comp instanceof \Phpdftk\Css\Value\Number) {
628                $resolved[$i] = $comp->value;
629                continue;
630            }
631            if ($comp instanceof \Phpdftk\Css\Value\Integer) {
632                $resolved[$i] = (float) $comp->value;
633                continue;
634            }
635            if ($comp instanceof \Phpdftk\Css\Value\Percentage) {
636                // hsl saturation / lightness as a percentage maps to
637                // [0, 1]; for hue or other components the spec keeps
638                // the percentage verbatim but no test we hit takes
639                // that path, so leave the simple 0..1 conversion.
640                $resolved[$i] = $comp->value / 100.0;
641                continue;
642            }
643            return null;
644        }
645        $alpha = $rc->alpha instanceof \Phpdftk\Css\Value\Number
646            ? $rc->alpha->value
647            : $source->a;
648        return $rebuild($resolved[0], $resolved[1], $resolved[2], $alpha);
649    }
650
651    /**
652     * CSS Color 4 Â§6 â€” sRGB â†’ HSL. Returns hue in degrees, saturation
653     * and lightness in [0, 1].
654     *
655     * @return array{0:float,1:float,2:float}
656     */
657    private static function srgbToHsl(float $r, float $g, float $b): array
658    {
659        $max = max($r, $g, $b);
660        $min = min($r, $g, $b);
661        $delta = $max - $min;
662        $l = ($max + $min) / 2.0;
663        if ($delta === 0.0) {
664            return [0.0, 0.0, $l];
665        }
666        $s = $l > 0.5 ? $delta / (2.0 - $max - $min) : $delta / ($max + $min);
667        if ($max === $r) {
668            $h = (($g - $b) / $delta) + ($g < $b ? 6.0 : 0.0);
669        } elseif ($max === $g) {
670            $h = (($b - $r) / $delta) + 2.0;
671        } else {
672            $h = (($r - $g) / $delta) + 4.0;
673        }
674        return [$h * 60.0, $s, $l];
675    }
676
677    /**
678     * CSS Color 4 Â§8 â€” sRGB â†’ HWB. Returns hue in degrees, whiteness
679     * and blackness in [0, 1].
680     *
681     * @return array{0:float,1:float,2:float}
682     */
683    private static function srgbToHwb(float $r, float $g, float $b): array
684    {
685        $max = max($r, $g, $b);
686        $min = min($r, $g, $b);
687        $h = self::srgbToHsl($r, $g, $b)[0];
688        return [$h, $min, 1.0 - $max];
689    }
690
691    /**
692     * CSS Color 4 Â§6 â€” HSL â†’ sRGB, with hue in [0, 1) (fraction of a
693     * full turn), saturation and lightness in [0, 1].
694     *
695     * @return array{0:float,1:float,2:float}
696     */
697    private static function hslToRgb(float $h, float $s, float $l): array
698    {
699        if ($s === 0.0) {
700            return [$l, $l, $l];
701        }
702        $q = $l < 0.5 ? $l * (1.0 + $s) : $l + $s - $l * $s;
703        $p = 2.0 * $l - $q;
704        $toRgb = static function (float $t) use ($p, $q): float {
705            if ($t < 0.0) {
706                $t += 1.0;
707            }
708            if ($t > 1.0) {
709                $t -= 1.0;
710            }
711            if ($t < 1.0 / 6.0) {
712                return $p + ($q - $p) * 6.0 * $t;
713            }
714            if ($t < 0.5) {
715                return $q;
716            }
717            if ($t < 2.0 / 3.0) {
718                return $p + ($q - $p) * (2.0 / 3.0 - $t) * 6.0;
719            }
720            return $p;
721        };
722        return [
723            $toRgb($h + 1.0 / 3.0),
724            $toRgb($h),
725            $toRgb($h - 1.0 / 3.0),
726        ];
727    }
728
729    /**
730     * CSS Color 4 Â§8 â€” HWB â†’ sRGB. Hue in degrees, whiteness and
731     * blackness in [0, 1].
732     *
733     * @return array{0:float,1:float,2:float}
734     */
735    private static function hwbToRgb(float $h, float $w, float $b): array
736    {
737        if ($w + $b >= 1.0) {
738            $gray = $w / ($w + $b);
739            return [$gray, $gray, $gray];
740        }
741        [$rh, $gh, $bh] = self::hslToRgb($h / 360.0, 1.0, 0.5);
742        return [
743            $rh * (1.0 - $w - $b) + $w,
744            $gh * (1.0 - $w - $b) + $w,
745            $bh * (1.0 - $w - $b) + $w,
746        ];
747    }
748
749    /**
750     * Slot identifier name CANDIDATES for a relative-color
751     * expression's target space. The CSS Color 5 parser maps the
752     * function name to a storage space (`hsl(from â€¦)` and `hwb(from
753     * â€¦)` both land at sRGB), so we accept any slot triple that's
754     * syntactically valid for THE SYNTAX a sRGB-stored relative
755     * color could have been written with.
756     *
757     * Returns an empty array when the space isn't a named family
758     * we model.
759     *
760     * @return list<array{0:string,1:string,2:string}>
761     */
762    private function relativeColorSlotIdents(\Phpdftk\Css\Value\ColorSpace $space): array
763    {
764        return match ($space) {
765            // The sRGB-family spaces accept rgb/hsl/hwb syntax depending
766            // on the relative-color function name; all three are valid
767            // here because we collapsed them onto a single storage space.
768            \Phpdftk\Css\Value\ColorSpace::sRGB,
769            \Phpdftk\Css\Value\ColorSpace::sRGBLinear => [
770                ['r', 'g', 'b'],
771                ['h', 's', 'l'],
772                ['h', 'w', 'b'],
773            ],
774            \Phpdftk\Css\Value\ColorSpace::DisplayP3,
775            \Phpdftk\Css\Value\ColorSpace::DisplayP3Linear,
776            \Phpdftk\Css\Value\ColorSpace::A98RGB,
777            \Phpdftk\Css\Value\ColorSpace::A98RGBLinear,
778            \Phpdftk\Css\Value\ColorSpace::ProPhotoRGB,
779            \Phpdftk\Css\Value\ColorSpace::ProPhotoRGBLinear,
780            \Phpdftk\Css\Value\ColorSpace::Rec2020,
781            \Phpdftk\Css\Value\ColorSpace::Rec2020Linear
782                => [['r', 'g', 'b']],
783            \Phpdftk\Css\Value\ColorSpace::HWB => [['h', 'w', 'b']],
784            \Phpdftk\Css\Value\ColorSpace::HSL => [['h', 's', 'l']],
785            \Phpdftk\Css\Value\ColorSpace::Lab => [['l', 'a', 'b']],
786            \Phpdftk\Css\Value\ColorSpace::Lch => [['l', 'c', 'h']],
787            \Phpdftk\Css\Value\ColorSpace::OKLab => [['l', 'a', 'b']],
788            \Phpdftk\Css\Value\ColorSpace::OKLCH => [['l', 'c', 'h']],
789            \Phpdftk\Css\Value\ColorSpace::XYZ,
790            \Phpdftk\Css\Value\ColorSpace::XYZD65,
791            \Phpdftk\Css\Value\ColorSpace::XYZD50 => [['x', 'y', 'z']],
792        };
793    }
794
795    private function isIdentMatching(\Phpdftk\Css\Value\Value $value, string $name): bool
796    {
797        return $value instanceof \Phpdftk\Css\Value\Keyword
798            && strtolower($value->name) === $name;
799    }
800
801    private function isAlphaSlotOrOne(\Phpdftk\Css\Value\Value $value): bool
802    {
803        if ($value instanceof \Phpdftk\Css\Value\Number) {
804            return abs($value->value - 1.0) < 1e-9;
805        }
806        return $this->isIdentMatching($value, 'alpha');
807    }
808
809    private function findBodyChild(Box $root): ?Box
810    {
811        foreach ($root->children as $child) {
812            if ($child->element !== null && strtolower($child->element->localName) === 'body') {
813                // CSS Backgrounds 3 Â§3.11.2 â€” the special body→canvas
814                // background propagation applies to the body's PRINCIPAL
815                // box. A `display: contents` body generates no principal
816                // box (only its children are hoisted), so it cannot
817                // propagate; the hoisted text/child boxes still carry the
818                // body element + its cascaded background, so match them
819                // here would wrongly flood the canvas. Skip it.
820                $disp = $child->style->get('display');
821                if ($disp instanceof Keyword && strtolower($disp->name) === 'contents') {
822                    continue;
823                }
824                return $child;
825            }
826        }
827        return null;
828    }
829
830    /**
831     * Return true when the box's `contain` property includes a value
832     * that creates a paint-containment boundary â€” either `paint` or
833     * `strict` or `content` (which include paint by definition), or
834     * the multi-keyword shorthand listing one of those terms. CSS
835     * Containment 3 Â§2.4 / Â§4.4. Per CSS Backgrounds 3 Â§3.11.2, when
836     * the root or body is paint-contained the body→canvas background
837     * propagation is suppressed.
838     */
839    private function boxIsPaintContained(Box $box): bool
840    {
841        $contain = $box->style->get('contain');
842        if ($contain instanceof \Phpdftk\Css\Value\Keyword) {
843            return $this->containKeywordImpliesPaint($contain->name);
844        }
845        if ($contain instanceof \Phpdftk\Css\Value\ValueList) {
846            foreach ($contain->values as $v) {
847                if ($v instanceof \Phpdftk\Css\Value\Keyword
848                    && $this->containKeywordImpliesPaint($v->name)
849                ) {
850                    return true;
851                }
852            }
853        }
854        return false;
855    }
856
857    private function containKeywordImpliesPaint(string $keyword): bool
858    {
859        // CSS Containment 3 â€” `paint`, `layout`, `size`, AND `style`
860        // each block ancestor propagation of properties like
861        // `background` and `overflow` to the root (§4.1 for layout;
862        // Â§3.4 for style explicitly lists the body→canvas
863        // background propagation as one of the things style
864        // containment blocks; size containment makes the element
865        // fully independent of its contents which has the same
866        // propagation-blocking effect for `<html>` / `<body>`
867        // backgrounds â€” covered by the `contain-{html,body}-bg-003/4`
868        // WPT fixtures). `strict` = layout+paint+style+size and
869        // `content` = layout+paint+style both include several of
870        // these so they get the same treatment.
871        return $keyword === 'paint'
872            || $keyword === 'layout'
873            || $keyword === 'size'
874            || $keyword === 'style'
875            || $keyword === 'strict'
876            || $keyword === 'content';
877    }
878
879    /**
880     * CSS Contain Â§2.3 â€” does `contain` force PAINT containment, which
881     * clips descendants to the box's overflow-clip-edge (the padding
882     * box) on BOTH axes, overriding an explicit `overflow: visible`?
883     * Only `paint`, `content` (= layout+paint+style), and `strict`
884     * (= layout+paint+style+size) include paint containment.
885     *
886     * Deliberately NARROWER than {@see containKeywordImpliesPaint},
887     * whose keyword test also returns true for `layout` / `size` /
888     * `style` (for the background-propagation use case). Clipping a
889     * `contain: layout` box would be a spec error, so this predicate
890     * excludes those keywords.
891     */
892    private function containImpliesPaintClip(Box $box): bool
893    {
894        $contain = $box->style->get('contain');
895        if ($contain instanceof \Phpdftk\Css\Value\Keyword) {
896            return $this->containKeywordClips($contain->name);
897        }
898        if ($contain instanceof \Phpdftk\Css\Value\ValueList) {
899            foreach ($contain->values as $v) {
900                if ($v instanceof \Phpdftk\Css\Value\Keyword
901                    && $this->containKeywordClips($v->name)
902                ) {
903                    return true;
904                }
905            }
906        }
907        return false;
908    }
909
910    private function containKeywordClips(string $keyword): bool
911    {
912        $k = strtolower($keyword);
913        return $k === 'paint' || $k === 'strict' || $k === 'content';
914    }
915
916    /**
917     * Paint order for a box's children, following the CSS 2.1 Â§9.9.1 /
918     * Appendix E stacking order for the common flat case:
919     *
920     *   - Positioned children (`position` â‰  `static`) with a negative
921     *     `z-index` paint first (most-negative first) â€” behind the
922     *     box's non-positioned in-flow descendants.
923     *   - Everything with `z-index: auto` / `0` (positioned or not) keeps
924     *     document order.
925     *   - Positioned children with a positive `z-index` paint last.
926     *
927     * Grid / flex items also take `z-index` even when not positioned
928     * (CSS Grid 1 Â§4.4, Flexbox 1 Â§5.4), so their children are bucketed
929     * by z-index regardless of `position`.
930     *
931     * The sort is stable (document-order tiebreak) and is skipped
932     * entirely â€” returning the children array untouched â€” whenever no
933     * child carries a non-zero effective z-index, so the overwhelming
934     * majority of boxes emit byte-identical paint output. This does not
935     * model nested stacking contexts (a positive-z grandchild of a
936     * negative-z child), which needs a full context tree; the flat
937     * reorder covers the ubiquitous "z-index: -1 backdrop" pattern.
938     *
939     * @return list<Box>
940     */
941    private function paintOrderChildren(Box $box): array
942    {
943        $children = $box->children;
944        if (count($children) < 2) {
945            return $children;
946        }
947        $display = $box->style->get('display');
948        $isGridOrFlex = $display instanceof Keyword
949            && (str_contains(strtolower($display->name), 'grid')
950                || str_contains(strtolower($display->name), 'flex'));
951        $indexed = [];
952        $anyReorder = false;
953        foreach ($children as $i => $child) {
954            // `z-index` takes effect on positioned boxes and on grid/flex
955            // items; for any other child it is ignored (layer 0) so the
956            // child stays wherever document order put it.
957            $z = ($isGridOrFlex || $this->isPositioned($child))
958                ? $this->zIndexOf($child)
959                : 0;
960            // CSS Grid 1 Â§4.2 / Flexbox 1 Â§5.4 â€” grid / flex items paint in
961            // "order-modified document order" within each z-index bucket:
962            // the `order` property reorders paint (a higher `order` paints
963            // later / on top), not just layout. It only applies to grid /
964            // flex items; its initial value is 0, so a non-zero `order`
965            // triggers the reorder just as a non-zero `z-index` does.
966            $order = $isGridOrFlex ? $this->orderOf($child) : 0;
967            // CSS 2.1 Appendix E â€” within one z-index bucket the paint
968            // sub-order is: in-flow non-positioned block-level boxes
969            // (step 3), then non-positioned floats (step 4), then in-flow
970            // non-positioned inline-level boxes (step 5). Document order
971            // alone paints a float behind any later in-flow block that
972            // overlaps it (a left float under a following full-page block);
973            // lifting floats to sub-layer 1 fixes that, while inline-level
974            // siblings go to sub-layer 2 so they still paint over the float.
975            // Positioned boxes keep their existing ordering (their z-index
976            // bucket already governs them).
977            $layer = 0;
978            if (!$this->isPositioned($child)) {
979                if ($this->isFloat($child)) {
980                    $layer = 1;
981                } elseif ($child instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
982                    || $child instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox
983                    || $child instanceof \Phpdftk\HtmlToPdf\Box\TextBox
984                    || $child instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
985                    // An anonymous wrapper around a run of inline content IS
986                    // that inline content for painting purposes (Appendix E
987                    // steps 5/7), so it belongs above floats. A float among
988                    // inline siblings makes `mixesBlockAndInline` true and
989                    // wraps the whole run, which then sat in sub-layer 0 and
990                    // let the float paint over every line box in it.
991                    //
992                    // `element === null` is the discriminator: the OTHER
993                    // AnonymousBlockBox site â€” the block-in-inline promotion
994                    // in BoxGenerator â€” passes the real element, and that
995                    // wrapper genuinely is block-level.
996                    || ($child instanceof \Phpdftk\HtmlToPdf\Box\AnonymousBlockBox
997                        && $child->element === null)
998                ) {
999                    $layer = 2;
1000                }
1001            } else {
1002                // Appendix E step 8 â€” a POSITIONED descendant with `z-index:
1003                // auto` or `0` paints above ALL in-flow content of the same
1004                // stacking context (steps 3-7), whatever document order says.
1005                // Leaving it in sub-layer 0 let a following in-flow sibling
1006                // cover it: the ubiquitous "absolutely positioned green
1007                // overlay declared before the red block" pattern rendered
1008                // entirely red. A non-zero z-index is already decisive on its
1009                // own, so this only settles the auto / 0 case.
1010                $layer = 3;
1011            }
1012            // Only a float (sub-layer 1) genuinely reorders against
1013            // document order; block (0) and inline (2) never coexist as
1014            // direct siblings (mixed content is anonymous-block-wrapped),
1015            // so those alone must not trip the reorder and disturb the
1016            // byte-identical fast path.
1017            if ($z !== 0 || $order !== 0 || $layer === 1 || $layer === 3) {
1018                $anyReorder = true;
1019            }
1020            $indexed[] = [$i, $z, $layer, $order, $child];
1021        }
1022        if (!$anyReorder) {
1023            return $children;
1024        }
1025        usort(
1026            $indexed,
1027            static fn(array $a, array $b): int => ($a[1] <=> $b[1])
1028                ?: ($a[2] <=> $b[2])
1029                ?: ($a[3] <=> $b[3])
1030                ?: ($a[0] <=> $b[0]),
1031        );
1032        return array_map(static fn(array $e): Box => $e[4], $indexed);
1033    }
1034
1035    /**
1036     * Whether the box is CSS-positioned (`relative` / `absolute` /
1037     * `fixed` / `sticky`) â€” the precondition for `z-index` to take
1038     * effect outside grid / flex layout (CSS 2.1 Â§9.9.1).
1039     */
1040    private function isPositioned(Box $box): bool
1041    {
1042        $p = $box->style->get('position');
1043        if (!$p instanceof Keyword) {
1044            return false;
1045        }
1046        return match (strtolower($p->name)) {
1047            'relative', 'absolute', 'fixed', 'sticky' => true,
1048            default => false,
1049        };
1050    }
1051
1052    /**
1053     * Whether the box is a CSS float (`float: left | right` and the
1054     * flow-relative aliases). Drives the Appendix E paint sub-layer so a
1055     * float paints over in-flow block siblings.
1056     */
1057    private function isFloat(Box $box): bool
1058    {
1059        $f = $box->style->get('float');
1060        if (!$f instanceof Keyword) {
1061            return false;
1062        }
1063        return match (strtolower($f->name)) {
1064            'left', 'right', 'inline-start', 'inline-end' => true,
1065            default => false,
1066        };
1067    }
1068
1069    /** Integer `order` (grid / flex item), or 0 for missing / non-numeric. */
1070    private function orderOf(Box $box): int
1071    {
1072        $o = $box->style->get('order');
1073        if ($o instanceof \Phpdftk\Css\Value\Integer) {
1074            return $o->value;
1075        }
1076        if ($o instanceof \Phpdftk\Css\Value\Number) {
1077            return (int) $o->value;
1078        }
1079        return 0;
1080    }
1081
1082    /** Integer `z-index`, or 0 for `auto` / missing / non-integer. */
1083    private function zIndexOf(Box $box): int
1084    {
1085        $z = $box->style->get('z-index');
1086        if ($z instanceof \Phpdftk\Css\Value\Integer) {
1087            return $z->value;
1088        }
1089        if ($z instanceof \Phpdftk\Css\Value\Number) {
1090            return (int) $z->value;
1091        }
1092        // CSS Values 4 Â§10.11 â€” `calc()` in an <integer> context rounds the
1093        // result to the nearest integer (halves toward +∞): `z-index: calc(3/2)`
1094        // is 2, not dropped to 0. Depending on the cascade path the calc arrives
1095        // either unevaluated (Calc) or already reduced to a numeric Length.
1096        if ($z instanceof \Phpdftk\Css\Value\Calc) {
1097            $v = \Phpdftk\Css\Cascade\CalcEvaluator::evaluate($z, new \Phpdftk\Css\Cascade\LengthContext());
1098            return (int) floor($v + 0.5);
1099        }
1100        if ($z instanceof \Phpdftk\Css\Value\Length) {
1101            return (int) floor($z->value + 0.5);
1102        }
1103        return 0;
1104    }
1105
1106    private function paintBox(
1107        Box $box,
1108        ContentStream $stream,
1109        ?Box $parent = null,
1110        bool $insideMaskedGroup = false,
1111    ): void {
1112        // CSS Overflow 4 Â§6 â€” content after a `line-clamp` container's
1113        // clamp point is discarded: neither the box nor anything in its
1114        // subtree (including out-of-flow descendants) is painted.
1115        if ($box->hiddenByLineClamp) {
1116            return;
1117        }
1118        // CSS Anchor Positioning 1 Â§10 â€” a `position-visibility` box that
1119        // overflows, or whose anchor is invisible / invalid, is *strongly
1120        // hidden*: neither it nor anything in its subtree paints, however
1121        // that subtree declares its own `visibility`.
1122        if ($box->hiddenByPositionVisibility) {
1123            return;
1124        }
1125        // Off-page skip: the box's layout-Y range doesn't overlap this
1126        // page's range. We still must descend into children for the
1127        // `<a href>` link-rect collection (which uses the page constant
1128        // to compute PDF-Y), but skip the heavy paint operations.
1129        if ($this->boxEntirelyOffPage($box) && !$this->hasMotionPath($box)) {
1130            return;
1131        }
1132        // CSS Masking 1 Â§4 â€” a `mask-image` masks the box (and its subtree)
1133        // by the mask's alpha/luminance. Install a soft mask over the whole
1134        // box paint (ISO 32000-2 Â§11.6.5.2). A `url(<image>)` mask builds an
1135        // Alpha/Luminosity soft mask from the resolved image; a
1136        // `<linear-gradient>` mask builds one from the gradient's alpha.
1137        $maskGsName = $insideMaskedGroup
1138            ? null
1139            : ($this->buildBoxSvgMaskGsName($box)
1140                ?? $this->buildBoxImageMaskGsName($box, $stream)
1141                ?? $this->buildBoxGradientMaskGsName($box, $stream));
1142        // CSS Masking 1 Â§4.1 â€” a `mask-image` layer that cannot be resolved
1143        // to a mask image is empty (transparent black), masking the element
1144        // AND its subtree fully out. When we could NOT build a soft mask for
1145        // a single `url(...)` layer (unresolvable source, or a mask box model
1146        // we don't model yet), fall back to that "definitively failed" blank.
1147        if ($maskGsName === null && !$insideMaskedGroup && $this->maskHidesElement($box)) {
1148            return;
1149        }
1150        if ($maskGsName !== null) {
1151            $this->paintMaskedGroup($box, $stream, $parent, $maskGsName);
1152            return;
1153        }
1154        $opacityGsName = $this->resolveOpacityGsName($box);
1155        if ($opacityGsName !== null) {
1156            $stream->saveGraphicsState();
1157            $stream->setGraphicsState($opacityGsName);
1158        }
1159        // CSS Transforms 2 Â§6: apply the box's transform (if any)
1160        // before any drawing. The graphics state save/restore wraps
1161        // the entire paint (background + content + children) so the
1162        // transform affects every nested operation.
1163        $hasTransform = $this->applyBoxTransform($box, $stream, $parent);
1164        // CSS Transforms 2 Â§15 â€” `backface-visibility: hidden`
1165        // suppresses paint when the cumulative 3D rotation around
1166        // the X / Y axis flips the box past 90° (cos(θ) < 0). The
1167        // 2D-projected matrix already has the cos-flatten baked in,
1168        // so we approximate by checking whether ANY X/Y rotation in
1169        // the transform list exceeds 90°.
1170        $hidden = $this->isVisibilityHidden($box)
1171            || $this->isBackfaceHidden($box);
1172        // CSS 2.1 Â§11.1.2 `clip` â€” clip an abspos element (its own paint AND
1173        // descendants) to `rect(top, right, bottom, left)` of its border box.
1174        // Pushed before any drawing so the rect can crop the box itself, and
1175        // popped after the children loop.
1176        $clipRect = $this->resolveClipRect($box);
1177        if ($clipRect !== null) {
1178            $stream->saveGraphicsState();
1179            $clipPdfY = $this->pageHeight - $clipRect['y'] - $clipRect['h'];
1180            $stream->rectangle($clipRect['x'], $clipPdfY, $clipRect['w'], $clipRect['h']);
1181            $stream->clip();
1182            $stream->endPath();
1183        }
1184        // CSS Masking 1 Â§6 `clip-path: <basic-shape>` â€” clip to a shape
1185        // resolved against the border box, wrapping the box + descendants.
1186        $clipPathApplied = $this->applyClipPath($box, $stream);
1187        if (!$hidden) {
1188            // CSS Fragmentation 4 Â§5.5: `box-decoration-break: clone`
1189            // makes each fragment paint full decorations as if it were
1190            // a standalone box. For a straddling box we temporarily
1191            // swap in a geometry clamped to this page's visible
1192            // extent, so background/border/shadow draw at the page
1193            // seam as a synthetic edge.
1194            $originalGeo = null;
1195            if ($this->shouldClampDecorationsToPage($box)) {
1196                $originalGeo = $box->geometry;
1197                $box->geometry = $this->clampGeometryToPage($originalGeo);
1198            }
1199            // Filter Effects 1 Â§16.1 â€” `filter: drop-shadow(...)`
1200            // paints an offset rect behind the box (below the
1201            // background, like an outset box-shadow). Other filter
1202            // primitives (`blur`, `grayscale`, etc.) require raster
1203            // pre-painting and are intentionally not honoured.
1204            $this->paintFilterDropShadow($box, $stream);
1205            // CSS Backgrounds 3 Â§6.1.1 â€” paint stack from bottom up:
1206            // outset shadows â†’ background â†’ inset shadows â†’ border.
1207            $this->paintBoxShadow($box, $stream, insetOnly: false);
1208            $this->paintBackground($box, $stream);
1209            $this->paintBoxShadow($box, $stream, insetOnly: true);
1210            // CSS Tables 3 Â§4.3 / painting order â€” a `border-collapse: collapse`
1211            // cell's (collapsed) border belongs to the table and paints AFTER
1212            // the cell's content, so a descendant can't overpaint it. Defer it
1213            // to the late phase below; separated-border cells paint here.
1214            $deferBorder = $this->isCollapsedBorderCell($box);
1215            if (!$deferBorder) {
1216                $this->paintBorders($box, $stream);
1217            }
1218            if ($originalGeo !== null) {
1219                $box->geometry = $originalGeo;
1220            }
1221            $this->paintOutline($box, $stream);
1222            $this->paintColumnRules($box, $stream);
1223            $this->paintGridGapRules($box, $stream);
1224            $this->paintFlexGapRules($box, $stream);
1225            $this->paintImage($box, $stream, $parent);
1226            $this->paintListMarker($box, $stream);
1227            // CSS Overflow 3 Â§3 â€” a box's own inline content is clipped
1228            // by its own `overflow`, exactly as its descendants are
1229            // below. Without this the text escapes the box it lives in.
1230            // Backgrounds, borders and the outline stay OUTSIDE the
1231            // clip: a box's own border and outline are not clipped by
1232            // its overflow.
1233            if ($this->shouldOverflowClip($box)) {
1234                $stream->saveGraphicsState();
1235                $this->emitOverflowClipPath($stream, $box);
1236                $this->paintLineBoxes($box, $stream);
1237                $stream->restoreGraphicsState();
1238            } else {
1239                $this->paintLineBoxes($box, $stream);
1240            }
1241            $this->collectBlockLinkRect($box);
1242        }
1243        // CSS 2.1 Â§17.5.1 â€” column-group and column backgrounds sit
1244        // above the table's own background and below the row groups /
1245        // rows / cells that follow as children.
1246        if ($box instanceof \Phpdftk\HtmlToPdf\Box\TableBox) {
1247            $this->paintTableColumnBackgrounds($box, $stream);
1248        }
1249        // CSS Overflow 3 Â§3 â€” `overflow: hidden | clip | scroll | auto`
1250        // clips descendants to the box's padding-edge. `visible` (the
1251        // initial value) lets descendants render outside the box.
1252        // Print medium can't scroll, so `scroll` / `auto` behave like
1253        // `hidden` here. The clip is push/popped around the children
1254        // loop so siblings of this box stay unaffected.
1255        $overflowClip = $this->shouldOverflowClip($box);
1256        if ($overflowClip) {
1257            $stream->saveGraphicsState();
1258            $this->emitOverflowClipPath($stream, $box);
1259        }
1260        $mcFragment = $box->multiColumn;
1261        if ($mcFragment !== null && ($mcFragment->fragmented || $mcFragment->runs !== [])) {
1262            // CSS Multi-column 1 Â§3.3 â€” `column-fill: auto`: the content was
1263            // laid out in one tall column; slice it into columns here.
1264            $this->paintFragmentedColumns($box, $stream, $mcFragment);
1265        } else {
1266            foreach ($this->paintOrderChildren($box) as $child) {
1267                $this->paintBox($child, $stream, $box);
1268            }
1269        }
1270        if ($overflowClip) {
1271            $stream->restoreGraphicsState();
1272        }
1273        // Late phase: a deferred `border-collapse: collapse` cell border paints
1274        // over the cell's content (CSS Tables 3 Â§4.3), after the overflow clip
1275        // is released so the border ring itself is not clipped.
1276        if ($deferBorder ?? false) {
1277            $this->paintBorders($box, $stream);
1278        }
1279        if ($clipPathApplied) {
1280            $stream->restoreGraphicsState();
1281        }
1282        if ($clipRect !== null) {
1283            $stream->restoreGraphicsState();
1284        }
1285        if ($hasTransform) {
1286            $stream->restoreGraphicsState();
1287        }
1288        if ($opacityGsName !== null) {
1289            $stream->restoreGraphicsState();
1290        }
1291    }
1292
1293    /**
1294     * CSS Masking 1 Â§4 â€” paint a masked box as a single composited GROUP:
1295     * the whole subtree goes into a transparency-group Form XObject, and
1296     * that form is drawn once under the soft-mask `gs`.
1297     *
1298     * The obvious alternative â€” set `gs` and then paint normally â€” is what
1299     * this replaces, and it does not survive a real consumer. Ghostscript
1300     * drops the soft mask at the first `Q` inside the masked scope, and the
1301     * painter wraps every step (background, each border, the content) in its
1302     * own `q`/`Q`, so only the FIRST drawing block was ever masked: a masked
1303     * `<div>` kept its borders at full opacity. Emitting one `Do` means
1304     * there is exactly one marking operation under the mask, which is also
1305     * the grouping CSS Masking specifies (the element and its subtree are
1306     * composited, then masked) rather than per-operation masking.
1307     *
1308     * The form carries no `/Resources`: fonts, images and ExtGStates
1309     * registered while painting the subtree land on the page, and a form
1310     * without its own dictionary looks them up there (ISO 32000-2 Â§8.10.1).
1311     */
1312    private function paintMaskedGroup(
1313        Box $box,
1314        ContentStream $stream,
1315        ?Box $parent,
1316        string $maskGsName,
1317    ): void {
1318        $inner = new ContentStream();
1319        $this->paintBox($box, $inner, $parent, insideMaskedGroup: true);
1320        $operators = $inner->getOperators();
1321        if ($operators === [] || $this->writer === null || $this->page === null) {
1322            return;
1323        }
1324        try {
1325            $form = new \Phpdftk\Pdf\Core\Graphics\XObject\FormXObject(
1326                new \Phpdftk\Pdf\Core\PdfArray([
1327                    new \Phpdftk\Pdf\Core\PdfNumber(0.0),
1328                    new \Phpdftk\Pdf\Core\PdfNumber(0.0),
1329                    new \Phpdftk\Pdf\Core\PdfNumber($this->pageWidth),
1330                    new \Phpdftk\Pdf\Core\PdfNumber($this->pageHeight),
1331                ]),
1332                implode("\n", $operators),
1333            );
1334            $group = new \Phpdftk\Pdf\Core\Document\GroupAttributes('Transparency');
1335            $group->cs = new \Phpdftk\Pdf\Core\PdfName('DeviceRGB');
1336            $group->i = new \Phpdftk\Pdf\Core\PdfBoolean(true);
1337            $form->group = $group;
1338            $this->writer->register($form);
1339            $name = 'Fx_mask_' . $form->objectNumber;
1340            $this->page->corePage()->resources?->addXObject(
1341                $name,
1342                new \Phpdftk\Pdf\Core\PdfReference($form->objectNumber),
1343            );
1344        } catch (\Throwable) {
1345            // Could not build the group â€” paint the subtree unmasked
1346            // rather than losing it entirely.
1347            foreach ($operators as $operator) {
1348                $stream->raw($operator);
1349            }
1350            return;
1351        }
1352        $stream->saveGraphicsState();
1353        $stream->setGraphicsState($maskGsName);
1354        $stream->doXObject($name);
1355        $stream->restoreGraphicsState();
1356    }
1357
1358    /**
1359     * Return `true` when the box's layout-Y range sits entirely above or
1360     * entirely below the painter's configured page range. Skipping these
1361     * subtrees lets a 100-page document not re-paint every box on every
1362     * page. Falls back to `false` (always paint) when the page range
1363     * isn't set â€” preserves the old behaviour for single-page renders.
1364     */
1365    private function boxEntirelyOffPage(Box $box): bool
1366    {
1367        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
1368            return false;
1369        }
1370        $g = $box->geometry;
1371        // A NEGATIVE margin makes `outerHeight()` smaller than the box's
1372        // own height â€” and can make it negative outright, which inverted
1373        // this interval and reported the box as entirely above the page.
1374        // A `margin-top: -70px` box 16px tall has an outer height of -54,
1375        // so `bottom` landed above `top` and the box was culled from the
1376        // content stream altogether. Normalise the interval so the test
1377        // asks about the range the box actually occupies.
1378        $extent = $g->outerHeight();
1379        $top = min($g->y, $g->y + $extent);
1380        $bottom = max($g->y, $g->y + $extent);
1381        // Outline boxes / anonymous boxes can carry zero geometry; never
1382        // skip them â€” descendants may still be in range.
1383        if ($bottom === $top) {
1384            return false;
1385        }
1386        return $bottom <= $this->pageRangeStart || $top >= $this->pageRangeEnd;
1387    }
1388
1389    /**
1390     * Apply the box's CSS `transform` (if any) via the PDF `cm`
1391     * operator. Returns `true` if a graphics-state save was emitted
1392     * (the caller must restoreGraphicsState after painting); `false`
1393     * if no transform applied. The transform is composed as
1394     *   T(origin) Ã— M_css→pdf Ã— T(-origin)
1395     * where M is the composition of all transform functions and the
1396     * origin sits at the box's `transform-origin` in PDF coordinates.
1397     */
1398    private function applyBoxTransform(Box $box, ContentStream $stream, ?Box $parent = null): bool
1399    {
1400        // CSS Transforms 2 Â§5 â€” the individual transform properties apply in
1401        // order (translate, rotate, scale) BEFORE the `transform` list, all
1402        // sharing one transform-origin. Build a combined function list.
1403        $functions = $this->individualTransformFunctions($box);
1404        // CSS Motion Path 1 Â§4 â€” `offset-path` contributes between the
1405        // individual properties and `transform`, per the CTM algorithm in
1406        // CSS Transforms 2 Â§CTM.
1407        $functions = array_merge($functions, $this->motionPathFunctions($box, $parent));
1408        $value = $box->style->get('transform');
1409        if ($value instanceof \Phpdftk\Css\Value\Transform) {
1410            $functions = array_merge($functions, $value->functions);
1411        }
1412        if ($functions === []) {
1413            return false;
1414        }
1415        $matrix = $this->composeTransformMatrix(new \Phpdftk\Css\Value\Transform($functions), $box);
1416        if ($matrix === null) {
1417            return false;
1418        }
1419        [$ox, $oy] = $this->resolveTransformOrigin($box);
1420        // T(ox, oy) Ã— M Ã— T(-ox, -oy). PDF cm composes
1421        // CTM_new = CTM_old Ã— M_provided, so submit in
1422        // outer-to-inner order: translate(+), matrix, translate(-).
1423        $stream->saveGraphicsState();
1424        if ($ox !== 0.0 || $oy !== 0.0) {
1425            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $ox, $oy);
1426        }
1427        $stream->concatMatrix($matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5]);
1428        if ($ox !== 0.0 || $oy !== 0.0) {
1429            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$ox, -$oy);
1430        }
1431        return true;
1432    }
1433
1434    /** Whether this box is positioned along an `offset-path`. */
1435    private function hasMotionPath(Box $box): bool
1436    {
1437        return $this->motionRay($box) !== null
1438            || $this->motionShape($box) !== null
1439            || $this->motionCoordBox($box) !== null;
1440    }
1441
1442    /**
1443     * The `<basic-shape>` in this box's `offset-path`, if any. As with
1444     * `ray()`, a trailing `<coord-box>` puts the cascade in a ValueList.
1445     */
1446    private function motionShape(Box $box): ?\Phpdftk\Css\Value\BasicShape
1447    {
1448        $value = $box->style->get('offset-path');
1449        if ($value instanceof \Phpdftk\Css\Value\BasicShape) {
1450            return $value;
1451        }
1452        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
1453            foreach ($value->values as $component) {
1454                if ($component instanceof \Phpdftk\Css\Value\BasicShape) {
1455                    return $component;
1456                }
1457            }
1458        }
1459        return null;
1460    }
1461
1462    /**
1463     * The `ray()` in this box's `offset-path`, if any. `offset-path` may
1464     * carry a trailing coordinate box (`ray(0deg) padding-box`), in which
1465     * case the cascade holds a ValueList.
1466     */
1467    private function motionRay(Box $box): ?\Phpdftk\Css\Value\Ray
1468    {
1469        $value = $box->style->get('offset-path');
1470        if ($value instanceof \Phpdftk\Css\Value\Ray) {
1471            return $value;
1472        }
1473        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
1474            foreach ($value->values as $component) {
1475                if ($component instanceof \Phpdftk\Css\Value\Ray) {
1476                    return $component;
1477                }
1478            }
1479        }
1480        return null;
1481    }
1482
1483    /**
1484     * CSS Motion Path 1 Â§2 â€” the transform functions that place a box on
1485     * its `offset-path`.
1486     *
1487     * The element's `offset-anchor` is moved onto the point at
1488     * `offset-distance` along the path, and rotated by `offset-rotate`.
1489     * `applyBoxTransform` wraps the returned list in
1490     * `T(origin) â€¦ T(-origin)`, so the composition
1491     * `T(P-O) Â· R(φ) Â· T(O-A)` reduces to `T(P) Â· R(φ) Â· T(-A)` â€” mapping
1492     * the anchor exactly onto the path point.
1493     *
1494     * @return list<\Phpdftk\Css\Value\TransformFunction>
1495     */
1496    private function motionPathFunctions(Box $box, ?Box $parent): array
1497    {
1498        $placement = $this->motionPlacement($box, $parent);
1499        if ($placement === null) {
1500            return [];
1501        }
1502        [$px, $py, $tangent] = $placement;
1503        $phi = $this->resolveMotionRotation($box, $tangent);
1504
1505        [$ox, $oy] = $this->transformOriginCss($box);
1506        [$ax, $ay] = $this->motionAnchorCss($box, $ox, $oy);
1507
1508        $functions = [$this->pixelTranslate($px - $ox, $py - $oy)];
1509        if ($phi !== 0.0) {
1510            $functions[] = new \Phpdftk\Css\Value\RotateTransform($phi);
1511        }
1512        if ($ox !== $ax || $oy !== $ay) {
1513            $functions[] = $this->pixelTranslate($ox - $ax, $oy - $ay);
1514        }
1515        return $functions;
1516    }
1517
1518
1519    /**
1520     * Where on its `offset-path` a box sits, in CSS page coordinates,
1521     * together with the path tangent in degrees.
1522     *
1523     * @return array{float, float, float}|null
1524     */
1525    private function motionPlacement(Box $box, ?Box $parent): ?array
1526    {
1527        [$cbX, $cbY, $cbW, $cbH] = $this->motionReferenceRect($box, $parent);
1528
1529        $ray = $this->motionRay($box);
1530        if ($ray !== null) {
1531            $degrees = $this->rayAngleDegrees($ray);
1532            if ($degrees === null) {
1533                return null;
1534            }
1535            [$sx, $sy] = $this->rayOrigin($box, $ray, $cbX, $cbY, $cbW, $cbH);
1536            $length = $this->rayLength($box, $ray, $degrees, $sx, $sy, $cbX, $cbY, $cbW, $cbH);
1537            $distance = $this->resolveMotionDistance($box, $length);
1538
1539            // CSS ray angles are compass BEARINGS: 0deg points UP and
1540            // positive angles turn clockwise. In CSS coordinates Y grows
1541            // downward, so the unit vector is (sin Î¸, -cos Î¸) â€” not the
1542            // (cos, sin) of an ordinary mathematical angle, which would be
1543            // 90° out. The tangent then trails the bearing by 90°.
1544            $theta = deg2rad($degrees);
1545            return [
1546                $sx + $distance * sin($theta),
1547                $sy - $distance * cos($theta),
1548                $degrees - 90.0,
1549            ];
1550        }
1551
1552        $shape = $this->motionShape($box);
1553        if ($shape === null) {
1554            // A lone `<coord-box>` is a complete offset-path: the reference
1555            // rectangle itself. The reference box's own `border-radius`
1556            // should round it; square corners are the approximation here.
1557            $coordBox = $this->motionCoordBox($box);
1558            if ($coordBox === null) {
1559                return null;
1560            }
1561            return $this->pointAlongPolyline($box, $this->roundedRectPolyline(
1562                $cbX,
1563                $cbY,
1564                $cbW,
1565                $cbH,
1566                $this->referenceBoxRadii($parent ?? $box, $coordBox),
1567            ));
1568        }
1569        if ($shape instanceof \Phpdftk\Css\Value\PathShape) {
1570            // Unlike a <basic-shape>, `path()` is NOT resolved in the
1571            // reference box: its coordinates are the element's own, so the
1572            // path hangs off the box's border-box origin. `offset-path-
1573            // string-003` pins this down â€” a 200x300 box at (50, 80) with
1574            // `path('M 50 40')` has to land at (100, 120), which only the
1575            // element-relative origin produces.
1576            $g = $box->geometry;
1577            [$points, $closed] = $this->svgPathPolyline(
1578                $shape->pathData,
1579                $g->x - $g->paddingLeft - $g->borderLeft,
1580                $g->y - $g->paddingTop - $g->borderTop,
1581            );
1582            if ($points === []) {
1583                return null;
1584            }
1585            return $this->pointAlongPolyline($box, $points, $closed);
1586        }
1587        $points = $this->motionShapePolyline($box, $shape, $cbX, $cbY, $cbW, $cbH);
1588        if ($points === []) {
1589            return null;
1590        }
1591        return $this->pointAlongPolyline($box, $points);
1592    }
1593
1594    /**
1595     * Walk a closed polyline to the point at `offset-distance`.
1596     *
1597     * Sampling every shape into a polyline gives one arc-length
1598     * parameterisation for all of them: an ellipse's quarter-perimeter has
1599     * no closed form, and a rounded rectangle mixes straight runs with
1600     * elliptical arcs, so a cumulative-length table is the honest way to
1601     * measure both.
1602     *
1603     * CSS Motion Path 1 Â§3.1 â€” a distance off either end of a CLOSED path
1604     * wraps around it, while an open path clamps to its endpoints.
1605     *
1606     * @param list<array{float, float}> $points
1607     * @return array{float, float, float}
1608     */
1609    private function pointAlongPolyline(Box $box, array $points, bool $closed = true): array
1610    {
1611        $count = count($points);
1612        $cumulative = [0.0];
1613        $total = 0.0;
1614        for ($i = 1; $i < $count; ++$i) {
1615            $total += hypot(
1616                $points[$i][0] - $points[$i - 1][0],
1617                $points[$i][1] - $points[$i - 1][1],
1618            );
1619            $cumulative[$i] = $total;
1620        }
1621        // A degenerate shape â€” `circle()` centred on an edge resolves
1622        // `closest-side` to zero â€” collapses to its single point.
1623        if ($total <= 0.0 || $count < 2) {
1624            return [$points[0][0], $points[0][1], 0.0];
1625        }
1626
1627        $distance = $this->resolveMotionDistance($box, $total);
1628        if ($closed) {
1629            $distance = fmod($distance, $total);
1630            if ($distance < 0.0) {
1631                $distance += $total;
1632            }
1633        } else {
1634            $distance = max(0.0, min($total, $distance));
1635        }
1636
1637        $index = 1;
1638        while ($index < $count - 1 && $cumulative[$index] < $distance) {
1639            ++$index;
1640        }
1641        [$x0, $y0] = $points[$index - 1];
1642        [$x1, $y1] = $points[$index];
1643        $span = $cumulative[$index] - $cumulative[$index - 1];
1644        $t = $span > 0.0 ? ($distance - $cumulative[$index - 1]) / $span : 0.0;
1645
1646        return [
1647            $x0 + ($x1 - $x0) * $t,
1648            $y0 + ($y1 - $y0) * $t,
1649            rad2deg(atan2($y1 - $y0, $x1 - $x0)),
1650        ];
1651    }
1652
1653    /**
1654     * Sample a `<basic-shape>` offset-path into a closed polyline, in CSS
1655     * page coordinates.
1656     *
1657     * Every shape starts where its conversion to a path starts and winds
1658     * clockwise: circles and ellipses from 3 o'clock, the rectangle family
1659     * from the top-left corner, and a polygon from its first vertex.
1660     *
1661     * @return list<array{float, float}>
1662     */
1663    private function motionShapePolyline(
1664        Box $box,
1665        \Phpdftk\Css\Value\BasicShape $shape,
1666        float $cbX,
1667        float $cbY,
1668        float $cbW,
1669        float $cbH,
1670    ): array {
1671        if ($shape instanceof \Phpdftk\Css\Value\CircleShape) {
1672            [$cx, $cy] = $this->shapeCentre(
1673                $box,
1674                $shape->centerX,
1675                $shape->centerY,
1676                $cbX,
1677                $cbY,
1678                $cbW,
1679                $cbH,
1680            );
1681            $radius = $this->shapeRadius(
1682                $shape->radius,
1683                $cx,
1684                $cy,
1685                $cbX,
1686                $cbY,
1687                $cbW,
1688                $cbH,
1689                null,
1690            );
1691            return $this->ellipsePolyline($cx, $cy, $radius, $radius);
1692        }
1693        if ($shape instanceof \Phpdftk\Css\Value\EllipseShape) {
1694            [$cx, $cy] = $this->shapeCentre(
1695                $box,
1696                $shape->centerX,
1697                $shape->centerY,
1698                $cbX,
1699                $cbY,
1700                $cbW,
1701                $cbH,
1702            );
1703            $rx = $this->shapeRadius($shape->radiusX, $cx, $cy, $cbX, $cbY, $cbW, $cbH, true);
1704            $ry = $this->shapeRadius($shape->radiusY, $cx, $cy, $cbX, $cbY, $cbW, $cbH, false);
1705            return $this->ellipsePolyline($cx, $cy, $rx, $ry);
1706        }
1707        if ($shape instanceof \Phpdftk\Css\Value\PolygonShape) {
1708            $points = [];
1709            foreach ($shape->vertices as $vertex) {
1710                $points[] = [
1711                    $cbX + $this->resolvePositionComponent($vertex[0], $cbW, true),
1712                    $cbY + $this->resolvePositionComponent($vertex[1], $cbH, false),
1713                ];
1714            }
1715            if (count($points) < 2) {
1716                return $points;
1717            }
1718            $points[] = $points[0];
1719            return $points;
1720        }
1721
1722        $rect = $this->motionShapeRect($shape, $cbX, $cbY, $cbW, $cbH);
1723        if ($rect === null) {
1724            return [];
1725        }
1726        [$left, $top, $width, $height, $radii] = $rect;
1727        return $this->roundedRectPolyline($left, $top, $width, $height, $radii);
1728    }
1729
1730
1731    /**
1732     * The centre of a `circle()` / `ellipse()` offset-path.
1733     *
1734     * An omitted `at <position>` does NOT default to the reference box's
1735     * centre the way it does for `clip-path` â€” CSS Motion Path 1 Â§2 makes
1736     * `offset-position` the default, so `circle()` with
1737     * `offset-position: auto` is centred on the box's own corner.
1738     *
1739     * @return array{float, float}
1740     */
1741    private function shapeCentre(
1742        Box $box,
1743        ?\Phpdftk\Css\Value\Value $centerX,
1744        ?\Phpdftk\Css\Value\Value $centerY,
1745        float $cbX,
1746        float $cbY,
1747        float $cbW,
1748        float $cbH,
1749    ): array {
1750        if ($centerX !== null && $centerY !== null) {
1751            return [
1752                $cbX + $this->resolvePositionComponent($centerX, $cbW, true),
1753                $cbY + $this->resolvePositionComponent($centerY, $cbH, false),
1754            ];
1755        }
1756        return $this->offsetPositionPoint($box, $cbX, $cbY, $cbW, $cbH);
1757    }
1758
1759    /**
1760     * `offset-position` in CSS page coordinates. `auto` is the box's own
1761     * border-box origin; `normal` â€” the initial value â€” is the centre of
1762     * the containing block.
1763     *
1764     * @return array{float, float}
1765     */
1766    private function offsetPositionPoint(
1767        Box $box,
1768        float $cbX,
1769        float $cbY,
1770        float $cbW,
1771        float $cbH,
1772    ): array {
1773        $position = $box->style->get('offset-position');
1774        if ($position instanceof Keyword && strtolower($position->name) === 'auto') {
1775            $g = $box->geometry;
1776            return [
1777                $g->x - $g->paddingLeft - $g->borderLeft,
1778                $g->y - $g->paddingTop - $g->borderTop,
1779            ];
1780        }
1781        if ($position instanceof \Phpdftk\Css\Value\ValueList
1782            && count($position->values) >= 2
1783        ) {
1784            return [
1785                $cbX + $this->resolvePositionComponent($position->values[0], $cbW, true),
1786                $cbY + $this->resolvePositionComponent($position->values[1], $cbH, false),
1787            ];
1788        }
1789        return [$cbX + $cbW / 2.0, $cbY + $cbH / 2.0];
1790    }
1791
1792    /**
1793     * One radius of a `circle()` / `ellipse()`.
1794     *
1795     * `$horizontal` picks the axis for an ellipse; `null` marks a circle,
1796     * whose percentage radius resolves against the reference box's
1797     * diagonal per CSS Shapes 1 Â§5 rather than against one side.
1798     */
1799    private function shapeRadius(
1800        ?\Phpdftk\Css\Value\Value $radius,
1801        float $cx,
1802        float $cy,
1803        float $cbX,
1804        float $cbY,
1805        float $cbW,
1806        float $cbH,
1807        ?bool $horizontal,
1808    ): float {
1809        $left = abs($cx - $cbX);
1810        $right = abs($cbX + $cbW - $cx);
1811        $top = abs($cy - $cbY);
1812        $bottom = abs($cbY + $cbH - $cy);
1813
1814        $keyword = null;
1815        if ($radius === null) {
1816            $keyword = 'closest-side';
1817        } elseif ($radius instanceof Keyword) {
1818            $keyword = strtolower($radius->name);
1819        }
1820        if ($keyword !== null) {
1821            $sides = $horizontal === null
1822                ? [$left, $right, $top, $bottom]
1823                : ($horizontal ? [$left, $right] : [$top, $bottom]);
1824            $corners = [
1825                hypot($left, $top),
1826                hypot($right, $top),
1827                hypot($left, $bottom),
1828                hypot($right, $bottom),
1829            ];
1830            return match ($keyword) {
1831                'farthest-side' => max($sides),
1832                'closest-corner' => min($corners),
1833                'farthest-corner' => max($corners),
1834                default => min($sides),
1835            };
1836        }
1837        if ($radius instanceof \Phpdftk\Css\Value\Percentage) {
1838            $basis = match ($horizontal) {
1839                true => $cbW,
1840                false => $cbH,
1841                // CSS Shapes 1 Â§5: sqrt(w² + h²) / sqrt(2).
1842                null => sqrt(($cbW * $cbW + $cbH * $cbH) / 2.0),
1843            };
1844            return abs($basis * $radius->value / 100.0);
1845        }
1846        return abs($this->resolvePositionComponent(
1847            $radius,
1848            $horizontal === false ? $cbH : $cbW,
1849            $horizontal !== false,
1850        ));
1851    }
1852
1853    /**
1854     * Resolve `inset()` / `rect()` / `xywh()` to a rectangle plus its four
1855     * corner radii, in CSS page coordinates.
1856     *
1857     * @return array{float, float, float, float, list<array{float, float}>}|null
1858     */
1859    private function motionShapeRect(
1860        \Phpdftk\Css\Value\BasicShape $shape,
1861        float $cbX,
1862        float $cbY,
1863        float $cbW,
1864        float $cbH,
1865    ): ?array {
1866        $radiiSource = null;
1867        if ($shape instanceof \Phpdftk\Css\Value\InsetShape) {
1868            $edges = $this->expandBoxSides($shape->insets);
1869            $top = $this->resolvePositionComponent($edges[0], $cbH, false);
1870            $right = $this->resolvePositionComponent($edges[1], $cbW, true);
1871            $bottom = $this->resolvePositionComponent($edges[2], $cbH, false);
1872            $left = $this->resolvePositionComponent($edges[3], $cbW, true);
1873            $x0 = $cbX + $left;
1874            $y0 = $cbY + $top;
1875            $x1 = $cbX + $cbW - $right;
1876            $y1 = $cbY + $cbH - $bottom;
1877            $radiiSource = $shape->borderRadius;
1878        } elseif ($shape instanceof \Phpdftk\Css\Value\RectShape) {
1879            // `rect()` edges are offsets from the reference box's top-left,
1880            // not insets from each side, and `auto` means that box's own
1881            // edge.
1882            $edges = $this->expandBoxSides($shape->edges);
1883            $top = $this->motionRectEdge($edges[0], $cbH, false, 0.0);
1884            $right = $this->motionRectEdge($edges[1], $cbW, true, $cbW);
1885            $bottom = $this->motionRectEdge($edges[2], $cbH, false, $cbH);
1886            $left = $this->motionRectEdge($edges[3], $cbW, true, 0.0);
1887            $x0 = $cbX + $left;
1888            $y0 = $cbY + $top;
1889            $x1 = $cbX + $right;
1890            $y1 = $cbY + $bottom;
1891            $radiiSource = $shape->borderRadius;
1892        } elseif ($shape instanceof \Phpdftk\Css\Value\XywhShape) {
1893            $x0 = $cbX + $this->resolvePositionComponent($shape->x, $cbW, true);
1894            $y0 = $cbY + $this->resolvePositionComponent($shape->y, $cbH, false);
1895            $x1 = $x0 + $this->resolvePositionComponent($shape->width, $cbW, true);
1896            $y1 = $y0 + $this->resolvePositionComponent($shape->height, $cbH, false);
1897            $radiiSource = $shape->borderRadius;
1898        } else {
1899            return null;
1900        }
1901
1902        $left = min($x0, $x1);
1903        $top = min($y0, $y1);
1904        $width = abs($x1 - $x0);
1905        $height = abs($y1 - $y0);
1906        $radii = $this->shapeCornerRadii($radiiSource, $width, $height);
1907
1908        return [$left, $top, $width, $height, $radii];
1909    }
1910
1911    /** One `rect()` edge, where `auto` falls back to the reference box. */
1912    private function motionRectEdge(
1913        \Phpdftk\Css\Value\Value $value,
1914        float $extent,
1915        bool $horizontal,
1916        float $auto,
1917    ): float {
1918        if ($value instanceof Keyword && strtolower($value->name) === 'auto') {
1919            return $auto;
1920        }
1921        return $this->resolvePositionComponent($value, $extent, $horizontal);
1922    }
1923
1924    /**
1925     * Expand a 1-to-4 value box-side list to the full top/right/bottom/left
1926     * quartet.
1927     *
1928     * @param list<\Phpdftk\Css\Value\Value> $values
1929     * @return array{
1930     *     \Phpdftk\Css\Value\Value,
1931     *     \Phpdftk\Css\Value\Value,
1932     *     \Phpdftk\Css\Value\Value,
1933     *     \Phpdftk\Css\Value\Value
1934     * }
1935     */
1936    private function expandBoxSides(array $values): array
1937    {
1938        return match (count($values)) {
1939            1 => [$values[0], $values[0], $values[0], $values[0]],
1940            2 => [$values[0], $values[1], $values[0], $values[1]],
1941            3 => [$values[0], $values[1], $values[2], $values[1]],
1942            default => [$values[0], $values[1], $values[2], $values[3]],
1943        };
1944    }
1945
1946    /**
1947     * The four `[rx, ry]` corner radii of a `round <border-radius>` clause,
1948     * clockwise from the top-left, scaled down together if adjacent radii
1949     * would overlap (CSS Backgrounds 3 Â§5.5).
1950     *
1951     * @param ?array<int, mixed> $radiusSource
1952     * @return list<array{float, float}>
1953     */
1954    private function shapeCornerRadii(?array $radiusSource, float $width, float $height): array
1955    {
1956        if ($radiusSource === null || $radiusSource === []) {
1957            return [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]];
1958        }
1959        $horizontal = [];
1960        $vertical = [];
1961        foreach ($radiusSource as $entry) {
1962            if (is_array($entry) && count($entry) >= 2) {
1963                $horizontal[] = $entry[0];
1964                $vertical[] = $entry[1];
1965                continue;
1966            }
1967            if ($entry instanceof \Phpdftk\Css\Value\Value) {
1968                $horizontal[] = $entry;
1969                $vertical[] = $entry;
1970            }
1971        }
1972        if ($horizontal === []) {
1973            return [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]];
1974        }
1975        $hx = $this->expandCornerList($horizontal);
1976        $vy = $this->expandCornerList($vertical);
1977
1978        $radii = [];
1979        for ($i = 0; $i < 4; ++$i) {
1980            $radii[] = [
1981                abs($this->resolvePositionComponent($hx[$i], $width, true)),
1982                abs($this->resolvePositionComponent($vy[$i], $height, false)),
1983            ];
1984        }
1985
1986        // Uniform down-scale when opposing radii overshoot an edge.
1987        $scale = 1.0;
1988        foreach ([
1989            [$radii[0][0] + $radii[1][0], $width],
1990            [$radii[3][0] + $radii[2][0], $width],
1991            [$radii[0][1] + $radii[3][1], $height],
1992            [$radii[1][1] + $radii[2][1], $height],
1993        ] as [$sum, $extent]) {
1994            if ($sum > 0.0 && $sum > $extent) {
1995                $scale = min($scale, $extent / $sum);
1996            }
1997        }
1998        if ($scale < 1.0) {
1999            foreach ($radii as $i => [$rx, $ry]) {
2000                $radii[$i] = [$rx * $scale, $ry * $scale];
2001            }
2002        }
2003        return $radii;
2004    }
2005
2006    /**
2007     * Expand a 1-to-4 value corner list to all four corners.
2008     *
2009     * @param list<\Phpdftk\Css\Value\Value> $values
2010     * @return array{
2011     *     \Phpdftk\Css\Value\Value,
2012     *     \Phpdftk\Css\Value\Value,
2013     *     \Phpdftk\Css\Value\Value,
2014     *     \Phpdftk\Css\Value\Value
2015     * }
2016     */
2017    private function expandCornerList(array $values): array
2018    {
2019        return match (count($values)) {
2020            1 => [$values[0], $values[0], $values[0], $values[0]],
2021            2 => [$values[0], $values[1], $values[0], $values[1]],
2022            3 => [$values[0], $values[1], $values[2], $values[1]],
2023            default => [$values[0], $values[1], $values[2], $values[3]],
2024        };
2025    }
2026
2027    /**
2028     * An ellipse sampled clockwise from 3 o'clock, closed.
2029     *
2030     * @return list<array{float, float}>
2031     */
2032    private function ellipsePolyline(float $cx, float $cy, float $rx, float $ry): array
2033    {
2034        if ($rx <= 0.0 && $ry <= 0.0) {
2035            return [[$cx, $cy]];
2036        }
2037        $steps = 720;
2038        $points = [];
2039        for ($i = 0; $i <= $steps; ++$i) {
2040            $angle = 2.0 * M_PI * $i / $steps;
2041            $points[] = [$cx + $rx * cos($angle), $cy + $ry * sin($angle)];
2042        }
2043        return $points;
2044    }
2045
2046    /**
2047     * A rectangle sampled clockwise from the top-left, closed. Non-zero
2048     * corner radii become elliptical arcs, so the path starts where the
2049     * top edge does rather than at the (cut away) corner itself.
2050     *
2051     * @param list<array{float, float}> $radii
2052     * @return list<array{float, float}>
2053     */
2054    private function roundedRectPolyline(
2055        float $left,
2056        float $top,
2057        float $width,
2058        float $height,
2059        array $radii,
2060    ): array {
2061        $right = $left + $width;
2062        $bottom = $top + $height;
2063        if ($width <= 0.0 && $height <= 0.0) {
2064            return [[$left, $top]];
2065        }
2066        [$tl, $tr, $br, $bl] = $radii;
2067        $rounded = false;
2068        foreach ($radii as [$rx, $ry]) {
2069            if ($rx > 0.0 || $ry > 0.0) {
2070                $rounded = true;
2071                break;
2072            }
2073        }
2074        if (!$rounded) {
2075            return [
2076                [$left, $top],
2077                [$right, $top],
2078                [$right, $bottom],
2079                [$left, $bottom],
2080                [$left, $top],
2081            ];
2082        }
2083
2084        $arcSteps = 24;
2085        $points = [[$left + $tl[0], $top]];
2086        // Each corner arc sweeps a quarter turn clockwise, from the end of
2087        // the incoming edge to the start of the outgoing one.
2088        foreach ([
2089            [$right - $tr[0], $top, $tr, $right - $tr[0], $top + $tr[1], -M_PI / 2.0],
2090            [$right, $bottom - $br[1], $br, $right - $br[0], $bottom - $br[1], 0.0],
2091            [$left + $bl[0], $bottom, $bl, $left + $bl[0], $bottom - $bl[1], M_PI / 2.0],
2092            [$left, $top + $tl[1], $tl, $left + $tl[0], $top + $tl[1], M_PI],
2093        ] as [$edgeX, $edgeY, $radius, $arcCx, $arcCy, $start]) {
2094            $points[] = [$edgeX, $edgeY];
2095            if ($radius[0] <= 0.0 && $radius[1] <= 0.0) {
2096                continue;
2097            }
2098            for ($i = 1; $i <= $arcSteps; ++$i) {
2099                $angle = $start + (M_PI / 2.0) * $i / $arcSteps;
2100                $points[] = [
2101                    $arcCx + $radius[0] * cos($angle),
2102                    $arcCy + $radius[1] * sin($angle),
2103                ];
2104            }
2105        }
2106        return $points;
2107    }
2108
2109
2110    /**
2111     * Flatten SVG path data into a polyline, in CSS page coordinates
2112     * anchored at the reference box's origin.
2113     *
2114     * Returns the points and whether the path closed, which decides
2115     * whether `offset-distance` wraps or clamps.
2116     *
2117     * A second subpath ends the walk: `offset-distance` measures one
2118     * continuous run, and stitching disjoint subpaths would invent
2119     * segments across the gaps that the author never drew.
2120     *
2121     * @return array{list<array{float, float}>, bool}
2122     */
2123    private function svgPathPolyline(string $pathData, float $originX, float $originY): array
2124    {
2125        $commands = \Phpdftk\Svg\Path\PathData::parse($pathData)->commands;
2126
2127        $points = [];
2128        $x = 0.0;
2129        $y = 0.0;
2130        $startX = 0.0;
2131        $startY = 0.0;
2132        $closed = false;
2133        // Reflection state for the smooth curve commands.
2134        $lastCubic = null;
2135        $lastQuadratic = null;
2136        $started = false;
2137
2138        $push = static function (float $px, float $py) use (&$points, $originX, $originY): void {
2139            $points[] = [$originX + $px, $originY + $py];
2140        };
2141
2142        foreach ($commands as $command) {
2143            $absolute = $command->absolute;
2144            $baseX = $absolute ? 0.0 : $x;
2145            $baseY = $absolute ? 0.0 : $y;
2146            $cubic = null;
2147            $quadratic = null;
2148
2149            if ($command instanceof \Phpdftk\Svg\Path\MoveTo) {
2150                if ($started) {
2151                    break;
2152                }
2153                $x = $baseX + $command->x;
2154                $y = $baseY + $command->y;
2155                $startX = $x;
2156                $startY = $y;
2157                $started = true;
2158                $push($x, $y);
2159            } elseif ($command instanceof \Phpdftk\Svg\Path\LineTo) {
2160                $x = $baseX + $command->x;
2161                $y = $baseY + $command->y;
2162                $push($x, $y);
2163            } elseif ($command instanceof \Phpdftk\Svg\Path\HorizontalLineTo) {
2164                $x = $baseX + $command->x;
2165                $push($x, $y);
2166            } elseif ($command instanceof \Phpdftk\Svg\Path\VerticalLineTo) {
2167                $y = $baseY + $command->y;
2168                $push($x, $y);
2169            } elseif ($command instanceof \Phpdftk\Svg\Path\CurveTo) {
2170                $c1x = $baseX + $command->x1;
2171                $c1y = $baseY + $command->y1;
2172                $c2x = $baseX + $command->x2;
2173                $c2y = $baseY + $command->y2;
2174                $ex = $baseX + $command->x;
2175                $ey = $baseY + $command->y;
2176                foreach ($this->cubicSamples($x, $y, $c1x, $c1y, $c2x, $c2y, $ex, $ey) as [$sx, $sy]) {
2177                    $push($sx, $sy);
2178                }
2179                $cubic = [$c2x, $c2y];
2180                $x = $ex;
2181                $y = $ey;
2182            } elseif ($command instanceof \Phpdftk\Svg\Path\SmoothCurveTo) {
2183                [$c1x, $c1y] = $lastCubic !== null
2184                    ? [2.0 * $x - $lastCubic[0], 2.0 * $y - $lastCubic[1]]
2185                    : [$x, $y];
2186                $c2x = $baseX + $command->x2;
2187                $c2y = $baseY + $command->y2;
2188                $ex = $baseX + $command->x;
2189                $ey = $baseY + $command->y;
2190                foreach ($this->cubicSamples($x, $y, $c1x, $c1y, $c2x, $c2y, $ex, $ey) as [$sx, $sy]) {
2191                    $push($sx, $sy);
2192                }
2193                $cubic = [$c2x, $c2y];
2194                $x = $ex;
2195                $y = $ey;
2196            } elseif ($command instanceof \Phpdftk\Svg\Path\QuadraticCurveTo) {
2197                $qx = $baseX + $command->x1;
2198                $qy = $baseY + $command->y1;
2199                $ex = $baseX + $command->x;
2200                $ey = $baseY + $command->y;
2201                foreach ($this->quadraticSamples($x, $y, $qx, $qy, $ex, $ey) as [$sx, $sy]) {
2202                    $push($sx, $sy);
2203                }
2204                $quadratic = [$qx, $qy];
2205                $x = $ex;
2206                $y = $ey;
2207            } elseif ($command instanceof \Phpdftk\Svg\Path\SmoothQuadraticCurveTo) {
2208                [$qx, $qy] = $lastQuadratic !== null
2209                    ? [2.0 * $x - $lastQuadratic[0], 2.0 * $y - $lastQuadratic[1]]
2210                    : [$x, $y];
2211                $ex = $baseX + $command->x;
2212                $ey = $baseY + $command->y;
2213                foreach ($this->quadraticSamples($x, $y, $qx, $qy, $ex, $ey) as [$sx, $sy]) {
2214                    $push($sx, $sy);
2215                }
2216                $quadratic = [$qx, $qy];
2217                $x = $ex;
2218                $y = $ey;
2219            } elseif ($command instanceof \Phpdftk\Svg\Path\ArcTo) {
2220                $ex = $baseX + $command->x;
2221                $ey = $baseY + $command->y;
2222                foreach ($this->arcSamples(
2223                    $x,
2224                    $y,
2225                    $command->rx,
2226                    $command->ry,
2227                    $command->xAxisRotation,
2228                    $command->largeArc,
2229                    $command->sweep,
2230                    $ex,
2231                    $ey,
2232                ) as [$sx, $sy]) {
2233                    $push($sx, $sy);
2234                }
2235                $x = $ex;
2236                $y = $ey;
2237            } elseif ($command instanceof \Phpdftk\Svg\Path\ClosePath) {
2238                $push($startX, $startY);
2239                $x = $startX;
2240                $y = $startY;
2241                $closed = true;
2242            }
2243
2244            $lastCubic = $cubic;
2245            $lastQuadratic = $quadratic;
2246        }
2247
2248        return [$points, $closed];
2249    }
2250
2251    /**
2252     * Points along a cubic Bézier, excluding its start.
2253     *
2254     * @return list<array{float, float}>
2255     */
2256    private function cubicSamples(
2257        float $x0,
2258        float $y0,
2259        float $x1,
2260        float $y1,
2261        float $x2,
2262        float $y2,
2263        float $x3,
2264        float $y3,
2265    ): array {
2266        $steps = 32;
2267        $samples = [];
2268        for ($i = 1; $i <= $steps; ++$i) {
2269            $t = $i / $steps;
2270            $u = 1.0 - $t;
2271            $a = $u * $u * $u;
2272            $b = 3.0 * $u * $u * $t;
2273            $c = 3.0 * $u * $t * $t;
2274            $d = $t * $t * $t;
2275            $samples[] = [
2276                $a * $x0 + $b * $x1 + $c * $x2 + $d * $x3,
2277                $a * $y0 + $b * $y1 + $c * $y2 + $d * $y3,
2278            ];
2279        }
2280        return $samples;
2281    }
2282
2283    /**
2284     * Points along a quadratic Bézier, excluding its start.
2285     *
2286     * @return list<array{float, float}>
2287     */
2288    private function quadraticSamples(
2289        float $x0,
2290        float $y0,
2291        float $x1,
2292        float $y1,
2293        float $x2,
2294        float $y2,
2295    ): array {
2296        $steps = 24;
2297        $samples = [];
2298        for ($i = 1; $i <= $steps; ++$i) {
2299            $t = $i / $steps;
2300            $u = 1.0 - $t;
2301            $samples[] = [
2302                $u * $u * $x0 + 2.0 * $u * $t * $x1 + $t * $t * $x2,
2303                $u * $u * $y0 + 2.0 * $u * $t * $y1 + $t * $t * $y2,
2304            ];
2305        }
2306        return $samples;
2307    }
2308
2309    /**
2310     * Points along an SVG elliptical arc, excluding its start.
2311     *
2312     * Implements the endpoint-to-centre conversion of SVG 2 Â§B.2.4,
2313     * including the Â§B.2.5 correction that scales up radii too small to
2314     * span the two endpoints.
2315     *
2316     * @return list<array{float, float}>
2317     */
2318    private function arcSamples(
2319        float $x0,
2320        float $y0,
2321        float $rx,
2322        float $ry,
2323        float $rotationDegrees,
2324        bool $largeArc,
2325        bool $sweep,
2326        float $x1,
2327        float $y1,
2328    ): array {
2329        $rx = abs($rx);
2330        $ry = abs($ry);
2331        // Degenerate radii make the arc a straight line (SVG 2 Â§B.2.5).
2332        if ($rx == 0.0 || $ry == 0.0 || ($x0 == $x1 && $y0 == $y1)) {
2333            return [[$x1, $y1]];
2334        }
2335
2336        $phi = deg2rad($rotationDegrees);
2337        $cosPhi = cos($phi);
2338        $sinPhi = sin($phi);
2339
2340        $dx = ($x0 - $x1) / 2.0;
2341        $dy = ($y0 - $y1) / 2.0;
2342        $x1p = $cosPhi * $dx + $sinPhi * $dy;
2343        $y1p = -$sinPhi * $dx + $cosPhi * $dy;
2344
2345        $lambda = ($x1p * $x1p) / ($rx * $rx) + ($y1p * $y1p) / ($ry * $ry);
2346        if ($lambda > 1.0) {
2347            $scale = sqrt($lambda);
2348            $rx *= $scale;
2349            $ry *= $scale;
2350        }
2351
2352        $numerator = $rx * $rx * $ry * $ry
2353            - $rx * $rx * $y1p * $y1p
2354            - $ry * $ry * $x1p * $x1p;
2355        $denominator = $rx * $rx * $y1p * $y1p + $ry * $ry * $x1p * $x1p;
2356        $factor = $denominator == 0.0 ? 0.0 : sqrt(max(0.0, $numerator / $denominator));
2357        if ($largeArc === $sweep) {
2358            $factor = -$factor;
2359        }
2360        $cxp = $factor * $rx * $y1p / $ry;
2361        $cyp = -$factor * $ry * $x1p / $rx;
2362
2363        $cx = $cosPhi * $cxp - $sinPhi * $cyp + ($x0 + $x1) / 2.0;
2364        $cy = $sinPhi * $cxp + $cosPhi * $cyp + ($y0 + $y1) / 2.0;
2365
2366        $startAngle = atan2(($y1p - $cyp) / $ry, ($x1p - $cxp) / $rx);
2367        $endAngle = atan2((-$y1p - $cyp) / $ry, (-$x1p - $cxp) / $rx);
2368        $sweepAngle = $endAngle - $startAngle;
2369        if (!$sweep && $sweepAngle > 0.0) {
2370            $sweepAngle -= 2.0 * M_PI;
2371        } elseif ($sweep && $sweepAngle < 0.0) {
2372            $sweepAngle += 2.0 * M_PI;
2373        }
2374
2375        $steps = max(8, (int) ceil(abs($sweepAngle) / (M_PI / 24.0)));
2376        $samples = [];
2377        for ($i = 1; $i <= $steps; ++$i) {
2378            $angle = $startAngle + $sweepAngle * $i / $steps;
2379            $ex = $rx * cos($angle);
2380            $ey = $ry * sin($angle);
2381            $samples[] = [
2382                $cx + $cosPhi * $ex - $sinPhi * $ey,
2383                $cy + $sinPhi * $ex + $cosPhi * $ey,
2384            ];
2385        }
2386        return $samples;
2387    }
2388
2389    private function pixelTranslate(float $dx, float $dy): \Phpdftk\Css\Value\TranslateTransform
2390    {
2391        return new \Phpdftk\Css\Value\TranslateTransform(
2392            new \Phpdftk\Css\Value\Length($dx, \Phpdftk\Css\Value\LengthUnit::Px),
2393            new \Phpdftk\Css\Value\Length($dy, \Phpdftk\Css\Value\LengthUnit::Px),
2394        );
2395    }
2396
2397    /**
2398     * A finite bearing in degrees. Enormous authored angles are wrapped
2399     * before they reach `sin()` / `cos()`, and non-finite values (which a
2400     * bad `calc()` can produce) disable the path rather than emitting a
2401     * NaN matrix that would corrupt the content stream.
2402     */
2403    private function rayAngleDegrees(\Phpdftk\Css\Value\Ray $ray): ?float
2404    {
2405        $degrees = $ray->angleDegrees();
2406        if ($degrees === null || !is_finite($degrees)) {
2407            return null;
2408        }
2409        return fmod($degrees, 360.0);
2410    }
2411
2412    /**
2413     * The reference rectangle a ray is measured against â€” its containing
2414     * block, in CSS page coordinates.
2415     *
2416     * Approximated by the paint parent's content box. That is correct for
2417     * in-flow boxes; an absolutely-positioned box whose containing block is
2418     * a more distant positioned ancestor resolves against the wrong
2419     * rectangle, which matters only for percentage distances, `contain`,
2420     * and the size keywords.
2421     *
2422     * @return array{float, float, float, float}
2423     */
2424    private function motionReferenceRect(Box $box, ?Box $parent): array
2425    {
2426        $source = $parent ?? $box;
2427        $g = $source->geometry;
2428
2429        // CSS Motion Path 1 Â§2 â€” an omitted `<coord-box>` is `border-box`.
2430        // The SVG boxes have no meaning for an HTML containing block, so
2431        // they fall back to it too.
2432        [$left, $top, $right, $bottom] = match ($this->motionCoordBox($box) ?? 'border-box') {
2433            'content-box' => [0.0, 0.0, 0.0, 0.0],
2434            'padding-box' => [
2435                $g->paddingLeft,
2436                $g->paddingTop,
2437                $g->paddingRight,
2438                $g->paddingBottom,
2439            ],
2440            'margin-box' => [
2441                $g->paddingLeft + $g->borderLeft + $g->marginLeft,
2442                $g->paddingTop + $g->borderTop + $g->marginTop,
2443                $g->paddingRight + $g->borderRight + $g->marginRight,
2444                $g->paddingBottom + $g->borderBottom + $g->marginBottom,
2445            ],
2446            default => [
2447                $g->paddingLeft + $g->borderLeft,
2448                $g->paddingTop + $g->borderTop,
2449                $g->paddingRight + $g->borderRight,
2450                $g->paddingBottom + $g->borderBottom,
2451            ],
2452        };
2453
2454        return [
2455            $g->x - $left,
2456            $g->y - $top,
2457            $g->width + $left + $right,
2458            $g->height + $top + $bottom,
2459        ];
2460    }
2461
2462    /**
2463     * The corner radii of the reference box, as a bare `<coord-box>`
2464     * offset-path inherits them.
2465     *
2466     * `border-radius` describes the BORDER box, so an inner coord box
2467     * shrinks each radius by the border and padding it sits inside
2468     * (CSS Backgrounds 3 Â§5.4), floored at zero.
2469     *
2470     * @return list<array{float, float}>
2471     */
2472    private function referenceBoxRadii(Box $source, string $coordBox): array
2473    {
2474        $g = $source->geometry;
2475        $inset = match ($coordBox) {
2476            'content-box' => [
2477                $g->borderLeft + $g->paddingLeft,
2478                $g->borderTop + $g->paddingTop,
2479                $g->borderRight + $g->paddingRight,
2480                $g->borderBottom + $g->paddingBottom,
2481            ],
2482            'padding-box' => [$g->borderLeft, $g->borderTop, $g->borderRight, $g->borderBottom],
2483            default => [0.0, 0.0, 0.0, 0.0],
2484        };
2485        [$left, $top, $right, $bottom] = $inset;
2486
2487        // Percentage radii always resolve against the BORDER box, whichever
2488        // coord box the path itself uses.
2489        [$borderWidth, $borderHeight] = $this->borderBoxExtent($source);
2490        $radii = $this->borderRadiiXY($source, $borderWidth, $borderHeight);
2491        return [
2492            [max(0.0, $radii[0][0] - $left), max(0.0, $radii[0][1] - $top)],
2493            [max(0.0, $radii[1][0] - $right), max(0.0, $radii[1][1] - $top)],
2494            [max(0.0, $radii[2][0] - $right), max(0.0, $radii[2][1] - $bottom)],
2495            [max(0.0, $radii[3][0] - $left), max(0.0, $radii[3][1] - $bottom)],
2496        ];
2497    }
2498
2499    /**
2500     * The `<coord-box>` named by this box's `offset-path`, if any.
2501     *
2502     * A `<coord-box>` may stand alone as the whole offset-path
2503     * (`offset-path: padding-box`), in which case it is both the reference
2504     * box and the path itself.
2505     */
2506    private function motionCoordBox(Box $box): ?string
2507    {
2508        $value = $box->style->get('offset-path');
2509        $components = $value instanceof \Phpdftk\Css\Value\ValueList
2510            ? $value->values
2511            : ($value !== null ? [$value] : []);
2512        foreach ($components as $component) {
2513            if (!($component instanceof Keyword)) {
2514                continue;
2515            }
2516            $name = strtolower($component->name);
2517            if (in_array($name, [
2518                'content-box',
2519                'padding-box',
2520                'border-box',
2521                'margin-box',
2522                'fill-box',
2523                'stroke-box',
2524                'view-box',
2525            ], true)) {
2526                return $name;
2527            }
2528        }
2529        return null;
2530    }
2531
2532    /**
2533     * Where the ray starts, in CSS page coordinates.
2534     *
2535     * `ray(... at <position>)` wins. Otherwise `offset-position: auto`
2536     * starts at the box's own border-box top-left, an explicit
2537     * `offset-position` resolves in the containing block, and `normal`
2538     * (the initial value) falls back to the containing block's centre.
2539     *
2540     * @return array{float, float}
2541     */
2542    private function rayOrigin(
2543        Box $box,
2544        \Phpdftk\Css\Value\Ray $ray,
2545        float $cbX,
2546        float $cbY,
2547        float $cbW,
2548        float $cbH,
2549    ): array {
2550        if ($ray->atX !== null && $ray->atY !== null) {
2551            return [
2552                $cbX + $this->resolvePositionComponent($ray->atX, $cbW, true),
2553                $cbY + $this->resolvePositionComponent($ray->atY, $cbH, false),
2554            ];
2555        }
2556        $position = $box->style->get('offset-position');
2557        if ($position instanceof Keyword && strtolower($position->name) === 'auto') {
2558            $g = $box->geometry;
2559            return [
2560                $g->x - $g->paddingLeft - $g->borderLeft,
2561                $g->y - $g->paddingTop - $g->borderTop,
2562            ];
2563        }
2564        if ($position instanceof \Phpdftk\Css\Value\ValueList
2565            && count($position->values) >= 2
2566        ) {
2567            return [
2568                $cbX + $this->resolvePositionComponent($position->values[0], $cbW, true),
2569                $cbY + $this->resolvePositionComponent($position->values[1], $cbH, false),
2570            ];
2571        }
2572        return [$cbX + $cbW / 2.0, $cbY + $cbH / 2.0];
2573    }
2574
2575    /**
2576     * CSS Motion Path 1 Â§2.2 â€” the ray's length.
2577     *
2578     * Only `sides` follows the angle, stopping where the ray leaves the
2579     * containing block. The other four ignore the angle entirely and
2580     * measure to a side or a corner.
2581     */
2582    private function rayLength(
2583        Box $box,
2584        \Phpdftk\Css\Value\Ray $ray,
2585        float $degrees,
2586        float $sx,
2587        float $sy,
2588        float $cbX,
2589        float $cbY,
2590        float $cbW,
2591        float $cbH,
2592    ): float {
2593        $left = $cbX;
2594        $right = $cbX + $cbW;
2595        $top = $cbY;
2596        $bottom = $cbY + $cbH;
2597        $sides = [abs($sx - $left), abs($right - $sx), abs($sy - $top), abs($bottom - $sy)];
2598        $corners = [];
2599        foreach ([$left, $right] as $cx) {
2600            foreach ([$top, $bottom] as $cy) {
2601                $corners[] = hypot($sx - $cx, $sy - $cy);
2602            }
2603        }
2604
2605        $length = match ($ray->size) {
2606            \Phpdftk\Css\Value\RaySize::ClosestSide => min($sides),
2607            \Phpdftk\Css\Value\RaySize::FarthestSide => max($sides),
2608            \Phpdftk\Css\Value\RaySize::ClosestCorner => min($corners),
2609            \Phpdftk\Css\Value\RaySize::FarthestCorner => max($corners),
2610            \Phpdftk\Css\Value\RaySize::Sides => $this->raySidesLength(
2611                $degrees,
2612                $sx,
2613                $sy,
2614                $left,
2615                $top,
2616                $right,
2617                $bottom,
2618            ),
2619        };
2620
2621        // CSS Motion Path 1 Â§2.2 â€” `contain` shortens the ray so the
2622        // element still fits inside its containing block. The reduction is
2623        // half the box's LARGER side and does not depend on the ray's
2624        // direction, so a wide box is held back as far when travelling
2625        // vertically as horizontally.
2626        if ($ray->contain) {
2627            [$width, $height] = $this->borderBoxExtent($box);
2628            $length = max(0.0, $length - max($width, $height) / 2.0);
2629        }
2630        return $length;
2631    }
2632
2633    /**
2634     * The box's border-box width and height.
2635     *
2636     * @return array{float, float}
2637     */
2638    private function borderBoxExtent(Box $box): array
2639    {
2640        $g = $box->geometry;
2641        return [
2642            $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight,
2643            $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom,
2644        ];
2645    }
2646
2647    /**
2648     * `sides`: the distance from the origin to where the ray crosses the
2649     * containing block's boundary. An origin outside the box gives zero â€”
2650     * the ray must not reach back in.
2651     */
2652    private function raySidesLength(
2653        float $degrees,
2654        float $sx,
2655        float $sy,
2656        float $left,
2657        float $top,
2658        float $right,
2659        float $bottom,
2660    ): float {
2661        if ($sx < $left || $sx > $right || $sy < $top || $sy > $bottom) {
2662            return 0.0;
2663        }
2664        $theta = deg2rad($degrees);
2665        $ux = sin($theta);
2666        $uy = -cos($theta);
2667        $best = null;
2668        // Distance to each of the four edge lines, keeping the nearest
2669        // one the ray actually travels toward.
2670        foreach ([[$ux, $left - $sx], [$ux, $right - $sx]] as [$component, $delta]) {
2671            if (abs($component) > 1e-9) {
2672                $t = $delta / $component;
2673                if ($t >= 0.0) {
2674                    $best = $best === null ? $t : min($best, $t);
2675                }
2676            }
2677        }
2678        foreach ([[$uy, $top - $sy], [$uy, $bottom - $sy]] as [$component, $delta]) {
2679            if (abs($component) > 1e-9) {
2680                $t = $delta / $component;
2681                if ($t >= 0.0) {
2682                    $best = $best === null ? $t : min($best, $t);
2683                }
2684            }
2685        }
2686        return $best ?? 0.0;
2687    }
2688
2689    /**
2690     * `offset-distance`. A percentage is of the path's length; absolute
2691     * lengths are NOT clamped to the path (only a `contain` ray shortens).
2692     */
2693    private function resolveMotionDistance(Box $box, float $length): float
2694    {
2695        $value = $box->style->get('offset-distance');
2696        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2697            return $length * $value->value / 100.0;
2698        }
2699        if ($value instanceof \Phpdftk\Css\Value\Length) {
2700            return $value->value;
2701        }
2702        // `calc(12.5% + 200px)` mixes both, so percentages resolve against
2703        // the path length rather than being dropped.
2704        if ($value instanceof \Phpdftk\Css\Value\Calc) {
2705            $resolved = \Phpdftk\Css\Cascade\CalcEvaluator::evaluate(
2706                $value,
2707                new \Phpdftk\Css\Cascade\LengthContext(percentageBasis: $length),
2708            );
2709            return is_finite($resolved) ? $resolved : 0.0;
2710        }
2711        return 0.0;
2712    }
2713
2714    /**
2715     * `offset-rotate` â€” initial value `auto`, which follows the path
2716     * tangent. `reverse` faces the other way; a bare angle ignores the
2717     * tangent; `auto <angle>` / `reverse <angle>` add to it.
2718     */
2719    private function resolveMotionRotation(Box $box, float $tangent): float
2720    {
2721        $value = $box->style->get('offset-rotate');
2722        $components = $value instanceof \Phpdftk\Css\Value\ValueList
2723            ? $value->values
2724            : ($value !== null ? [$value] : []);
2725
2726        $base = $tangent;
2727        $extra = 0.0;
2728        $sawKeyword = false;
2729        foreach ($components as $component) {
2730            if ($component instanceof Keyword) {
2731                $name = strtolower($component->name);
2732                if ($name === 'auto') {
2733                    $base = $tangent;
2734                    $sawKeyword = true;
2735                } elseif ($name === 'reverse') {
2736                    $base = $tangent + 180.0;
2737                    $sawKeyword = true;
2738                }
2739                continue;
2740            }
2741            if ($component instanceof \Phpdftk\Css\Value\Angle) {
2742                $extra = $component->toDegrees();
2743            }
2744        }
2745        // A bare `<angle>` replaces the tangent rather than adding to it.
2746        if (!$sawKeyword && $components !== []) {
2747            $hasAngle = false;
2748            foreach ($components as $component) {
2749                if ($component instanceof \Phpdftk\Css\Value\Angle) {
2750                    $hasAngle = true;
2751                    break;
2752                }
2753            }
2754            if ($hasAngle) {
2755                return $extra;
2756            }
2757        }
2758        return $base + $extra;
2759    }
2760
2761    /**
2762     * `transform-origin` in CSS page coordinates (Y down), as the motion
2763     * math needs it. {@see resolveTransformOrigin} returns the same point
2764     * already flipped into PDF space.
2765     *
2766     * @return array{float, float}
2767     */
2768    private function transformOriginCss(Box $box): array
2769    {
2770        $g = $box->geometry;
2771        $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight;
2772        $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom;
2773        $boxX = $g->x - $g->paddingLeft - $g->borderLeft;
2774        $boxY = $g->y - $g->paddingTop - $g->borderTop;
2775        $value = $box->style->get('transform-origin');
2776        $values = $value instanceof \Phpdftk\Css\Value\ValueList
2777            ? $value->values
2778            : ($value !== null ? [$value] : []);
2779        [$offX, $offY] = $this->resolveTransformOriginOffsets($values, $width, $height);
2780        return [$boxX + $offX, $boxY + $offY];
2781    }
2782
2783    /**
2784     * `offset-anchor` in CSS page coordinates. `auto` â€” the initial value
2785     * â€” means the computed `transform-origin`, NOT `offset-position`.
2786     *
2787     * @return array{float, float}
2788     */
2789    private function motionAnchorCss(Box $box, float $originX, float $originY): array
2790    {
2791        $value = $box->style->get('offset-anchor');
2792        if (!($value instanceof \Phpdftk\Css\Value\ValueList) || count($value->values) < 2) {
2793            return [$originX, $originY];
2794        }
2795        $g = $box->geometry;
2796        $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight;
2797        $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom;
2798        $boxX = $g->x - $g->paddingLeft - $g->borderLeft;
2799        $boxY = $g->y - $g->paddingTop - $g->borderTop;
2800        return [
2801            $boxX + $this->resolvePositionComponent($value->values[0], $width, true),
2802            $boxY + $this->resolvePositionComponent($value->values[1], $height, false),
2803        ];
2804    }
2805
2806    /**
2807     * One `<position>` component against an extent. `$horizontal` selects
2808     * which edge keywords apply.
2809     */
2810    private function resolvePositionComponent(
2811        \Phpdftk\Css\Value\Value $value,
2812        float $extent,
2813        bool $horizontal,
2814    ): float {
2815        if ($value instanceof \Phpdftk\Css\Value\Length) {
2816            return $value->value;
2817        }
2818        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2819            return $extent * $value->value / 100.0;
2820        }
2821        if ($value instanceof Keyword) {
2822            return match (strtolower($value->name)) {
2823                'left', 'top' => 0.0,
2824                'right', 'bottom' => $extent,
2825                default => $extent / 2.0,
2826            };
2827        }
2828        // The three/four-value `<position>` form folds an offset from a far
2829        // edge into `calc(100% - <length>)`.
2830        if ($value instanceof \Phpdftk\Css\Value\Calc) {
2831            $resolved = \Phpdftk\Css\Cascade\CalcEvaluator::evaluate(
2832                $value,
2833                new \Phpdftk\Css\Cascade\LengthContext(percentageBasis: $extent),
2834            );
2835            return is_finite($resolved) ? $resolved : 0.0;
2836        }
2837        return 0.0;
2838    }
2839
2840    /**
2841     * Compose the box's transform-function list into a single 2D
2842     * matrix [a, b, c, d, e, f] in PDF coordinate space (Y-up).
2843     * Returns `null` if no function produced output (3D-only
2844     * transforms flatten to identity at Phase 2).
2845     *
2846     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
2847     */
2848    private function composeTransformMatrix(\Phpdftk\Css\Value\Transform $transform, Box $box): ?array
2849    {
2850        $result = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // identity
2851        $any = false;
2852        foreach ($transform->functions as $fn) {
2853            $m = $this->transformFunctionToPdfMatrix($fn, $box);
2854            if ($m === null) {
2855                continue;
2856            }
2857            $result = $this->multiplyMatrices($result, $m);
2858            $any = true;
2859        }
2860        return $any ? $result : null;
2861    }
2862
2863    /**
2864     * CSS Transforms 2 Â§5 â€” the transform functions contributed by the
2865     * individual `translate` / `rotate` / `scale` properties, in that
2866     * order (they compose before the `transform` list). Each reads its
2867     * computed value directly; `none` / absent / malformed â†’ skipped.
2868     *
2869     * @return list<\Phpdftk\Css\Value\TransformFunction>
2870     */
2871    private function individualTransformFunctions(Box $box): array
2872    {
2873        $out = [];
2874        $t = $this->individualTranslate($box->style->get('translate'));
2875        if ($t !== null) {
2876            $out[] = $t;
2877        }
2878        $r = $this->individualRotate($box->style->get('rotate'));
2879        if ($r !== null) {
2880            $out[] = $r;
2881        }
2882        $s = $this->individualScale($box->style->get('scale'));
2883        if ($s !== null) {
2884            $out[] = $s;
2885        }
2886        return $out;
2887    }
2888
2889    /**
2890     * Unwrap a transform property's computed value into its component
2891     * list. `none` / null â†’ null (skip); a space-separated ValueList â†’
2892     * its values; a single value â†’ a one-element list.
2893     *
2894     * @return list<\Phpdftk\Css\Value\Value>|null
2895     */
2896    private function transformPropertyComponents(?\Phpdftk\Css\Value\Value $value): ?array
2897    {
2898        if ($value === null) {
2899            return null;
2900        }
2901        if ($value instanceof Keyword && strtolower($value->name) === 'none') {
2902            return null;
2903        }
2904        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
2905            return $value->values;
2906        }
2907        return [$value];
2908    }
2909
2910    private function individualTranslate(?\Phpdftk\Css\Value\Value $value): ?\Phpdftk\Css\Value\TranslateTransform
2911    {
2912        $c = $this->transformPropertyComponents($value);
2913        if ($c === null || $c === []) {
2914            return null;
2915        }
2916        $x = $c[0];
2917        if (!($x instanceof \Phpdftk\Css\Value\Length || $x instanceof \Phpdftk\Css\Value\Percentage)) {
2918            return null;
2919        }
2920        $y = $c[1] ?? new \Phpdftk\Css\Value\Length(0.0, \Phpdftk\Css\Value\LengthUnit::Px);
2921        if (!($y instanceof \Phpdftk\Css\Value\Length || $y instanceof \Phpdftk\Css\Value\Percentage)) {
2922            return null;
2923        }
2924        $z = ($c[2] ?? null) instanceof \Phpdftk\Css\Value\Length ? $c[2] : null;
2925        return new \Phpdftk\Css\Value\TranslateTransform($x, $y, $z);
2926    }
2927
2928    private function individualScale(?\Phpdftk\Css\Value\Value $value): ?\Phpdftk\Css\Value\ScaleTransform
2929    {
2930        $c = $this->transformPropertyComponents($value);
2931        if ($c === null || $c === []) {
2932            return null;
2933        }
2934        $sx = $this->transformNumber($c[0]);
2935        if ($sx === null) {
2936            return null;
2937        }
2938        $sy = isset($c[1]) ? $this->transformNumber($c[1]) : $sx;
2939        if ($sy === null) {
2940            return null;
2941        }
2942        $sz = isset($c[2]) ? $this->transformNumber($c[2]) : null;
2943        return new \Phpdftk\Css\Value\ScaleTransform($sx, $sy, $sz);
2944    }
2945
2946    private function individualRotate(?\Phpdftk\Css\Value\Value $value): ?\Phpdftk\Css\Value\RotateTransform
2947    {
2948        $c = $this->transformPropertyComponents($value);
2949        if ($c === null || $c === []) {
2950            return null;
2951        }
2952        $ax = 0.0;
2953        $ay = 0.0;
2954        $az = 1.0;
2955        $angle = null;
2956        if (count($c) === 1) {
2957            // `rotate: <angle>` â†’ 2D rotation about Z.
2958            $angle = $c[0];
2959        } elseif (count($c) === 2 && $c[0] instanceof Keyword) {
2960            // `rotate: [x|y|z] <angle>`.
2961            switch (strtolower($c[0]->name)) {
2962                case 'x': $ax = 1.0;
2963                    $ay = 0.0;
2964                    $az = 0.0;
2965                    break;
2966                case 'y': $ax = 0.0;
2967                    $ay = 1.0;
2968                    $az = 0.0;
2969                    break;
2970                case 'z': $ax = 0.0;
2971                    $ay = 0.0;
2972                    $az = 1.0;
2973                    break;
2974                default: return null;
2975            }
2976            $angle = $c[1];
2977        } elseif (count($c) === 4) {
2978            // `rotate: <number>{3} <angle>` (axis vector + angle).
2979            $ax = $this->transformNumber($c[0]);
2980            $ay = $this->transformNumber($c[1]);
2981            $az = $this->transformNumber($c[2]);
2982            if ($ax === null || $ay === null || $az === null) {
2983                return null;
2984            }
2985            $angle = $c[3];
2986        } else {
2987            return null;
2988        }
2989        if (!$angle instanceof \Phpdftk\Css\Value\Angle) {
2990            return null;
2991        }
2992        return new \Phpdftk\Css\Value\RotateTransform($angle->toDegrees(), $ax, $ay, $az);
2993    }
2994
2995    private function transformNumber(\Phpdftk\Css\Value\Value $value): ?float
2996    {
2997        if ($value instanceof \Phpdftk\Css\Value\Number) {
2998            return $value->value;
2999        }
3000        if ($value instanceof \Phpdftk\Css\Value\Integer) {
3001            return (float) $value->value;
3002        }
3003        return null;
3004    }
3005
3006    /**
3007     * Convert a single CSS transform-function to a 2D PDF matrix
3008     * (Y-up). The conversion negates the (b, c, f) entries â€” that's
3009     * the matrix-form of conjugating by a Y-axis flip, which maps
3010     * CSS's Y-down coords to PDF's Y-up.
3011     *
3012     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
3013     */
3014    private function transformFunctionToPdfMatrix(
3015        \Phpdftk\Css\Value\TransformFunction $fn,
3016        Box $box,
3017    ): ?array {
3018        if ($fn instanceof \Phpdftk\Css\Value\TranslateTransform) {
3019            $tx = $this->lengthOrPercentageToFloat($fn->x, $box->geometry->width);
3020            $ty = $this->lengthOrPercentageToFloat($fn->y, $box->geometry->height);
3021            return [1.0, 0.0, 0.0, 1.0, $tx, -$ty];
3022        }
3023        if ($fn instanceof \Phpdftk\Css\Value\RotateTransform) {
3024            // CSS Transforms 2 Â§13 â€” rotateX / rotateY collapse onto
3025            // a 2D plane in print. Approximation: rotateX(θ) scales
3026            // the box vertically by |cos(θ)| (mirroring past 90°),
3027            // rotateY(θ) scales horizontally. Visually correct at
3028            // canonical angles (0° = unchanged, 90° = edge-on,
3029            // 180° = mirrored). Z rotation keeps the 2D rotation
3030            // matrix.
3031            $rad = deg2rad($fn->angleDeg);
3032            $cos = cos($rad);
3033            $sin = sin($rad);
3034            $axisLen = sqrt($fn->ax * $fn->ax + $fn->ay * $fn->ay + $fn->az * $fn->az);
3035            if ($axisLen <= 0.0) {
3036                return null;
3037            }
3038            $nx = $fn->ax / $axisLen;
3039            $ny = $fn->ay / $axisLen;
3040            $nz = $fn->az / $axisLen;
3041            // Decompose rotate3d into axis projections. For an axis
3042            // that's purely Z (nx=ny=0, nz=±1), keep the planar
3043            // rotation; for X/Y components, flatten via cos-scaling.
3044            if (abs($nz) > 0.9999) {
3045                // Z rotation (or close to it).
3046                $sign = $nz > 0 ? 1.0 : -1.0;
3047                return [$cos, -$sign * $sin, $sign * $sin, $cos, 0.0, 0.0];
3048            }
3049            // X / Y rotation (or a tilt): use cos-flatten. For mixed
3050            // axes, weight by the axis components. Pure rotateX
3051            // (nx=1) â†’ sy = cos, sx = 1. Pure rotateY (ny=1) â†’ sx
3052            // = cos, sy = 1.
3053            $sy = 1.0 - abs($nx) + abs($nx) * $cos;
3054            $sx = 1.0 - abs($ny) + abs($ny) * $cos;
3055            return [$sx, 0.0, 0.0, $sy, 0.0, 0.0];
3056        }
3057        if ($fn instanceof \Phpdftk\Css\Value\ScaleTransform) {
3058            return [$fn->sx, 0.0, 0.0, $fn->sy, 0.0, 0.0];
3059        }
3060        if ($fn instanceof \Phpdftk\Css\Value\SkewTransform) {
3061            $tanX = tan(deg2rad($fn->xDeg));
3062            $tanY = tan(deg2rad($fn->yDeg));
3063            // CSS skewX: [1, 0, tan(x), 1]; PDF flips b+c â†’ [1, 0, -tan(x), 1]
3064            // CSS skewY: [1, tan(y), 0, 1]; PDF flips â†’ [1, -tan(y), 0, 1]
3065            return [1.0, -$tanY, -$tanX, 1.0, 0.0, 0.0];
3066        }
3067        if ($fn instanceof \Phpdftk\Css\Value\MatrixTransform) {
3068            return [$fn->a, -$fn->b, -$fn->c, $fn->d, $fn->e, -$fn->f];
3069        }
3070        return null;
3071    }
3072
3073    /**
3074     * Multiply two 2D affine matrices in [a, b, c, d, e, f] form:
3075     *   M = [a c e]    M' = [a' c' e']    M Ã— M' = [...]
3076     *       [b d f]         [b' d' f']
3077     *       [0 0 1]         [0  0  1]
3078     *
3079     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m1
3080     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m2
3081     * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
3082     */
3083    private function multiplyMatrices(array $m1, array $m2): array
3084    {
3085        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
3086        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
3087        return [
3088            $a1 * $a2 + $c1 * $b2,
3089            $b1 * $a2 + $d1 * $b2,
3090            $a1 * $c2 + $c1 * $d2,
3091            $b1 * $c2 + $d1 * $d2,
3092            $a1 * $e2 + $c1 * $f2 + $e1,
3093            $b1 * $e2 + $d1 * $f2 + $f1,
3094        ];
3095    }
3096
3097    /**
3098     * Resolve `transform-origin` to a PDF coordinate point. Default
3099     * `50% 50%` puts the pivot at the box's centre. Lengths and
3100     * percentages compose; percentages resolve against the box's
3101     * border-box dimension on each axis.
3102     *
3103     * @return array{0: float, 1: float} (px, py) in PDF coords.
3104     */
3105    private function resolveTransformOrigin(Box $box): array
3106    {
3107        $g = $box->geometry;
3108        $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight;
3109        $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom;
3110        $boxX = $g->x - $g->paddingLeft - $g->borderLeft;
3111        $boxY = $g->y - $g->paddingTop - $g->borderTop;
3112
3113        $value = $box->style->get('transform-origin');
3114        $values = $value instanceof \Phpdftk\Css\Value\ValueList
3115            ? $value->values
3116            : ($value !== null ? [$value] : []);
3117        [$offX, $offY] = $this->resolveTransformOriginOffsets($values, $width, $height);
3118        $cssY = $boxY + $offY;
3119        return [$boxX + $offX, $this->pageHeight - $cssY];
3120    }
3121
3122    /**
3123     * Resolve `transform-origin`'s 1-3 components to (x, y) offsets. Position
3124     * keywords bind by AXIS IDENTITY, not order (CSS Transforms 1 Â§6 / the
3125     * <position> grammar): `left`/`right` set X, `top`/`bottom` set Y in any
3126     * order; `center`, `<length>` and `<percentage>` fill the remaining axes
3127     * positionally (X first). A single keyword leaves the other axis at center.
3128     *
3129     * @param list<\Phpdftk\Css\Value\Value> $values
3130     * @return array{float, float}
3131     */
3132    private function resolveTransformOriginOffsets(array $values, float $width, float $height): array
3133    {
3134        $offX = null;
3135        $offY = null;
3136        $positional = [];
3137        foreach ($values as $v) {
3138            if ($v instanceof \Phpdftk\Css\Value\Keyword) {
3139                switch (strtolower($v->name)) {
3140                    case 'left':   $offX = 0.0;
3141                        continue 2;
3142                    case 'right':  $offX = $width;
3143                        continue 2;
3144                    case 'top':    $offY = 0.0;
3145                        continue 2;
3146                    case 'bottom': $offY = $height;
3147                        continue 2;
3148                        // 'center' falls through to positional assignment.
3149                }
3150            }
3151            $positional[] = $v;
3152        }
3153        foreach ($positional as $v) {
3154            if ($offX === null) {
3155                $offX = $this->resolveOriginComponent($v, $width, $width / 2.0);
3156            } elseif ($offY === null) {
3157                $offY = $this->resolveOriginComponent($v, $height, $height / 2.0);
3158            }
3159        }
3160        return [$offX ?? $width / 2.0, $offY ?? $height / 2.0];
3161    }
3162
3163    private function resolveOriginComponent(
3164        \Phpdftk\Css\Value\Value $value,
3165        float $extent,
3166        float $fallback,
3167    ): float {
3168        if ($value instanceof \Phpdftk\Css\Value\Length) {
3169            return $value->value;
3170        }
3171        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
3172            return $value->value / 100.0 * $extent;
3173        }
3174        // CSS Values 4 Â§5.2 â€” a unitless `0` is equivalent to `0px` in
3175        // any length context. The generic stylesheet parser stores it as
3176        // `Integer` / `Number`, which the cascade keeps unchanged for
3177        // properties (like `transform-origin`) that don't have a
3178        // dedicated typed parser. Treat both shapes as a px length so
3179        // `transform-origin: 0 0` doesn't silently fall back to the 50%
3180        // default.
3181        if ($value instanceof \Phpdftk\Css\Value\Integer
3182            || $value instanceof \Phpdftk\Css\Value\Number
3183        ) {
3184            return (float) $value->value;
3185        }
3186        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
3187            return match (strtolower($value->name)) {
3188                'left', 'top' => 0.0,
3189                'right', 'bottom' => $extent,
3190                'center' => $extent / 2.0,
3191                default => $fallback,
3192            };
3193        }
3194        return $fallback;
3195    }
3196
3197    private function lengthOrPercentageToFloat(
3198        \Phpdftk\Css\Value\Length|\Phpdftk\Css\Value\Percentage $value,
3199        float $basis,
3200    ): float {
3201        if ($value instanceof \Phpdftk\Css\Value\Length) {
3202            return $value->value;
3203        }
3204        return $value->value / 100.0 * $basis;
3205    }
3206
3207    /**
3208     * `true` when this box (a) declares `box-decoration-break: clone`
3209     * AND (b) actually straddles the current page boundary. Boxes that
3210     * fit entirely on one page don't need the clamp â€” slice and clone
3211     * paint identically in that case.
3212     */
3213    private function shouldClampDecorationsToPage(Box $box): bool
3214    {
3215        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
3216            return false;
3217        }
3218        if (!$this->isCloneDecorationBreak($box)) {
3219            return false;
3220        }
3221        $g = $box->geometry;
3222        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
3223        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
3224        return $outerTop < $this->pageRangeStart || $outerBottom > $this->pageRangeEnd;
3225    }
3226
3227    private function isCloneDecorationBreak(Box $box): bool
3228    {
3229        $value = $box->style->get('box-decoration-break');
3230        if (!($value instanceof Keyword)) {
3231            return false;
3232        }
3233        return strtolower($value->name) === 'clone';
3234    }
3235
3236    /**
3237     * Return a clone of `$g` with content y / height clamped so the
3238     * box's outer margin-box sits entirely inside the painter's
3239     * current page range. Used by `box-decoration-break: clone` to
3240     * make each fragment paint full borders at its visible extent.
3241     */
3242    private function clampGeometryToPage(BoxGeometry $g): BoxGeometry
3243    {
3244        $clone = clone $g;
3245        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
3246            return $clone;
3247        }
3248        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
3249        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
3250        if ($outerTop < $this->pageRangeStart) {
3251            $delta = $this->pageRangeStart - $outerTop;
3252            $clone->y += $delta;
3253            $clone->height = max(0.0, $clone->height - $delta);
3254        }
3255        if ($outerBottom > $this->pageRangeEnd) {
3256            $delta = $outerBottom - $this->pageRangeEnd;
3257            $clone->height = max(0.0, $clone->height - $delta);
3258        }
3259        return $clone;
3260    }
3261
3262    /**
3263     * Phase-1 `<img src="data:image/...">` painter: decodes the data URL,
3264     * spills the bytes to a tempfile, registers an Image XObject on the
3265     * current page via the writer, and emits `q cm /Name Do Q` at the
3266     * box's geometry. No-op when the writer or page is not wired in, or
3267     * when the src isn't a `data:image/png|jpeg` URL we can paint.
3268     */
3269    private function paintImage(Box $box, ContentStream $stream, ?Box $parent = null): void
3270    {
3271        // Replaced elements render whether they are atomic-inline
3272        // (`display: inline-block`) OR blockified into a BlockBox. An
3273        // `<img>` / `<embed>` / `<object>` that is FLOATED, `display:
3274        // block`, OR out-of-flow (`position: absolute` / `fixed`, which
3275        // CSS Display Â§2.7 blockifies) becomes a BlockBox, but its
3276        // geometry is still laid out by the normal block-flow path â€”
3277        // including the CSS Sizing Â§4.2 aspect-ratio transfer that sizes a
3278        // `width: 50%; height: auto` abspos image â€” so it paints at the
3279        // right rect. Accept every such BlockBox replaced element.
3280        //
3281        // Two geometry paths are still wrong, so gate them out:
3282        //  - a GRID ITEM replaced BlockBox reaches paint with grid-track
3283        //    geometry not wired for direct raster placement (painting it
3284        //    regressed css-grid âˆ’6); detect via the parent box type. A FLEX
3285        //    ITEM, by contrast, is blockified (CSS Display Â§2.7) and its
3286        //    main/cross size + Â§4.5 auto-minimum are resolved by the normal
3287        //    block-flow measure, so its geometry IS correct â€” paint it;
3288        //  - a VERTICAL-writing-mode replaced BlockBox (float/block WM
3289        //    positioning is still wrong â€” see css-writing-modes follow-up).
3290        $parentIsGrid = $parent instanceof \Phpdftk\HtmlToPdf\Box\GridBox;
3291        $isBlockReplaced = $box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox
3292            && !$parentIsGrid
3293            && !WritingMode::fromStyle($box->style)->isVertical();
3294        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)
3295            && !$isBlockReplaced
3296        ) {
3297            return;
3298        }
3299        if ($this->writer === null || $this->page === null) {
3300            return;
3301        }
3302        $element = $box->element;
3303        if ($element === null) {
3304            return;
3305        }
3306        // Inline foreign content (`<svg>` / `<math>`): the parser
3307        // tagged the subtree with its namespace, but our
3308        // AtomicInlineBox path historically only knew about `<img>`.
3309        // Detect each foreign namespace and route to the dedicated
3310        // painter before the img-src lookup.
3311        $foreignKind = \Phpdftk\HtmlToPdf\Box\BoxGenerator::foreignContentKind($element);
3312        if ($foreignKind === 'svg') {
3313            // Only the OUTERMOST `<svg>` is painted from here: an inner
3314            // one is a nested viewport the SVG pipeline already drew
3315            // while painting its ancestor (SVG 2 Â§7.5). Painting it
3316            // again emitted a second, unclipped copy at the HTML
3317            // pipeline's scale.
3318            if (!\Phpdftk\HtmlToPdf\Box\BoxGenerator::isOutermostSvg($element)) {
3319                return;
3320            }
3321            $this->paintInlineSvg($element, $box, $stream);
3322            return;
3323        }
3324        if ($foreignKind === 'math') {
3325            $this->paintInlineMath($element, $box, $stream);
3326            return;
3327        }
3328        // `<img src>`, `<embed src>`, `<object data>` and a `<video>`'s
3329        // `poster` frame all render an external image resource through the
3330        // same SVG / raster paint path (object-fit / object-position aware).
3331        $tag = strtolower($element->localName);
3332        if ($tag !== 'img' && $tag !== 'embed' && $tag !== 'object' && $tag !== 'video') {
3333            return;
3334        }
3335        $src = $element->getAttribute(match ($tag) {
3336            'object' => 'data',
3337            'video' => 'poster',
3338            default => 'src',
3339        });
3340        if ($src === null) {
3341            return;
3342        }
3343        // `<img src="*.svg">` and `<img src="data:image/svg+xml,...">`
3344        // route through the SVG painter rather than the raster image
3345        // XObject path â€” SVG isn't a pixel container, so PdfWriter's
3346        // addImage rejects it. The painter has the loader / sizer /
3347        // renderer already used by background-image:url(svg); reuse
3348        // it here so an inline replaced `<img>` honours its CSS
3349        // geometry plus `object-fit` / `object-position`.
3350        if ($this->isSvgSrc($src)) {
3351            $this->paintImgSvg($element, $box, $stream, $src);
3352            return;
3353        }
3354        // Per-page cache: the same src only spills + registers once on
3355        // this page. Multi-page reuse still re-registers â€” XObject
3356        // resource names are page-local in PdfWriter.
3357        if (isset($this->imageNameCache[$src])) {
3358            $name = $this->imageNameCache[$src];
3359        } else {
3360            $resolvedPath = $this->resolveImageSrc($src);
3361            if ($resolvedPath === null) {
3362                return;
3363            }
3364            // ImageParser throws on malformed bytes; swallow + fall back to
3365            // the alt-text path (or empty box) rather than crashing the whole
3366            // render because of one bad asset.
3367            try {
3368                $name = $this->writer->addImage($resolvedPath, $this->page);
3369            } catch (\Throwable) {
3370                return;
3371            }
3372            $this->imageNameCache[$src] = $name;
3373        }
3374        $geo = $box->geometry;
3375        if ($geo->width <= 0.0) {
3376            // No declared size â€” skip; the alt-text fallback path covers
3377            // unsized images via the BoxGenerator's InlineBox lowering.
3378            return;
3379        }
3380        $height = $geo->height > 0.0 ? $geo->height : $geo->width;
3381        // CSS Images 3 Â§5: `object-fit` controls the image's scale
3382        // within its declared content rect. `fill` (default) stretches;
3383        // `contain` / `cover` / `none` / `scale-down` preserve aspect.
3384        // `object-position` selects which part of the box the image
3385        // anchors to when there's slack â€” defaults to centre.
3386        $fit = $this->objectFitKeyword($box);
3387        $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height);
3388        $positionValue = $box->style->get('object-position');
3389        if ($positionValue !== null
3390            && ($rect['w'] !== $geo->width || $rect['h'] !== $height)
3391        ) {
3392            $pos = $this->resolveBackgroundPosition(
3393                $positionValue,
3394                $rect['w'],
3395                $rect['h'],
3396                $geo->width,
3397                $height,
3398            );
3399            $rect['offsetX'] = $pos['offsetX'];
3400            $rect['offsetY'] = $pos['offsetY'];
3401        }
3402        // PDF y-axis is inverted; the `cm` matrix maps the unit square
3403        // [0,1]^2 to the box's PDF-space rect.
3404        $pdfY = $this->pageHeight - $geo->y - $height;
3405        $stream->saveGraphicsState();
3406        // Clip to the box rect so `cover` overflow doesn't bleed into
3407        // sibling boxes.
3408        $stream->rectangle($geo->x, $pdfY, $geo->width, $height);
3409        $stream->clip();
3410        $stream->endPath();
3411        $stream->concatMatrix(
3412            $rect['w'],
3413            0.0,
3414            0.0,
3415            $rect['h'],
3416            $geo->x + $rect['offsetX'],
3417            $pdfY + ($height - $rect['h'] - $rect['offsetY']),
3418        );
3419        $stream->doXObject($name);
3420        $stream->restoreGraphicsState();
3421    }
3422
3423    /**
3424     * Read the box's cascaded `object-fit` value, normalised to one of
3425     * `fill` / `contain` / `cover` / `none` / `scale-down`. Unknown
3426     * keywords fall back to `fill`.
3427     */
3428    private function objectFitKeyword(Box $box): string
3429    {
3430        $value = $box->style->get('object-fit');
3431        if ($value instanceof Keyword) {
3432            $kw = strtolower($value->name);
3433            if (in_array($kw, ['fill', 'contain', 'cover', 'none', 'scale-down'], true)) {
3434                return $kw;
3435            }
3436        }
3437        return 'fill';
3438    }
3439
3440    /**
3441     * Compute the painted rect for a replaced element under `object-fit`.
3442     * Mirrors CSS Images 3 Â§5 semantics:
3443     *   - `fill` â†’ stretch to the box.
3444     *   - `contain` â†’ preserve aspect, fit inside; centred slack.
3445     *   - `cover` â†’ preserve aspect, fill; clipped overflow.
3446     *   - `none` â†’ natural size; centred slack.
3447     *   - `scale-down` â†’ min(`none`, `contain`) â€” uses natural size when
3448     *     the image already fits, otherwise behaves like `contain`.
3449     *
3450     * @return array{w: float, h: float, offsetX: float, offsetY: float}
3451     */
3452    private function resolveObjectFit(
3453        string $fit,
3454        string $src,
3455        float $boxWidth,
3456        float $boxHeight,
3457    ): array {
3458        if ($fit === 'fill') {
3459            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
3460        }
3461        $intrinsic = $this->intrinsicSize($src);
3462        if ($intrinsic === null) {
3463            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
3464        }
3465        [$natW, $natH] = $intrinsic;
3466        if ($fit === 'none') {
3467            return [
3468                'w' => (float) $natW,
3469                'h' => (float) $natH,
3470                'offsetX' => ($boxWidth - $natW) / 2,
3471                'offsetY' => ($boxHeight - $natH) / 2,
3472            ];
3473        }
3474        $scaleW = $boxWidth / $natW;
3475        $scaleH = $boxHeight / $natH;
3476        if ($fit === 'scale-down') {
3477            // Use 1.0 (natural) when it already fits; else contain.
3478            $scale = min(1.0, $scaleW, $scaleH);
3479        } elseif ($fit === 'cover') {
3480            $scale = max($scaleW, $scaleH);
3481        } else {
3482            // contain
3483            $scale = min($scaleW, $scaleH);
3484        }
3485        $finalW = $natW * $scale;
3486        $finalH = $natH * $scale;
3487        return [
3488            'w' => $finalW,
3489            'h' => $finalH,
3490            'offsetX' => ($boxWidth - $finalW) / 2,
3491            'offsetY' => ($boxHeight - $finalH) / 2,
3492        ];
3493    }
3494
3495    /**
3496     * Resolve an `<img src>` value to a real path that
3497     * {@see PdfWriter::addImage} can read. Handles:
3498     *   - `data:image/{png,jpeg}[;base64],...` â†’ spilled tempfile
3499     *   - `http(s)://...` â†’ fetched via `phpdftk/resource-loader`
3500     *     (4F.5) when a loader was supplied; spilled to a tempfile.
3501     *     Without a loader, http(s) silently drops the image.
3502     *   - relative paths â†’ joined with `baseDir`, must resolve under it
3503     *
3504     * Returns null when the source isn't a Phase-1 supported variant or
3505     * when path resolution escapes `baseDir`.
3506     */
3507    private function resolveImageSrc(string $src): ?string
3508    {
3509        if (str_starts_with($src, 'data:')) {
3510            return $this->materializeDataUrl($src);
3511        }
3512        if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) {
3513            return $this->fetchHttpSrc($src);
3514        }
3515        return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir, $this->sandboxRoot))
3516            ->resolveLocalPath($src);
3517    }
3518
3519    /**
3520     * 4F.5 â€” fetch an `http(s)://` `<img src>` through the injected
3521     * ResourceLoader and materialise the bytes to a temp file so
3522     * the existing `ImageParser` + `PdfWriter::addImage` flow can
3523     * register them as a PDF XObject. Returns the temp path on
3524     * success or null on any failure (no loader configured, SSRF
3525     * policy violation, network error, non-2xx, body cap exceeded,
3526     * write failure) â€” all of which surface as the no-image
3527     * outcome.
3528     */
3529    private function fetchHttpSrc(string $src): ?string
3530    {
3531        if ($this->resourceLoader === null) {
3532            return null;
3533        }
3534        try {
3535            $result = $this->resourceLoader->fetch($src);
3536        } catch (SsrfBlockedException | FetchFailedException) {
3537            return null;
3538        }
3539        $tmpPath = tempnam(sys_get_temp_dir(), 'phpdftk-http-img-');
3540        if ($tmpPath === false) {
3541            return null;
3542        }
3543        try {
3544            \Phpdftk\Filesystem\LocalFilesystem::writeFile($tmpPath, $result->bytes);
3545        } catch (\Throwable) {
3546            @unlink($tmpPath);
3547            return null;
3548        }
3549        $this->tempImagePaths[] = $tmpPath;
3550        return $tmpPath;
3551    }
3552
3553    /**
3554     * Decode `data:image/{png,jpeg};base64,...` (or the rfc2397 non-base64
3555     * form) into a tempfile so {@see PdfWriter::addImage} can parse it.
3556     * Returns null when the URL isn't a Phase-1 supported variant.
3557     */
3558    private function materializeDataUrl(string $dataUrl): ?string
3559    {
3560        // `data:image/png;base64,iVBORw0K...` â€” match the MIME + optional
3561        // `;base64` flag + the payload.
3562        if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $dataUrl, $m) !== 1) {
3563            return null;
3564        }
3565        $mime = $m[1] === 'jpg' ? 'jpeg' : $m[1];
3566        $payload = $m[2] === 'base64,'
3567            ? base64_decode($m[3], strict: true)
3568            : urldecode($m[3]);
3569        if ($payload === false || $payload === '') {
3570            return null;
3571        }
3572        $ext = $mime === 'jpeg' ? 'jpg' : 'png';
3573        $tempPath = tempnam(sys_get_temp_dir(), 'phpdftk-img-') . '.' . $ext;
3574        \Phpdftk\Filesystem\LocalFilesystem::writeFile($tempPath, $payload);
3575        $this->tempImagePaths[] = $tempPath;
3576        return $tempPath;
3577    }
3578
3579    /**
3580     * Resolve CSS Backgrounds 3 Â§3.5 `background-clip` to one of
3581     * `border-box` / `padding-box` / `content-box`. Unknown
3582     * keywords fall back to the initial `border-box`.
3583     */
3584    private function resolveBackgroundClip(Box $box): string
3585    {
3586        $value = $box->style->get('background-clip');
3587        if (!($value instanceof Keyword)) {
3588            return 'border-box';
3589        }
3590        $name = strtolower($value->name);
3591        // CSS Backgrounds 4 Â§3.5 â€” `border-area` paints only inside
3592        // the border ring (border-box âˆ– padding-box). The bg-clip
3593        // resolution still hands back the OUTER bounding rect
3594        // (border-box dimensions) so the caller computes positions
3595        // against that, but the actual paint is intersected with
3596        // the ring path; the special-case emission lives in
3597        // `paintBackground`.
3598        if (in_array($name, ['border-box', 'padding-box', 'content-box', 'border-area'], true)) {
3599            return $name;
3600        }
3601        return 'border-box';
3602    }
3603
3604    /**
3605     * Resolve CSS Backgrounds 3 Â§3.4 `background-origin` to one of
3606     * `padding-box` (initial) / `border-box` / `content-box`. The
3607     * origin rect anchors `background-position`'s percentage math.
3608     */
3609    private function resolveBackgroundOrigin(Box $box): string
3610    {
3611        $value = $box->style->get('background-origin');
3612        if (!($value instanceof Keyword)) {
3613            return 'padding-box';
3614        }
3615        $name = strtolower($value->name);
3616        if (in_array($name, ['border-box', 'padding-box', 'content-box'], true)) {
3617            return $name;
3618        }
3619        return 'padding-box';
3620    }
3621
3622    /**
3623     * Compute the (x, top, width, height) rect that
3624     * `background-origin: <value>` selects on `$box`. `x/top` are in
3625     * top-down layout space.
3626     *
3627     * @return array{x: float, top: float, width: float, height: float}
3628     */
3629    private function backgroundOriginRect(Box $box, string $origin): array
3630    {
3631        $g = $box->geometry;
3632        switch ($origin) {
3633            case 'content-box':
3634                return [
3635                    'x' => $g->x,
3636                    'top' => $g->y,
3637                    'width' => $g->width,
3638                    'height' => $g->height,
3639                ];
3640            case 'border-box':
3641                return [
3642                    'x' => $g->x - $g->paddingLeft - $g->borderLeft,
3643                    'top' => $g->y - $g->paddingTop - $g->borderTop,
3644                    'width' => $g->paddingLeft + $g->width + $g->paddingRight
3645                        + $g->borderLeft + $g->borderRight,
3646                    'height' => $g->paddingTop + $g->height + $g->paddingBottom
3647                        + $g->borderTop + $g->borderBottom,
3648                ];
3649            default: // 'padding-box'
3650                return [
3651                    'x' => $g->x - $g->paddingLeft,
3652                    'top' => $g->y - $g->paddingTop,
3653                    'width' => $g->paddingLeft + $g->width + $g->paddingRight,
3654                    'height' => $g->paddingTop + $g->height + $g->paddingBottom,
3655                ];
3656        }
3657    }
3658
3659    /**
3660     * CSS Overflow 3 Â§3 â€” return true when this box should clip its
3661     * descendants on at least one axis. `visible` (initial) â†’ no
3662     * clip; any of `hidden` / `clip` / `scroll` / `auto` â†’ clip.
3663     * Per-axis: when `overflow-x` is constraining and `overflow-y`
3664     * isn't (or vice versa), the clip rect extends to the page on
3665     * the unconstrained axis so spec-correct one-axis clipping holds.
3666     */
3667    private function shouldOverflowClip(Box $box): bool
3668    {
3669        // CSS Overflow 3 Â§3.3 â€” when the body's overflow has propagated
3670        // to the root, the body itself paints as if overflow:visible.
3671        if ($box === $this->propagatedOverflowBox) {
3672            return false;
3673        }
3674        return $this->axisClips($box, 'x') || $this->axisClips($box, 'y');
3675    }
3676
3677    /**
3678     * Return true when the given axis (`'x'` or `'y'`) should clip
3679     * for this box. Checks the axis-specific longhand first, then
3680     * the `overflow` shorthand.
3681     */
3682    private function axisClips(Box $box, string $axis): bool
3683    {
3684        // CSS Overflow 3 Â§3.3 â€” when overflow propagated from body to
3685        // root, the root's effective overflow is the body's. Per Â§2.1 a
3686        // viewport can't mix a clipped axis with a `visible` one: if the
3687        // body constrained EITHER axis, the other (`visible`) axis
3688        // computes to `auto` on the viewport and therefore also clips.
3689        // (WPT overflow-body-propagation-007 pairs body `overflow-x: clip`
3690        // against a ref `html { overflow: hidden auto }` â€” both axes clip.)
3691        if ($box === $this->propagatedOverflowRoot && $this->propagatedOverflowBox !== null) {
3692            return $this->originalAxisClips($this->propagatedOverflowBox, 'x')
3693                || $this->originalAxisClips($this->propagatedOverflowBox, 'y');
3694        }
3695        return $this->originalAxisClips($box, $axis);
3696    }
3697
3698    /** True when the box's `overflow` (or per-axis) uses the `clip` keyword. */
3699    private function hasOverflowClip(Box $box): bool
3700    {
3701        foreach (['overflow', 'overflow-x', 'overflow-y'] as $prop) {
3702            $v = $box->style->get($prop);
3703            if ($v instanceof Keyword && strtolower($v->name) === 'clip') {
3704                return true;
3705            }
3706        }
3707        return false;
3708    }
3709
3710    /**
3711     * CSS Overflow 3 Â§4.2 â€” resolve `overflow-clip-margin` to a per-edge
3712     * OUTWARD expansion (left, top, right, bottom) from the padding box.
3713     * The value is `[ <visual-box> || <length [0,∞]> ]`: the reference box
3714     * (content-box / padding-box[default] / border-box) sets the base
3715     * edge, the length pushes further out. Only applies to `overflow:
3716     * clip`; returns zeros otherwise.
3717     *
3718     * @return array{0: float, 1: float, 2: float, 3: float}
3719     */
3720    private function overflowClipMargin(Box $box): array
3721    {
3722        // CSS Overflow 3 Â§4.2 â€” `overflow-clip-margin` applies to a box
3723        // that clips via `overflow: clip` OR paint containment
3724        // (`contain: paint`), the latter clipping even when `overflow`
3725        // stays `visible` (WPT paint-containment-svg).
3726        if (!$this->hasOverflowClip($box) && !$this->containImpliesPaintClip($box)) {
3727            return [0.0, 0.0, 0.0, 0.0];
3728        }
3729        $value = $box->style->get('overflow-clip-margin');
3730        $items = $value instanceof \Phpdftk\Css\Value\ValueList
3731            ? $value->values
3732            : ($value !== null ? [$value] : []);
3733        $refBox = 'padding-box';
3734        $margin = 0.0;
3735        foreach ($items as $item) {
3736            if ($item instanceof Keyword) {
3737                $n = strtolower($item->name);
3738                if (in_array($n, ['content-box', 'padding-box', 'border-box'], true)) {
3739                    $refBox = $n;
3740                }
3741            } elseif ($item instanceof \Phpdftk\Css\Value\Length) {
3742                $margin = max(0.0, $item->value);
3743            }
3744        }
3745        $g = $box->geometry;
3746        $baseL = $baseT = $baseR = $baseB = 0.0;
3747        if ($refBox === 'border-box') {
3748            $baseL = $g->borderLeft;
3749            $baseT = $g->borderTop;
3750            $baseR = $g->borderRight;
3751            $baseB = $g->borderBottom;
3752        } elseif ($refBox === 'content-box') {
3753            $baseL = -$g->paddingLeft;
3754            $baseT = -$g->paddingTop;
3755            $baseR = -$g->paddingRight;
3756            $baseB = -$g->paddingBottom;
3757        }
3758        return [$baseL + $margin, $baseT + $margin, $baseR + $margin, $baseB + $margin];
3759    }
3760
3761    private function originalAxisClips(Box $box, string $axis): bool
3762    {
3763        // CSS Contain Â§2.3 â€” paint containment (`contain: paint |
3764        // content | strict`) clips descendants to the padding box on
3765        // BOTH axes, independent of and overriding `overflow: visible`.
3766        if ($this->containImpliesPaintClip($box)) {
3767            return true;
3768        }
3769        foreach (["overflow-$axis", 'overflow'] as $prop) {
3770            $value = $box->style->get($prop);
3771            if (!($value instanceof Keyword)) {
3772                continue;
3773            }
3774            $name = strtolower($value->name);
3775            if ($name === 'visible') {
3776                return false;
3777            }
3778            if ($name === 'hidden' || $name === 'clip' || $name === 'scroll' || $name === 'auto') {
3779                return true;
3780            }
3781        }
3782        return false;
3783    }
3784
3785    /**
3786     * Emit a clip rect for CSS Overflow 3 Â§4. Unconstrained axes are
3787     * widened to the full page so the clip is effectively one-axis;
3788     * fully-constrained boxes clip to the padding rect on both axes.
3789     * Caller is responsible for the `saveGraphicsState` /
3790     * `restoreGraphicsState` envelope.
3791     */
3792    /**
3793     * CSS 2.1 Â§17.5.1 background layers 2 and 3 â€” column groups and
3794     * columns. `<col>` / `<colgroup>` generate no boxes of their own,
3795     * so their background is painted into the CELLS of the columns they
3796     * cover: the background POSITIONING AREA is the whole column strip,
3797     * while the paint is CLIPPED to each cell's border box.
3798     *
3799     * That split matters twice over. It is what the spec asks for, so a
3800     * `background-image` on a column tiles across the strip rather than
3801     * restarting in every cell; and because the paint only ever happens
3802     * per-cell, a column with no cells renders nothing at all â€” which
3803     * the spec also requires, and which a naive "fill the strip" would
3804     * get wrong.
3805     */
3806    private function paintTableColumnBackgrounds(
3807        \Phpdftk\HtmlToPdf\Box\TableBox $table,
3808        ContentStream $stream,
3809    ): void {
3810        if ($table->columnLayers === []) {
3811            return;
3812        }
3813        /** @var array<int, list<\Phpdftk\HtmlToPdf\Box\TableCellBox>> $byColumn */
3814        $byColumn = [];
3815        $this->collectTableCellsByColumn($table, $byColumn);
3816        if ($byColumn === []) {
3817            return;
3818        }
3819        // The strip's block extent is the table's whole row area.
3820        $stripTop = null;
3821        $stripBottom = null;
3822        foreach ($byColumn as $cells) {
3823            foreach ($cells as $cell) {
3824                $rect = $this->cellBorderBoxRect($cell);
3825                $stripTop = $stripTop === null ? $rect['y'] : min($stripTop, $rect['y']);
3826                $bottom = $rect['y'] + $rect['h'];
3827                $stripBottom = $stripBottom === null ? $bottom : max($stripBottom, $bottom);
3828            }
3829        }
3830        if ($stripTop === null || $stripBottom === null || $stripBottom <= $stripTop) {
3831            return;
3832        }
3833        foreach ($table->columnLayers as $layer) {
3834            $colBox = $layer['box'];
3835            if (!$this->boxHasPaintableBackground($colBox)) {
3836                continue;
3837            }
3838            $left = null;
3839            $right = null;
3840            /** @var list<\Phpdftk\HtmlToPdf\Box\TableCellBox> $covered */
3841            $covered = [];
3842            for ($c = $layer['start']; $c < $layer['start'] + $layer['span']; $c++) {
3843                foreach ($byColumn[$c] ?? [] as $cell) {
3844                    $rect = $this->cellBorderBoxRect($cell);
3845                    $left = $left === null ? $rect['x'] : min($left, $rect['x']);
3846                    $edge = $rect['x'] + $rect['w'];
3847                    $right = $right === null ? $edge : max($right, $edge);
3848                    $covered[] = $cell;
3849                }
3850            }
3851            if ($covered === [] || $left === null || $right === null || $right <= $left) {
3852                continue;
3853            }
3854            // Stand the column box up at the strip's geometry so the
3855            // existing background machinery (colour, images, repeat,
3856            // size, position) resolves against the strip.
3857            $strip = new \Phpdftk\HtmlToPdf\Layout\BoxGeometry();
3858            $strip->x = $left;
3859            $strip->y = $stripTop;
3860            $strip->width = $right - $left;
3861            $strip->height = $stripBottom - $stripTop;
3862            $original = $colBox->geometry;
3863            $colBox->geometry = $strip;
3864            foreach ($covered as $cell) {
3865                $rect = $this->cellBorderBoxRect($cell);
3866                if ($rect['w'] <= 0.0 || $rect['h'] <= 0.0) {
3867                    continue;
3868                }
3869                $stream->saveGraphicsState();
3870                $stream->rectangle(
3871                    $rect['x'],
3872                    $this->pageHeight - $rect['y'] - $rect['h'],
3873                    $rect['w'],
3874                    $rect['h'],
3875                );
3876                $stream->clip();
3877                $this->paintBackground($colBox, $stream);
3878                $stream->restoreGraphicsState();
3879            }
3880            $colBox->geometry = $original;
3881        }
3882    }
3883
3884    /**
3885     * Border-box rect of a table cell in layout (top-down) coordinates.
3886     *
3887     * @return array{x: float, y: float, w: float, h: float}
3888     */
3889    private function cellBorderBoxRect(\Phpdftk\HtmlToPdf\Box\TableCellBox $cell): array
3890    {
3891        $g = $cell->geometry;
3892        return [
3893            'x' => $g->x - $g->paddingLeft - $g->borderLeft,
3894            'y' => $g->y - $g->paddingTop - $g->borderTop,
3895            'w' => $g->paddingLeft + $g->width + $g->paddingRight
3896                + $g->borderLeft + $g->borderRight,
3897            'h' => $g->paddingTop + $g->height + $g->paddingBottom
3898                + $g->borderTop + $g->borderBottom,
3899        ];
3900    }
3901
3902    /**
3903     * Collect every cell in the table, keyed by the column it starts in.
3904     * A cell spanning N columns is registered under each of them so a
3905     * column background reaches the part of the cell it covers (the clip
3906     * still confines the paint to the cell itself).
3907     *
3908     * @param array<int, list<\Phpdftk\HtmlToPdf\Box\TableCellBox>> $byColumn
3909     */
3910    private function collectTableCellsByColumn(Box $box, array &$byColumn): void
3911    {
3912        foreach ($box->children as $child) {
3913            if ($child instanceof \Phpdftk\HtmlToPdf\Box\TableBox) {
3914                // A nested table owns its own column layers.
3915                continue;
3916            }
3917            if ($child instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox
3918                && $child->tableColumn !== null
3919            ) {
3920                for ($i = 0; $i < $child->tableColumnSpan; $i++) {
3921                    $byColumn[$child->tableColumn + $i][] = $child;
3922                }
3923                continue;
3924            }
3925            $this->collectTableCellsByColumn($child, $byColumn);
3926        }
3927    }
3928
3929    private function emitOverflowClipPath(ContentStream $stream, Box $box): void
3930    {
3931        $g = $box->geometry;
3932        $padX = $g->x - $g->paddingLeft;
3933        $padTop = $g->y - $g->paddingTop;
3934        $padWidth = $g->paddingLeft + $g->width + $g->paddingRight;
3935        $padHeight = $g->paddingTop + $g->height + $g->paddingBottom;
3936        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
3937            return;
3938        }
3939        $clipsX = $this->axisClips($box, 'x');
3940        $clipsY = $this->axisClips($box, 'y');
3941        // CSS Overflow 3 Â§4.2 â€” `overflow-clip-margin` expands the clip
3942        // region outward from a visual reference box (only for the `clip`
3943        // keyword; hidden/scroll/auto always clip at the padding box).
3944        [$expandL, $expandT, $expandR, $expandB] = $this->overflowClipMargin($box);
3945        // CSS Backgrounds 3 Â§5.3 â€” a rounded box clips its overflow to the
3946        // padding box's ROUNDED corners (border radii reduced by the border
3947        // widths). Only the standard both-axes padding-box clip is rounded;
3948        // one-axis clips and `overflow-clip-margin` expansions keep the rect.
3949        if ($clipsX && $clipsY
3950            && $expandL === 0.0 && $expandT === 0.0 && $expandR === 0.0 && $expandB === 0.0
3951        ) {
3952            $borderBoxW = $g->borderLeft + $padWidth + $g->borderRight;
3953            $borderBoxH = $g->borderTop + $padHeight + $g->borderBottom;
3954            $radii = $this->borderRadiiXY($box, $borderBoxW, $borderBoxH);
3955            if ($this->radiiAnyPositive($radii)) {
3956                $padRadii = $this->reduceRadiiToBox($radii, 'padding-box', $g);
3957                $this->buildRoundedRectPath($stream, $padX, $padTop, $padWidth, $padHeight, $padRadii, $this->cornerShapes($box));
3958                $stream->clip();
3959                $stream->endPath();
3960                return;
3961            }
3962        }
3963        $rectX = $clipsX ? $padX - $expandL : 0.0;
3964        $rectWidth = $clipsX ? $padWidth + $expandL + $expandR : $this->pageWidth;
3965        $rectTop = $clipsY ? $padTop - $expandT : 0.0;
3966        $rectHeight = $clipsY ? $padHeight + $expandT + $expandB : $this->pageHeight;
3967        $pdfY = $this->pageHeight - $rectTop - $rectHeight;
3968        $stream->rectangle($rectX, $pdfY, $rectWidth, $rectHeight);
3969        $stream->clip();
3970        $stream->endPath();
3971    }
3972
3973    /**
3974     * CSS 2.1 Â§11.1.2 `clip: rect(top, right, bottom, left)` â€” resolve the
3975     * clipping rectangle (physical layout coords, y-down) for an
3976     * absolutely-positioned box, or `null` when `clip` is `auto`, the box
3977     * is not absolutely positioned, or the value isn't a `rect()`.
3978     * `top`/`left` offset from the border box's top/left edge; `right`/
3979     * `bottom` are measured from those same edges; `auto` means the
3980     * corresponding border edge.
3981     *
3982     * @return array{x: float, y: float, w: float, h: float}|null
3983     */
3984    private function resolveClipRect(Box $box): ?array
3985    {
3986        $clip = $box->style->get('clip');
3987        $edges = null;
3988        if ($clip instanceof \Phpdftk\Css\Value\CssFunction
3989            && strtolower($clip->name) === 'rect'
3990            && count($clip->arguments) === 4
3991        ) {
3992            // Comma-separated legacy form: `clip: rect(t, r, b, l)`.
3993            $edges = $clip->arguments;
3994        } elseif ($clip instanceof \Phpdftk\Css\Value\RectShape
3995            && count($clip->edges) === 4
3996        ) {
3997            // Whitespace-separated legacy form: `clip: rect(t r b l)` parses
3998            // to a RectShape (the CSS Shapes 2 `rect()` grammar) whose edges
3999            // carry the same top/right/bottom/left border-box-origin semantics
4000            // as the comma form (CSS 2.1 Â§11.1.2). `round <radius>` never
4001            // appears on the legacy `clip` property, so it is ignored.
4002            $edges = $clip->edges;
4003        }
4004        if ($edges === null) {
4005            return null;
4006        }
4007        $position = $box->style->get('position');
4008        if (!($position instanceof Keyword)) {
4009            return null;
4010        }
4011        $pos = strtolower($position->name);
4012        if ($pos !== 'absolute' && $pos !== 'fixed') {
4013            return null;
4014        }
4015        $g = $box->geometry;
4016        $borderBoxX = $g->x - $g->paddingLeft - $g->borderLeft;
4017        $borderBoxY = $g->y - $g->paddingTop - $g->borderTop;
4018        $borderBoxW = $g->borderLeft + $g->paddingLeft + $g->width
4019            + $g->paddingRight + $g->borderRight;
4020        $borderBoxH = $g->borderTop + $g->paddingTop + $g->height
4021            + $g->paddingBottom + $g->borderBottom;
4022        // rect(top, right, bottom, left); `auto` â†’ border edge.
4023        [$topV, $rightV, $bottomV, $leftV] = $edges;
4024        $top = $this->clipEdgePx($topV, 0.0);
4025        $right = $this->clipEdgePx($rightV, $borderBoxW);
4026        $bottom = $this->clipEdgePx($bottomV, $borderBoxH);
4027        $left = $this->clipEdgePx($leftV, 0.0);
4028        $x = $borderBoxX + $left;
4029        $y = $borderBoxY + $top;
4030        return [
4031            'x' => $x,
4032            'y' => $y,
4033            'w' => max(0.0, ($borderBoxX + $right) - $x),
4034            'h' => max(0.0, ($borderBoxY + $bottom) - $y),
4035        ];
4036    }
4037
4038    /**
4039     * CSS Masking 1 Â§6 â€” apply a `clip-path: <basic-shape>` to the box by
4040     * pushing a PDF clip path for the shape, resolved against the BORDER
4041     * box. Returns true when a clip was pushed (caller restores after the
4042     * children loop). Supports inset / circle / ellipse / polygon; the
4043     * `<geometry-box>` form and `url()` references are not handled.
4044     */
4045    /**
4046     * CSS Masking 1 Â§4 â€” true when the box's `mask-image` is a single
4047     * `url(...)` layer we cannot resolve to a mask image. Such a failed
4048     * layer is transparent black, so it masks the element (and subtree)
4049     * fully out. Restricted to a lone `Url` value: gradients (which we
4050     * render unmasked for now) and multi-layer / keyword values are left
4051     * alone so a merely-unsupported mask never hides content.
4052     */
4053    private function maskHidesElement(Box $box): bool
4054    {
4055        return $box->style->get('mask-image') instanceof \Phpdftk\Css\Value\Url;
4056    }
4057
4058    /**
4059     * CSS Masking 1 Â§4 â€” resolve a `mask-image: <linear-gradient>` to the
4060     * Luminosity soft-mask ExtGState name that masks the box (and its
4061     * subtree) by the gradient, or null when the mask isn't a supported
4062     * single translucent linear gradient in the default `match-source`
4063     * (alpha) mode.
4064     *
4065     * `match-source` on a CSS `<image>` (a gradient, not an SVG `<mask>`)
4066     * resolves to `alpha`, so the mask value is the gradient's alpha
4067     * channel â€” exactly the DeviceGray alpha shading the background path
4068     * builds ({@see addLinearAlphaShading()}). The gradient is laid out
4069     * over the box's border box (the default mask-origin / mask-clip),
4070     * at its default size (auto â†’ fills the box), so the geometry mirrors
4071     * a background gradient painted over that box.
4072     *
4073     * Bails (returns null â†’ box paints unmasked) for anything outside this
4074     * narrow case â€” multi-layer masks, non-linear gradients, explicit
4075     * `luminance` mode, or any non-default mask geometry longhand â€” so an
4076     * unsupported mask never silently hides content.
4077     */
4078    /**
4079     * CSS Masking 1 Â§4 â€” resolve a `mask-image: url(<image>)` to a soft-mask
4080     * ExtGState name that masks the box by the image's alpha (`match-source`
4081     * / `alpha`) or luminance (`mask-mode: luminance`). Increment 1 handles a
4082     * single url() layer under the DEFAULT mask box model (border-box
4083     * origin/clip, position 0 0, size auto, repeat) drawn to fill the border
4084     * box; non-default geometry / multi-layer / url(#id) return null (the
4085     * caller falls back, so the box paints unmasked or blank as before).
4086     * Returns null when not applicable so the gradient builder can try next.
4087     */
4088    private function buildBoxImageMaskGsName(Box $box, ContentStream $stream): ?string
4089    {
4090        if ($this->writer === null || $this->page === null) {
4091            return null;
4092        }
4093        $maskImage = $box->style->get('mask-image');
4094        if (!$maskImage instanceof \Phpdftk\Css\Value\Url) {
4095            return null;
4096        }
4097        // In-document SVG references (`url(#mask)`) need element resolution â€”
4098        // a later increment; only external image URLs here.
4099        $src = $maskImage->url;
4100        if (str_starts_with(ltrim($src), '#')) {
4101            return null;
4102        }
4103        // Multi-layer mask compositing beyond a single `add` layer isn't
4104        // modeled yet.
4105        $composite = $box->style->get('mask-composite');
4106        if ($composite !== null && $composite->toCss() !== 'add') {
4107            return null;
4108        }
4109        // CSS Masking 1 Â§3.3 â€” `match-source` on an image (and explicit
4110        // `alpha`) uses the image's ALPHA; explicit `luminance` uses its
4111        // LUMINOSITY. (SVG `<mask>` elements default to luminance, but those
4112        // are url(#id) references handled later.)
4113        $subtype = 'Alpha';
4114        $maskMode = $box->style->get('mask-mode');
4115        if ($maskMode instanceof \Phpdftk\Css\Value\Keyword
4116            && strtolower($maskMode->name) === 'luminance'
4117        ) {
4118            $subtype = 'Luminosity';
4119        }
4120        // CSS Masking 1 Â§4 â€” resolve the mask box model, reusing the
4121        // background origin/clip/size/position/repeat machinery. mask-origin
4122        // is the positioning area; mask-clip is the painted (group) region.
4123        $origin = $this->backgroundOriginRect($box, $this->resolveMaskOrigin($box));
4124        $clipName = $this->resolveMaskClip($box);
4125        if ($clipName === 'no-clip') {
4126            $clipX = 0.0;
4127            $clipTop = 0.0;
4128            $clipW = $this->pageWidth;
4129            $clipH = $this->pageHeight;
4130        } else {
4131            [$clipX, $clipTop, $clipW, $clipH] = $this->clipReferenceBox($box->geometry, $clipName);
4132        }
4133        if ($clipW <= 0.0 || $clipH <= 0.0) {
4134            return null;
4135        }
4136        $svgDoc = null;
4137        $imgName = null;
4138        $imgRef = null;
4139        if ($this->isSvgSrc($src)) {
4140            $svgDoc = $this->loadSvgDocument($src);
4141            if ($svgDoc === null) {
4142                return null;
4143            }
4144        } else {
4145            $resolved = $this->resolveImageSrc($src);
4146            if ($resolved === null) {
4147                return null;
4148            }
4149            try {
4150                $imgName = $this->writer->addImage($resolved, $this->page);
4151            } catch (\Throwable) {
4152                return null;
4153            }
4154            $imgRef = $this->page->corePage()->resources->xObject[$imgName] ?? null;
4155            if ($imgRef === null) {
4156                return null;
4157            }
4158        }
4159        // Final paint size (mask-size) then position (mask-position) within
4160        // the positioning area.
4161        $paint = $this->resolveBackgroundSize(
4162            $box->style->get('mask-size'),
4163            $src,
4164            $origin['width'],
4165            $origin['height'],
4166        );
4167        $maskPos = $box->style->get('mask-position');
4168        if ($maskPos !== null) {
4169            $pos = $this->resolveBackgroundPosition(
4170                $maskPos,
4171                $paint['w'],
4172                $paint['h'],
4173                $origin['width'],
4174                $origin['height'],
4175            );
4176            $paint['offsetX'] = $pos['offsetX'];
4177            $paint['offsetY'] = $pos['offsetY'];
4178        }
4179        $maskRepeat = $box->style->get('mask-repeat');
4180        $clipPdfY = $this->pageHeight - $clipTop - $clipH;
4181        try {
4182            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
4183            $group = $doc->createTransparencyGroup(
4184                new \Phpdftk\Geometry\Rectangle($clipX, $clipPdfY, $clipW, $clipH),
4185                function (
4186                    ContentStream $cs,
4187                    \Phpdftk\Pdf\Core\Content\Resources $res,
4188                ) use ($svgDoc, $imgName, $imgRef, $clipX, $clipTop, $clipW, $clipH, $paint, $maskRepeat, $origin): void {
4189                    $register = ($imgName !== null && $imgRef !== null)
4190                        ? function (string $n) use ($res, $imgRef): void {
4191                            $res->addXObject($n, $imgRef);
4192                        }
4193                    : null;
4194                    $this->drawImageTilesInto(
4195                        $cs,
4196                        $svgDoc,
4197                        $imgName,
4198                        $clipX,
4199                        $clipTop,
4200                        $clipW,
4201                        $clipH,
4202                        $paint,
4203                        $maskRepeat,
4204                        $origin['x'],
4205                        $origin['top'],
4206                        $origin['width'],
4207                        $origin['height'],
4208                        $register,
4209                    );
4210                },
4211            );
4212            return $this->page->ensureSoftMaskState($group, $subtype);
4213        } catch (\Throwable) {
4214            return null;
4215        }
4216    }
4217
4218    /**
4219     * CSS Masking 1 Â§4.1 â€” `mask-image: url(#id)` referencing an SVG
4220     * `<mask>` ELEMENT (as opposed to an image). The mask element brings
4221     * its own geometry â€” `maskUnits` / `maskContentUnits` / `x` / `y` /
4222     * `width` / `height` â€” so the CSS mask box model (`mask-origin`,
4223     * `mask-size`, `mask-position`, `mask-repeat`, `mask-clip`) does not
4224     * apply; only `mask-mode` still speaks, overriding the element's
4225     * `mask-type`.
4226     *
4227     * The mask's children are resolved in the referencing element's own
4228     * user coordinate system â€” origin at the top-left of its border box,
4229     * y running down â€” which is the same frame `clip-path: url(#id)`
4230     * uses, and the element's bounding box for `objectBoundingBox` units
4231     * is that border box.
4232     *
4233     * Returns null when the reference does not resolve to a `<mask>`, so
4234     * the image / gradient builders get their turn.
4235     */
4236    private function buildBoxSvgMaskGsName(Box $box): ?string
4237    {
4238        if ($this->writer === null || $this->page === null) {
4239            return null;
4240        }
4241        $maskImage = $box->style->get('mask-image');
4242        if (!$maskImage instanceof \Phpdftk\Css\Value\Url) {
4243            return null;
4244        }
4245        $url = trim($maskImage->url);
4246        $hash = strpos($url, '#');
4247        if ($hash === false) {
4248            return null;
4249        }
4250        $id = substr($url, $hash + 1);
4251        if ($id === '') {
4252            return null;
4253        }
4254        $svgDoc = $hash === 0
4255            ? $this->hostSvgDocumentFor($box, $id)
4256            : $this->loadSvgDocument(substr($url, 0, $hash));
4257        if ($svgDoc === null) {
4258            return null;
4259        }
4260        $mask = $svgDoc->findById($id);
4261        if (!$mask instanceof \Phpdftk\Svg\Mask) {
4262            return null;
4263        }
4264        [$bx, $by, $bw, $bh] = $this->clipReferenceBox($box->geometry, 'border-box');
4265        if ($bw <= 0.0 || $bh <= 0.0) {
4266            return null;
4267        }
4268        // CSS Masking 1 Â§3.3 â€” `mask-mode` overrides the mask element's
4269        // own `mask-type`; `match-source` defers to it.
4270        $subtype = null;
4271        $maskMode = $box->style->get('mask-mode');
4272        if ($maskMode instanceof \Phpdftk\Css\Value\Keyword) {
4273            $subtype = match (strtolower($maskMode->name)) {
4274                'alpha' => 'Alpha',
4275                'luminance' => 'Luminosity',
4276                default => null,
4277            };
4278        }
4279        try {
4280            return $this->clipTranslator()->registerMaskState(
4281                $svgDoc,
4282                $mask,
4283                $this->page,
4284                $this->writer,
4285                ['minX' => 0.0, 'minY' => 0.0, 'width' => $bw, 'height' => $bh],
4286                [1.0, 0.0, 0.0, -1.0, $bx, $this->pageHeight - $by],
4287                $subtype,
4288            );
4289        } catch (\Throwable) {
4290            return null;
4291        }
4292    }
4293
4294    /**
4295     * CSS Masking 1 Â§4.5 â€” `mask-origin` positioning box. Initial `border-box`
4296     * (unlike `background-origin`'s `padding-box`).
4297     */
4298    private function resolveMaskOrigin(Box $box): string
4299    {
4300        $v = $box->style->get('mask-origin');
4301        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
4302            $n = strtolower($v->name);
4303            if (in_array($n, ['content-box', 'padding-box', 'border-box'], true)) {
4304                return $n;
4305            }
4306        }
4307        return 'border-box';
4308    }
4309
4310    /**
4311     * CSS Masking 1 Â§4.6 â€” `mask-clip` painting box (initial `border-box`).
4312     * `no-clip` leaves the mask unclipped; SVG visual boxes (fill/stroke/
4313     * view-box) map to the nearest supported CSS box.
4314     */
4315    private function resolveMaskClip(Box $box): string
4316    {
4317        $v = $box->style->get('mask-clip');
4318        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
4319            $n = strtolower($v->name);
4320            if ($n === 'no-clip') {
4321                return 'no-clip';
4322            }
4323            if (in_array($n, ['content-box', 'padding-box', 'border-box'], true)) {
4324                return $n;
4325            }
4326        }
4327        return 'border-box';
4328    }
4329
4330    private function buildBoxGradientMaskGsName(Box $box, ContentStream $stream): ?string
4331    {
4332        if ($this->writer === null || $this->page === null) {
4333            return null;
4334        }
4335        $maskImage = $box->style->get('mask-image');
4336        if (!$maskImage instanceof \Phpdftk\Css\Value\LinearGradient) {
4337            return null;
4338        }
4339        // Only the default `match-source` (→ alpha for a CSS image) or an
4340        // explicit `alpha` mode maps cleanly to the alpha shading. Any
4341        // other mode (`luminance`) would need a different mask value.
4342        $maskMode = $box->style->get('mask-mode');
4343        if ($maskMode instanceof \Phpdftk\Css\Value\Keyword) {
4344            $mode = strtolower($maskMode->name);
4345            if ($mode !== 'match-source' && $mode !== 'alpha') {
4346                return null;
4347            }
4348        }
4349        // Bail on any non-default mask geometry â€” those change where / how
4350        // the gradient tiles and we only model the full-border-box case.
4351        // The longhands are registered with initial values, so compare the
4352        // computed value's serialisation against the CSS initial rather
4353        // than testing for absence.
4354        $defaults = [
4355            'mask-repeat' => 'repeat',
4356            'mask-size' => 'auto',
4357            'mask-position' => '0% 0%',
4358            'mask-clip' => 'border-box',
4359            'mask-origin' => 'border-box',
4360            'mask-composite' => 'add',
4361        ];
4362        foreach ($defaults as $prop => $initial) {
4363            $value = $box->style->get($prop);
4364            if ($value !== null && $value->toCss() !== $initial) {
4365                return null;
4366            }
4367        }
4368        // A gradient with no translucent stop masks nothing out (mask is
4369        // fully opaque) â€” skip the soft mask entirely (fast path, no-op).
4370        if (!$this->gradientHasAlpha($maskImage->stops)) {
4371            return null;
4372        }
4373        [$bx, $by, $bw, $bh] = $this->clipReferenceBox($box->geometry, 'border-box');
4374        if ($bw <= 0.0 || $bh <= 0.0) {
4375            return null;
4376        }
4377        $tilePdfY = $this->pageHeight - $by - $bh;
4378        [$startX, $startY, $endX, $endY, $lineLength] =
4379            $this->linearGradientLine($maskImage->angleDeg, $bx, $tilePdfY, $bw, $bh);
4380        $stopList = $this->resolveGradientStops($maskImage->stops, $lineLength);
4381        if (count($stopList) < 2) {
4382            return null;
4383        }
4384        $alphaStops = $this->alphaStopsFrom($stopList);
4385        return $this->buildGradientAlphaMaskState(
4386            \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer),
4387            fn(\Phpdftk\Pdf\Writer\PdfDoc $d): int => $d->addLinearAlphaShading(
4388                new \Phpdftk\Geometry\Point($startX, $startY),
4389                new \Phpdftk\Geometry\Point($endX, $endY),
4390                $alphaStops,
4391            )->objectNumber,
4392            $bx,
4393            $tilePdfY,
4394            $bw,
4395            $bh,
4396        );
4397    }
4398
4399    /**
4400     * Compute a CSS linear-gradient line over a tile: its start / end
4401     * points (PDF coordinates) and full length. `angleDeg` follows the
4402     * CSS convention (0° points up, increases clockwise); the endpoints
4403     * land on the tile boundary corners per CSS Images 3 Â§3.1. Shared by
4404     * the background gradient painter and the gradient mask builder.
4405     *
4406     * @return array{float, float, float, float, float}
4407     *   [startX, startY, endX, endY, lineLength]
4408     */
4409    private function linearGradientLine(
4410        float $angleDeg,
4411        float $tileX,
4412        float $tilePdfY,
4413        float $tileWidth,
4414        float $tileHeight,
4415    ): array {
4416        $angle = fmod($angleDeg, 360.0);
4417        if ($angle < 0.0) {
4418            $angle += 360.0;
4419        }
4420        $rad = deg2rad($angle);
4421        $cx = $tileX + $tileWidth / 2;
4422        $cy = $tilePdfY + $tileHeight / 2;
4423        $sin = sin($rad);
4424        $cos = cos($rad);
4425        $halfLen = (abs($tileWidth * $sin) + abs($tileHeight * $cos)) / 2;
4426        return [
4427            $cx - $sin * $halfLen,
4428            $cy - $cos * $halfLen,
4429            $cx + $sin * $halfLen,
4430            $cy + $cos * $halfLen,
4431            $halfLen * 2,
4432        ];
4433    }
4434
4435    private function applyClipPath(Box $box, ContentStream $stream): bool
4436    {
4437        $shape = $box->style->get('clip-path');
4438        // CSS Masking 1 Â§6.1 â€” `clip-path: <clip-source>` references a
4439        // `<clipPath>` element. A `<url>` never combines with a
4440        // `<geometry-box>`, so dispatch before the ValueList unwrap below.
4441        if ($shape instanceof \Phpdftk\Css\Value\Url) {
4442            return $this->applyReferencedClipPath($box, $shape->url, $stream);
4443        }
4444        // CSS Masking 1 Â§6 â€” `clip-path: <basic-shape> || <geometry-box>`.
4445        // When a reference box is present the value parses as a ValueList
4446        // of [shape, geometry-box keyword] (in either order); unwrap it so
4447        // the `instanceof` shape dispatch below still fires, and resolve
4448        // the shape against the named box instead of the default border box.
4449        $geometryBoxes = [
4450            'content-box', 'padding-box', 'border-box', 'margin-box',
4451            'fill-box', 'stroke-box', 'view-box',
4452        ];
4453        $refBox = 'border-box';
4454        $bareBox = false;
4455        if ($shape instanceof \Phpdftk\Css\Value\ValueList) {
4456            $extractedShape = null;
4457            foreach ($shape->values as $part) {
4458                if ($part instanceof \Phpdftk\Css\Value\BasicShape) {
4459                    $extractedShape = $part;
4460                } elseif ($part instanceof \Phpdftk\Css\Value\Keyword) {
4461                    $refBox = strtolower($part->name);
4462                }
4463            }
4464            $shape = $extractedShape;
4465            // A bare `<geometry-box>` with no shape clips to that box's edge.
4466            $bareBox = $extractedShape === null;
4467        } elseif ($shape instanceof \Phpdftk\Css\Value\Keyword
4468            && in_array(strtolower($shape->name), $geometryBoxes, true)
4469        ) {
4470            // `clip-path: <geometry-box>` with no shape â€” clip to that box.
4471            $refBox = strtolower($shape->name);
4472            $bareBox = true;
4473        }
4474        $g = $box->geometry;
4475        [$bx, $by, $bw, $bh] = $this->clipReferenceBox($g, $refBox);
4476        if ($bw <= 0.0 || $bh <= 0.0) {
4477            return false;
4478        }
4479        $ph = $this->pageHeight;
4480        if ($bareBox) {
4481            // CSS Masking 1 Â§6 â€” `clip-path: <geometry-box>` clips to that
4482            // box's edge. When the element has border-radius the clip follows
4483            // the (reference-box-reduced) rounded corners (CSS Backgrounds
4484            // 3 Â§5.3); otherwise a plain rectangle.
4485            $stream->saveGraphicsState();
4486            $g = $box->geometry;
4487            $borderBoxW = $g->borderLeft + $g->paddingLeft + $g->width + $g->paddingRight + $g->borderRight;
4488            $borderBoxH = $g->borderTop + $g->paddingTop + $g->height + $g->paddingBottom + $g->borderBottom;
4489            $radii = $this->reduceRadiiToBox(
4490                $this->borderRadiiXY($box, $borderBoxW, $borderBoxH),
4491                $refBox,
4492                $g,
4493            );
4494            if ($this->radiiAnyPositive($radii)) {
4495                $this->buildRoundedRectPath($stream, $bx, $by, $bw, $bh, $radii, $this->cornerShapes($box));
4496            } else {
4497                $stream->rectangle($bx, $ph - $by - $bh, $bw, $bh);
4498            }
4499            $stream->clip();
4500            $stream->endPath();
4501            return true;
4502        }
4503        // CSS Shapes 1 Â§3 â€” resolve the shape's outline against the
4504        // reference box in CSS coordinates (y down), then emit it with y
4505        // flipped into PDF space. The resolver is shared with the SVG
4506        // translator's `clip-path` so both halves of the engine agree on
4507        // the geometry.
4508        $outline = \Phpdftk\Css\Shape\BasicShapePath::build($shape, $bx, $by, $bw, $bh);
4509        if ($outline === null) {
4510            return false;
4511        }
4512        $stream->saveGraphicsState();
4513        foreach ($outline['commands'] as $command) {
4514            switch ($command[0]) {
4515                case 'M':
4516                    $stream->moveTo((float) $command[1], $ph - (float) $command[2]);
4517                    break;
4518                case 'L':
4519                    $stream->lineTo((float) $command[1], $ph - (float) $command[2]);
4520                    break;
4521                case 'C':
4522                    $stream->curveTo(
4523                        (float) $command[1],
4524                        $ph - (float) $command[2],
4525                        (float) $command[3],
4526                        $ph - (float) $command[4],
4527                        (float) $command[5],
4528                        $ph - (float) $command[6],
4529                    );
4530                    break;
4531                default:
4532                    $stream->closePath();
4533                    break;
4534            }
4535        }
4536        if ($outline['fillRule'] === 'evenodd') {
4537            $stream->clipEvenOdd();
4538        } else {
4539            $stream->clip();
4540        }
4541        $stream->endPath();
4542        return true;
4543    }
4544
4545    /**
4546     * CSS Masking 1 Â§6.1 â€” `clip-path: url(#id)` clips the box (and its
4547     * subtree) to the geometry of the referenced `<clipPath>` element.
4548     *
4549     * The clipPath's children are resolved in the referencing element's
4550     * own user coordinate system, whose origin is the top-left of its
4551     * BORDER box with y running down (CSS Masking 1 Â§6.1, "the user
4552     * coordinate system in place at the time the clipPath is
4553     * referenced"). We install exactly that mapping as a `cm`, let the
4554     * SVG translator build the region, then concatenate the inverse: the
4555     * clip established by `W` already lives in device space, so undoing
4556     * the CTM leaves the region in force while the caller keeps painting
4557     * in page coordinates. (Popping with `Q` instead would discard the
4558     * clip along with the matrix.)
4559     *
4560     * `clipPathUnits="objectBoundingBox"` measures the children against
4561     * the element's bounding box, which for a non-SVG element is that
4562     * same border box.
4563     *
4564     * Fragment-only URLs are always document-local (CSS Values 4 Â§4.5),
4565     * so a `<base href>` never redirects them; a URL with a path is
4566     * loaded as an external SVG resource. Returns false â€” no clip, the
4567     * element paints whole â€” when the reference does not resolve to a
4568     * `<clipPath>`.
4569     */
4570    private function applyReferencedClipPath(Box $box, string $rawUrl, ContentStream $stream): bool
4571    {
4572        $url = trim($rawUrl);
4573        $hash = strpos($url, '#');
4574        if ($hash === false) {
4575            return false;
4576        }
4577        $id = substr($url, $hash + 1);
4578        if ($id === '') {
4579            return false;
4580        }
4581        $svgDoc = $hash === 0
4582            ? $this->hostSvgDocumentFor($box, $id)
4583            : $this->loadSvgDocument(substr($url, 0, $hash));
4584        if ($svgDoc === null) {
4585            return false;
4586        }
4587        $clipPath = $svgDoc->findById($id);
4588        if (!$clipPath instanceof \Phpdftk\Svg\ClipPath) {
4589            return false;
4590        }
4591        [$bx, $by, $bw, $bh] = $this->clipReferenceBox($box->geometry, 'border-box');
4592        if ($bw <= 0.0 || $bh <= 0.0) {
4593            return false;
4594        }
4595        $ph = $this->pageHeight;
4596        $stream->saveGraphicsState();
4597        $stream->concatMatrix(1.0, 0.0, 0.0, -1.0, $bx, $ph - $by);
4598        try {
4599            $this->clipTranslator()->emitClipPathRegion(
4600                $svgDoc,
4601                $clipPath,
4602                $stream,
4603                // SVG 2 Â§10.3 â€” percentage lengths on the clipPath's
4604                // children resolve against the viewport. The referencing
4605                // element is not inside any SVG viewport, so that is the
4606                // initial viewport: the page box.
4607                ['w' => $this->pageWidth, 'h' => $ph],
4608                [0.0, 0.0, $bw, $bh],
4609            );
4610        } catch (\Throwable) {
4611            $stream->restoreGraphicsState();
4612            return false;
4613        }
4614        $stream->concatMatrix(1.0, 0.0, 0.0, -1.0, -$bx, $ph - $by);
4615        return true;
4616    }
4617
4618    /**
4619     * Resolve a document-local `#id` clip / mask reference to the parsed
4620     * `SvgDocument` for the inline `<svg>` that hosts it.
4621     *
4622     * The id is looked up in the HTML DOM (so an `<svg>` anywhere in the
4623     * document can serve a `<clipPath>` to any element), then the
4624     * OUTERMOST `<svg>` ancestor of the hit is adapted â€” that is the
4625     * subtree `InlineSvgAdapter` knows how to turn into typed SVG
4626     * elements, and its cache makes repeat lookups free.
4627     */
4628    private function hostSvgDocumentFor(Box $box, string $id): ?\Phpdftk\Svg\SvgDocument
4629    {
4630        $element = $box->element;
4631        if ($element === null) {
4632            return null;
4633        }
4634        $target = $element->ownerDocument->getElementById($id);
4635        if ($target === null) {
4636            return null;
4637        }
4638        $svgRoot = null;
4639        for ($n = $target; $n !== null; $n = $n->parentNode) {
4640            if ($n instanceof \Phpdftk\Html\Dom\Element
4641                && \Phpdftk\HtmlToPdf\Box\BoxGenerator::foreignContentKind($n) === 'svg'
4642            ) {
4643                $svgRoot = $n;
4644            }
4645        }
4646        if ($svgRoot === null) {
4647            return null;
4648        }
4649        try {
4650            return $this->inlineSvgAdapter()->adapt($svgRoot);
4651        } catch (\Throwable) {
4652            return null;
4653        }
4654    }
4655
4656    /**
4657     * Lazily-built translator used ONLY to emit `<clipPath>` / `<mask>`
4658     * geometry for document-local CSS references. Kept separate from
4659     * {@see svgRenderer}'s translator so a clip emitted mid-paint can
4660     * never disturb the state of an inline-SVG paint in progress.
4661     */
4662    private function clipTranslator(): \Phpdftk\SvgToPdf\Translator
4663    {
4664        if ($this->clipTranslator === null) {
4665            $this->clipTranslator = new \Phpdftk\SvgToPdf\Translator();
4666        }
4667        return $this->clipTranslator;
4668    }
4669
4670    /**
4671     * CSS Masking 1 Â§6 / CSS Box 3 â€” resolve a `<geometry-box>` keyword to
4672     * the box rectangle `[left, top, width, height]` (top-left origin, y
4673     * down, CSS px) a clip shape is measured against. `$g->x` / `$g->y` are
4674     * the CONTENT box origin; padding / border / margin extend outward.
4675     * The SVG-only boxes map to their closest CSS box for HTML elements
4676     * (fill-box â†’ content, stroke-box / view-box â†’ border), per the spec's
4677     * fallback for elements without an SVG geometry.
4678     *
4679     * @return array{0: float, 1: float, 2: float, 3: float}
4680     */
4681    private function clipReferenceBox(\Phpdftk\HtmlToPdf\Layout\BoxGeometry $g, string $box): array
4682    {
4683        return match ($box) {
4684            'content-box', 'fill-box' => [
4685                $g->x,
4686                $g->y,
4687                $g->width,
4688                $g->height,
4689            ],
4690            'padding-box' => [
4691                $g->x - $g->paddingLeft,
4692                $g->y - $g->paddingTop,
4693                $g->paddingLeft + $g->width + $g->paddingRight,
4694                $g->paddingTop + $g->height + $g->paddingBottom,
4695            ],
4696            'margin-box' => [
4697                $g->x - $g->paddingLeft - $g->borderLeft - $g->marginLeft,
4698                $g->y - $g->paddingTop - $g->borderTop - $g->marginTop,
4699                $g->marginLeft + $g->borderLeft + $g->paddingLeft + $g->width
4700                    + $g->paddingRight + $g->borderRight + $g->marginRight,
4701                $g->marginTop + $g->borderTop + $g->paddingTop + $g->height
4702                    + $g->paddingBottom + $g->borderBottom + $g->marginBottom,
4703            ],
4704            // border-box (the clip-path default), stroke-box, view-box.
4705            default => [
4706                $g->x - $g->paddingLeft - $g->borderLeft,
4707                $g->y - $g->paddingTop - $g->borderTop,
4708                $g->borderLeft + $g->paddingLeft + $g->width + $g->paddingRight + $g->borderRight,
4709                $g->borderTop + $g->paddingTop + $g->height + $g->paddingBottom + $g->borderBottom,
4710            ],
4711        };
4712    }
4713
4714    /**
4715     * One `clip` rect edge â†’ px. `auto` returns `$autoValue` (the border
4716     * edge); a `<length>` / `0` returns its value.
4717     */
4718    private function clipEdgePx(\Phpdftk\Css\Value\Value $value, float $autoValue): float
4719    {
4720        if ($value instanceof Keyword && strtolower($value->name) === 'auto') {
4721            return $autoValue;
4722        }
4723        if ($value instanceof \Phpdftk\Css\Value\Length) {
4724            return $value->value;
4725        }
4726        if ($value instanceof \Phpdftk\Css\Value\Integer
4727            || $value instanceof \Phpdftk\Css\Value\Number
4728        ) {
4729            return (float) $value->value;
4730        }
4731        return $autoValue;
4732    }
4733
4734    /**
4735     * Return true when the box's cumulative `transform` rotates the
4736     * backface forward AND `backface-visibility: hidden` is set.
4737     * We walk the box's rotate functions, summing the (signed) X /
4738     * Y rotations; the backface is forward-facing when either total
4739     * exceeds 90° (mod 360°) on the canonical face.
4740     */
4741    private function isBackfaceHidden(Box $box): bool
4742    {
4743        $visibility = $box->style->get('backface-visibility');
4744        if (!$visibility instanceof Keyword
4745            || strtolower($visibility->name) !== 'hidden'
4746        ) {
4747            return false;
4748        }
4749        $transform = $box->style->get('transform');
4750        if (!$transform instanceof \Phpdftk\Css\Value\Transform) {
4751            return false;
4752        }
4753        $cumX = 0.0;
4754        $cumY = 0.0;
4755        foreach ($transform->functions as $fn) {
4756            if (!$fn instanceof \Phpdftk\Css\Value\RotateTransform) {
4757                continue;
4758            }
4759            $axisLen = sqrt($fn->ax * $fn->ax + $fn->ay * $fn->ay + $fn->az * $fn->az);
4760            if ($axisLen <= 0.0) {
4761                continue;
4762            }
4763            // Distribute the rotation across X / Y by axis component.
4764            $cumX += $fn->angleDeg * ($fn->ax / $axisLen);
4765            $cumY += $fn->angleDeg * ($fn->ay / $axisLen);
4766        }
4767        $isFlippedX = cos(deg2rad($cumX)) < 0;
4768        $isFlippedY = cos(deg2rad($cumY)) < 0;
4769        return $isFlippedX || $isFlippedY;
4770    }
4771
4772    private function isVisibilityHidden(Box $box): bool
4773    {
4774        $value = $box->style->get('visibility');
4775        return $value instanceof Keyword
4776            && in_array(strtolower($value->name), ['hidden', 'collapse'], true);
4777    }
4778
4779    /**
4780     * Paint CSS Backgrounds 3 Â§6 `box-shadow`. Phase-1 implementation:
4781     * draws a hard-edged shadow rect (no blur â€” blur needs Filter Effects 1
4782     * which is Phase 2). Honours `<offset-x>`, `<offset-y>`, optional
4783     * `<spread-radius>`, and `<color>` (defaults to cascaded `color`).
4784     * Inset shadows are not yet emitted (would clip inward).
4785     *
4786     * Multi-shadow comma lists are read; each shadow paints in reverse
4787     * order so the first listed sits on top â€” matching CSS stacking.
4788     */
4789    private function paintBoxShadow(Box $box, ContentStream $stream, bool $insetOnly): void
4790    {
4791        $value = $box->style->get('box-shadow');
4792        if ($value === null
4793            || ($value instanceof Keyword && strtolower($value->name) === 'none')
4794        ) {
4795            return;
4796        }
4797        $shadows = $this->collectShadowLayers($value);
4798        if ($shadows === []) {
4799            return;
4800        }
4801        $defaultColor = $box->style->get('color');
4802        $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1);
4803        $geo = $box->geometry;
4804
4805        // Paint last shadow first so earlier-listed shadows sit on top.
4806        foreach (array_reverse($shadows) as $shadow) {
4807            if ($shadow['inset'] !== $insetOnly) {
4808                continue;
4809            }
4810            $color = $shadow['color'] ?? $textColor;
4811            // CSS Backgrounds 3 Â§6 â€” a fully-transparent shadow colour
4812            // contributes nothing visible; skip the paint so the
4813            // alpha=0 colour doesn't resolve through the DeviceRGB
4814            // `rg` operator (which has no alpha) and render as black.
4815            if ($color->a <= 0.0) {
4816                continue;
4817            }
4818            $spread = $shadow['spread'];
4819            if ($shadow['inset']) {
4820                $this->paintInsetShadow($geo, $shadow, $color, $stream);
4821                continue;
4822            }
4823            $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'] - $spread;
4824            $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'] - $spread;
4825            $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
4826                + $geo->borderLeft + $geo->borderRight + 2 * $spread;
4827            $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
4828                + $geo->borderTop + $geo->borderBottom + 2 * $spread;
4829            $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
4830        }
4831    }
4832
4833    /**
4834     * Paint an inset box-shadow per CSS Backgrounds 3 Â§6. The shadow
4835     * paints INSIDE the padding-box edge (not outside the border-box
4836     * like the default outset case). Offsets are inverted in effect:
4837     * a positive `offsetX` makes the shadow visible at the *left*
4838     * edge of the inside (the shadow "comes from" the +X direction).
4839     * Positive `spread` makes the visible inner shadow band thicker
4840     * by shrinking the unshaded inner rect.
4841     *
4842     * Implementation: paint the padding-box outer rect plus the
4843     * computed inner rect as two subpaths, then fill with the
4844     * even-odd rule (`f*`) so PDF leaves the inner rect transparent
4845     * and fills only the frame between them with the shadow colour.
4846     *
4847     * @param array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool} $shadow
4848     */
4849    private function paintInsetShadow(\Phpdftk\HtmlToPdf\Layout\BoxGeometry $geo, array $shadow, Color $color, ContentStream $stream): void
4850    {
4851        // Padding-box edge (one step inside the border edge).
4852        $padX = $geo->x - $geo->paddingLeft;
4853        $padTop = $geo->y - $geo->paddingTop;
4854        $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
4855        $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
4856        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
4857            return;
4858        }
4859        $spread = $shadow['spread'];
4860        // Inner unshaded rect â€” inset from the padding-box by the
4861        // offset on the corresponding side, then further by spread on
4862        // every side. CSS 2 Â§6: a positive +X offset moves the shadow
4863        // toward +X (visible at the OPPOSITE edge â€” the left), so the
4864        // inner rect's left edge advances by offsetX.
4865        $innerX = $padX + max(0.0, $shadow['offsetX']) + $spread;
4866        $innerTop = $padTop + max(0.0, $shadow['offsetY']) + $spread;
4867        $innerRight = $padX + $padWidth + min(0.0, $shadow['offsetX']) - $spread;
4868        $innerBottom = $padTop + $padHeight + min(0.0, $shadow['offsetY']) - $spread;
4869        $innerWidth = $innerRight - $innerX;
4870        $innerHeight = $innerBottom - $innerTop;
4871        if ($innerWidth <= 0.0 || $innerHeight <= 0.0) {
4872            // Spread+offset consumes the whole padding box â€” fill it
4873            // solid with the shadow colour.
4874            $this->emitRect($stream, $padX, $padTop, $padWidth, $padHeight, fill: $color);
4875            return;
4876        }
4877        // PDF Y axis is inverted vs layout. Flip both rects.
4878        $padPdfY = $this->pageHeight - $padTop - $padHeight;
4879        $innerPdfY = $this->pageHeight - $innerTop - $innerHeight;
4880        $stream->saveGraphicsState();
4881        $stream->setFillColorRGB($color->r, $color->g, $color->b);
4882        $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight);
4883        $stream->rectangle($innerX, $innerPdfY, $innerWidth, $innerHeight);
4884        $stream->fillEvenOdd();
4885        $stream->restoreGraphicsState();
4886    }
4887
4888    /**
4889     * Filter Effects 1 Â§16.1 â€” paint `filter: drop-shadow(...)` as an
4890     * offset rect behind the box. Syntax matches `box-shadow`'s
4891     * `<offset-x> <offset-y> <blur>? <color>?` minus `inset` and
4892     * `spread`. Multiple drop-shadow filters in the value list paint
4893     * back-to-front (first listed sits on top), matching CSS stacking.
4894     * Other filter primitives (`blur`, `brightness`, `grayscale`, â€¦)
4895     * silently fall through â€” they require raster pre-painting.
4896     */
4897    private function paintFilterDropShadow(Box $box, ContentStream $stream): void
4898    {
4899        $value = $box->style->get('filter');
4900        if ($value === null
4901            || ($value instanceof Keyword && strtolower($value->name) === 'none')
4902        ) {
4903            return;
4904        }
4905        $shadows = $this->collectDropShadowFilters($value);
4906        if ($shadows === []) {
4907            return;
4908        }
4909        $defaultColor = $box->style->get('color');
4910        $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1);
4911        $geo = $box->geometry;
4912        foreach (array_reverse($shadows) as $shadow) {
4913            $color = $shadow['color'] ?? $textColor;
4914            $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'];
4915            $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'];
4916            $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
4917                + $geo->borderLeft + $geo->borderRight;
4918            $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
4919                + $geo->borderTop + $geo->borderBottom;
4920            if ($width <= 0.0 || $height <= 0.0) {
4921                continue;
4922            }
4923            $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
4924        }
4925    }
4926
4927    /**
4928     * Walk a `filter` value collecting every `drop-shadow(...)` call.
4929     * Returns `[]` when no drop-shadow appears (other filter primitives
4930     * are skipped without warning).
4931     *
4932     * @return list<array{offsetX: float, offsetY: float, blur: float, color: ?Color}>
4933     */
4934    private function collectDropShadowFilters(\Phpdftk\Css\Value\Value $value): array
4935    {
4936        $out = [];
4937        // Filter post-processing typed form: Filter<list<FilterFunction>>.
4938        if ($value instanceof \Phpdftk\Css\Value\Filter) {
4939            foreach ($value->functions as $fn) {
4940                if ($fn->kind === \Phpdftk\Css\Value\FilterKind::DropShadow) {
4941                    $parsed = $this->parseDropShadowArgs($fn->args);
4942                    if ($parsed !== null) {
4943                        $out[] = $parsed;
4944                    }
4945                }
4946            }
4947            return $out;
4948        }
4949        // Legacy generic form (CssFunction / ValueList<CssFunction>) for
4950        // value-paths that bypass Parser::makeDeclaration.
4951        $items = $value instanceof \Phpdftk\Css\Value\ValueList
4952            ? $value->values
4953            : [$value];
4954        foreach ($items as $item) {
4955            if (!$item instanceof \Phpdftk\Css\Value\CssFunction) {
4956                continue;
4957            }
4958            if (strtolower($item->name) !== 'drop-shadow') {
4959                continue;
4960            }
4961            $parsed = $this->parseDropShadowArgs($item->arguments);
4962            if ($parsed !== null) {
4963                $out[] = $parsed;
4964            }
4965        }
4966        return $out;
4967    }
4968
4969    /**
4970     * Parse `drop-shadow(<offset-x> <offset-y> [<blur>] [<color>])`
4971     * arguments into a layer struct. The CSS parser wraps a
4972     * space-separated function-arg list inside a single ValueList,
4973     * so flatten one level of ValueList before scanning.
4974     *
4975     * @param list<\Phpdftk\Css\Value\Value> $args
4976     * @return array{offsetX: float, offsetY: float, blur: float, color: ?Color}|null
4977     */
4978    private function parseDropShadowArgs(array $args): ?array
4979    {
4980        $flat = [];
4981        foreach ($args as $a) {
4982            if ($a instanceof \Phpdftk\Css\Value\ValueList) {
4983                foreach ($a->values as $inner) {
4984                    $flat[] = $inner;
4985                }
4986            } else {
4987                $flat[] = $a;
4988            }
4989        }
4990        $color = null;
4991        $lengths = [];
4992        foreach ($flat as $a) {
4993            if ($a instanceof Color) {
4994                $color = $a;
4995                continue;
4996            }
4997            if ($a instanceof \Phpdftk\Css\Value\Length) {
4998                $lengths[] = $a->value;
4999                continue;
5000            }
5001            if ($a instanceof \Phpdftk\Css\Value\Integer
5002                || $a instanceof \Phpdftk\Css\Value\Number
5003            ) {
5004                $lengths[] = (float) $a->value;
5005            }
5006        }
5007        if (count($lengths) < 2) {
5008            return null;
5009        }
5010        return [
5011            'offsetX' => $lengths[0],
5012            'offsetY' => $lengths[1],
5013            'blur' => $lengths[2] ?? 0.0,
5014            'color' => $color,
5015        ];
5016    }
5017
5018    /**
5019     * Parse the value list(s) into per-shadow layer arrays.
5020     *
5021     * @return list<array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}>
5022     */
5023    private function collectShadowLayers(\Phpdftk\Css\Value\Value $value): array
5024    {
5025        if ($value instanceof \Phpdftk\Css\Value\ValueList
5026            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
5027        ) {
5028            $layers = [];
5029            foreach ($value->values as $item) {
5030                $parsed = $this->parseShadowLayer($item);
5031                if ($parsed !== null) {
5032                    $layers[] = $parsed;
5033                }
5034            }
5035            return $layers;
5036        }
5037        $single = $this->parseShadowLayer($value);
5038        return $single === null ? [] : [$single];
5039    }
5040
5041    /**
5042     * @return array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}|null
5043     */
5044    private function parseShadowLayer(\Phpdftk\Css\Value\Value $value): ?array
5045    {
5046        $components = $value instanceof \Phpdftk\Css\Value\ValueList
5047            ? $value->values
5048            : [$value];
5049
5050        $inset = false;
5051        $color = null;
5052        $lengths = [];
5053        foreach ($components as $c) {
5054            if ($c instanceof Keyword && strtolower($c->name) === 'inset') {
5055                $inset = true;
5056                continue;
5057            }
5058            if ($c instanceof Color) {
5059                $color = $c;
5060                continue;
5061            }
5062            if ($c instanceof \Phpdftk\Css\Value\Length) {
5063                $lengths[] = $c->value;
5064                continue;
5065            }
5066            // CSS Values 4 Â§6.2: a unitless `0` is a valid zero-length
5067            // wherever a length is expected. Accept Integer/Number
5068            // values as zero (and treat non-zero numerics as 0 â€” the
5069            // grammar requires a unit otherwise).
5070            if ($c instanceof \Phpdftk\Css\Value\Integer
5071                || $c instanceof \Phpdftk\Css\Value\Number
5072            ) {
5073                $lengths[] = (float) $c->value;
5074            }
5075        }
5076        if (count($lengths) < 2) {
5077            return null;
5078        }
5079        return [
5080            'offsetX' => $lengths[0],
5081            'offsetY' => $lengths[1],
5082            'blur' => $lengths[2] ?? 0.0,
5083            'spread' => $lengths[3] ?? 0.0,
5084            'color' => $color,
5085            'inset' => $inset,
5086        ];
5087    }
5088
5089    /**
5090     * Resolve the box's cascaded `opacity`. Returns the page-level
5091     * `ExtGState` resource name to invoke for partial opacity, or null
5092     * when opacity is full (1.0) or the painter wasn't given a Page
5093     * reference. Opacity affects this box plus every descendant since
5094     * the `gs` operator persists until the matching `Q`.
5095     */
5096    private function resolveOpacityGsName(Box $box): ?string
5097    {
5098        if ($this->page === null) {
5099            return null;
5100        }
5101        $value = $box->style->get('opacity');
5102        $alpha = match (true) {
5103            $value instanceof \Phpdftk\Css\Value\Number => $value->value,
5104            $value instanceof \Phpdftk\Css\Value\Integer => (float) $value->value,
5105            default => 1.0,
5106        };
5107        $alpha = max(0.0, min(1.0, $alpha));
5108        if ($alpha >= 0.999) {
5109            return null;
5110        }
5111        return $this->page->ensureOpacityState($alpha, $alpha);
5112    }
5113
5114    /**
5115     * Paint a CSS Lists 3 list marker (the `::marker` pseudo) for boxes
5116     * with `display: list-item`, honouring `list-style-type` for the
5117     * three geometric markers (`disc` / `circle` / `square`). Counter-
5118     * style markers (`decimal`, `lower-alpha`, etc.) require font
5119     * rendering of the running counter and live in Phase 2; they fall
5120     * through to a `disc` (filled circle) here. Marker colour follows
5121     * the cascaded `color`.
5122     */
5123    private function paintListMarker(Box $box, ContentStream $stream): void
5124    {
5125        $display = $box->style->get('display');
5126        if (!$display instanceof Keyword || strtolower($display->name) !== 'list-item') {
5127            return;
5128        }
5129        // HTML Â§15.3.9 â€” a `<fieldset>` is a list item that draws no
5130        // marker. Flagged at box generation; see Box::$suppressesListMarker.
5131        if ($box->suppressesListMarker) {
5132            return;
5133        }
5134        // CSS Lists 3 Â§3.3 â€” `list-style-position: inside` markers are
5135        // INLINE content, not a box painted alongside the principal box.
5136        // BoxGenerator materialises those as a text child so they take
5137        // part in line layout; painting one here too would double them.
5138        $position = $box->style->get('list-style-position');
5139        if ($position instanceof Keyword && strtolower($position->name) === 'inside') {
5140            return;
5141        }
5142        $typeValue = $box->style->get('list-style-type');
5143        $type = $typeValue instanceof Keyword ? strtolower($typeValue->name) : 'disc';
5144        if ($type === 'none') {
5145            return;
5146        }
5147
5148        $color = $box->style->get('color');
5149        $markerColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
5150        $fontSize = $this->dominantFontSize($box);
5151
5152        // Counter-style markers â€” formatted text, requires a registered font.
5153        $counterText = $this->formatCounterMarker($box, $type);
5154        if ($counterText !== null && $this->defaultFont !== null) {
5155            $this->paintCounterMarker($box, $stream, $markerColor, $fontSize, $counterText);
5156            return;
5157        }
5158
5159        $size = max(2.0, $fontSize / 3.0);
5160        $x = $box->geometry->x - max(6.0, $fontSize * 0.5);
5161        $layoutY = $box->geometry->y + $fontSize * 0.35;
5162        $pdfY = $this->pageHeight - $layoutY - $size;
5163
5164        $stream->saveGraphicsState();
5165        $stream->setFillColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
5166        $stream->setStrokeColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
5167        match ($type) {
5168            'circle' => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: false),
5169            'square' => $this->paintMarkerSquare($stream, $x, $pdfY, $size),
5170            default => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: true),
5171        };
5172        $stream->restoreGraphicsState();
5173    }
5174
5175    /**
5176     * Format the marker text for counter-style list-style-types. Returns
5177     * null for geometric / unknown / `none` types (caller paints the
5178     * geometric stand-in or skips).
5179     */
5180    private function formatCounterMarker(Box $box, string $type): ?string
5181    {
5182        $index = $this->listItemIndex($box);
5183        if ($index < 1) {
5184            return null;
5185        }
5186        $supported = [
5187            'decimal', 'decimal-leading-zero',
5188            'lower-alpha', 'lower-latin', 'upper-alpha', 'upper-latin',
5189            'lower-roman', 'upper-roman',
5190        ];
5191        if (!in_array(strtolower($type), $supported, true)) {
5192            return null;
5193        }
5194        return \Phpdftk\HtmlToPdf\Layout\CounterFormat::format($index, $type) . '.';
5195    }
5196
5197    /**
5198     * The ordinal `$box`'s `::marker` counts with. Returns 0 when the box
5199     * is not a numbered list item, which the caller reads as "no marker".
5200     *
5201     * The number itself is decided during box generation and parked on
5202     * {@see Box::$listItemOrdinal} â€” HTML's "list owner" is the nearest
5203     * ancestor `ol` / `ul` / `menu` THAT GENERATES BOXES, and an item that
5204     * generates no boxes consumes no ordinal, so the answer is not
5205     * recoverable from the DOM at paint time. Reading the same field the
5206     * `inside` marker was built from is also what keeps the two marker
5207     * positions numbering a list identically.
5208     */
5209    private function listItemIndex(Box $box): int
5210    {
5211        return $box->listItemOrdinal ?? 0;
5212    }
5213
5214    /**
5215     * Paint a counter-style marker â€” shapes the text against the
5216     * registered font and emits a Tj at the marker position.
5217     */
5218    private function paintCounterMarker(
5219        Box $box,
5220        ContentStream $stream,
5221        Color $color,
5222        float $fontSize,
5223        string $text,
5224    ): void {
5225        $font = $this->defaultFont;
5226        if (!$font instanceof WriterFont) {
5227            return;
5228        }
5229        // CSS Lists 3 Â§3.2 â€” a counter marker is ordinary shaped text, so
5230        // ANY parsed face will do. `ShapingContext` takes the `FontFaceData`
5231        // base type; narrowing to `OpenTypeData` (the CFF flavour) silently
5232        // dropped every numbered marker whenever the default face was a
5233        // TrueType/`glyf` font â€” i.e. for DejaVu, Ahem and most web fonts.
5234        $otd = $font->getParsedData();
5235        if (!$otd instanceof \Phpdftk\FontParser\FontFaceData) {
5236            return;
5237        }
5238        $shaper = new \Phpdftk\Text\Shaper();
5239        $shapedRun = $shaper->shapeRun(
5240            $text,
5241            new \Phpdftk\Text\ShapingContext($otd, $fontSize),
5242        );
5243        if ($shapedRun->glyphs === []) {
5244            return;
5245        }
5246        $ascent = ($otd->ascent / max(1, $otd->unitsPerEm)) * $fontSize;
5247        // Right-align the marker so it sits just to the left of the box content.
5248        $width = $shapedRun->totalAdvance;
5249        $x = $box->geometry->x - $width - max(2.0, $fontSize * 0.2);
5250        $baselineY = $box->geometry->y + $ascent;
5251        $pdfY = $this->pageHeight - $baselineY;
5252
5253        $hex = '';
5254        $gidMap = $font->getOldToNewGidMap();
5255        foreach ($shapedRun->glyphs as $g) {
5256            $hex .= sprintf('%04X', $gidMap[$g->glyphId] ?? $g->glyphId);
5257        }
5258
5259        $stream->saveGraphicsState();
5260        $stream->setFillColorRGB($color->r, $color->g, $color->b);
5261        $stream->beginText();
5262        $stream->setFont($font, $fontSize);
5263        $stream->setTextMatrix(1, 0, 0, 1, $x, $pdfY);
5264        $stream->showTextHex($hex);
5265        $stream->endText();
5266        $stream->restoreGraphicsState();
5267    }
5268
5269    private function paintMarkerSquare(ContentStream $stream, float $x, float $y, float $size): void
5270    {
5271        $stream->rectangle($x, $y, $size, $size);
5272        $stream->fill();
5273    }
5274
5275    /**
5276     * Approximate a circle inside the bounding box (x, y, size, size) with
5277     * four cubic Bézier curves. The classic offset constant for unit-radius
5278     * approximation is `0.5522847498` â€” keeps the curve within â‰ˆ 0.027% of
5279     * the true circle, more than enough at marker scale.
5280     */
5281    private function paintMarkerCircle(
5282        ContentStream $stream,
5283        float $x,
5284        float $y,
5285        float $size,
5286        bool $fill,
5287    ): void {
5288        $r = $size / 2.0;
5289        $cx = $x + $r;
5290        $cy = $y + $r;
5291        $k = $r * 0.5522847498307933;
5292        $stream->moveTo($cx + $r, $cy);
5293        $stream->curveTo($cx + $r, $cy + $k, $cx + $k, $cy + $r, $cx, $cy + $r);
5294        $stream->curveTo($cx - $k, $cy + $r, $cx - $r, $cy + $k, $cx - $r, $cy);
5295        $stream->curveTo($cx - $r, $cy - $k, $cx - $k, $cy - $r, $cx, $cy - $r);
5296        $stream->curveTo($cx + $k, $cy - $r, $cx + $r, $cy - $k, $cx + $r, $cy);
5297        $stream->closePath();
5298        if ($fill) {
5299            $stream->fill();
5300        } else {
5301            $stream->setLineWidth(max(0.4, $r / 6.0));
5302            $stream->stroke();
5303        }
5304    }
5305
5306    private function dominantFontSize(Box $box): float
5307    {
5308        $value = $box->style->get('font-size');
5309        if ($value instanceof \Phpdftk\Css\Value\Length) {
5310            return $value->value;
5311        }
5312        return 12.0;
5313    }
5314
5315    /**
5316     * Emit glyphs for every {@see InlineFragment} in this box's line boxes.
5317     * Requires a {@see RegisteredFont} on the painter â€” without one, text
5318     * painting is a no-op so block + border content still renders.
5319     *
5320     * Coordinates: layout space is top-down; PDF text positioning is
5321     * baseline-relative in bottom-up space. The baseline sits at
5322     * `lineBox.y + ascent` where ascent = (font.ascent / unitsPerEm) Ã—
5323     * fontSize. The painter converts to PDF Y by subtracting from
5324     * `$this->pageHeight`.
5325     */
5326    private function paintLineBoxes(Box $box, ContentStream $stream): void
5327    {
5328        // Text emission needs a registered font to look up. Skip
5329        // entirely when neither the registered map nor the explicit
5330        // default supplies a candidate Tf resource for any fragment â€”
5331        // the result is a no-op text pass so background/border
5332        // content still renders.
5333        if ($box->lineBoxes === []
5334            || ($this->defaultFont === null && $this->registeredFonts === [])
5335        ) {
5336            return;
5337        }
5338        $color = $box->style->get('color');
5339        $textColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
5340
5341        $shadows = $this->collectTextShadowLayers($box, $textColor);
5342        // CSS 2.1 Appendix E â€” inline content paints in TREE order, so each
5343        // line's background goes down immediately before its own glyphs
5344        // rather than every background preceding every glyph. The difference
5345        // shows once a background can extend past its line: an inline whose
5346        // vertical padding bleeds upward has to cover the previous line's
5347        // text, which it cannot do if that text is painted afterwards.
5348        foreach ($box->lineBoxes as $line) {
5349            // Inline backgrounds (`<mark>` and friends) paint as a strip
5350            // behind each fragment carrying a `backgroundColor`, before this
5351            // line's text + shadow passes so its own glyphs sit on top.
5352            $this->paintInlineBackgrounds($box, $line, $stream);
5353            // Paint shadow layers behind the real text. CSS Text Decoration 4
5354            // Â§6 says the first listed shadow is painted on top, so we
5355            // reverse the list for the back-to-front emission order.
5356            foreach (array_reverse($shadows) as $shadow) {
5357                $this->paintLine(
5358                    $box,
5359                    $line,
5360                    $stream,
5361                    $shadow['color'],
5362                    $shadow['offsetX'],
5363                    $shadow['offsetY'],
5364                );
5365            }
5366            $this->paintLine($box, $line, $stream, $textColor);
5367            $this->paintTextDecorations($box, $line, $stream, $textColor);
5368        }
5369    }
5370
5371    /**
5372     * Paint per-fragment inline background rectangles. Each fragment that
5373     * carries a `backgroundColor` (propagated from an inline element like
5374     * `<mark>` whose cascade sets `background-color`) gets a filled rect
5375     * spanning the fragment's width and the line's height. Adjacent
5376     * fragments with the same colour are merged so we emit one wider rect
5377     * per run of same-colour fragments â€” cheaper output without sub-pixel
5378     * gaps from neighbouring fills.
5379     */
5380    private function paintInlineBackgrounds(Box $box, LineBox $line, ContentStream $stream): void
5381    {
5382        if ($line->fragments === []) {
5383            return;
5384        }
5385        // Coalesce contiguous fragments that share a background colour into
5386        // single rects so we emit cheaper output without sub-pixel gaps.
5387        /** @var list<array{x: float, width: float, color: Color, above: float, below: float}> $runs */
5388        $runs = [];
5389        foreach ($line->fragments as $fragment) {
5390            $bg = $fragment->backgroundColor;
5391            // A fully transparent background paints nothing â€” the CSS
5392            // `background-color` initial is `transparent` (rgba(0,0,0,0)),
5393            // which every inline box computes and propagates to its
5394            // fragments, so without this guard a plain nested inline (or
5395            // generated counter/marker content) fills an opaque BLACK rect
5396            // over its own text. Mirrors the alpha guards already at
5397            // paintBoxShadow and boxHasPaintableBackground.
5398            if ($bg === null || $bg->a <= 0.0) {
5399                continue;
5400            }
5401            $last = $runs === [] ? null : $runs[array_key_last($runs)];
5402            $sameAsLast = $last !== null
5403                && abs($last['x'] + $last['width'] - $fragment->x) < 0.001
5404                && $last['color']->r === $bg->r
5405                && $last['color']->g === $bg->g
5406                && $last['color']->b === $bg->b
5407                && $last['above'] === $fragment->bgExtendAbove
5408                && $last['below'] === $fragment->bgExtendBelow;
5409            if ($sameAsLast) {
5410                $runs[array_key_last($runs)]['width'] = ($fragment->x + $fragment->width) - $last['x'];
5411            } else {
5412                $runs[] = [
5413                    'x' => $fragment->x,
5414                    'width' => $fragment->width,
5415                    'color' => $bg,
5416                    'above' => $fragment->bgExtendAbove,
5417                    'below' => $fragment->bgExtendBelow,
5418                ];
5419            }
5420        }
5421        if ($runs === []) {
5422            return;
5423        }
5424        $pdfY = $this->pageHeight - ($box->geometry->y + $line->y + $line->height);
5425        foreach ($runs as $run) {
5426            $stream->saveGraphicsState();
5427            $stream->setFillColorRGB($run['color']->r, $run['color']->g, $run['color']->b);
5428            // CSS 2.1 Â§10.6.1 â€” an inline box's vertical padding and border
5429            // do not grow the line box, but they ARE painted, so the
5430            // background bleeds over the lines above and below rather than
5431            // being clipped to this one.
5432            $stream->rectangle(
5433                $box->geometry->x + $run['x'],
5434                $pdfY - $run['below'],
5435                $run['width'],
5436                $line->height + $run['above'] + $run['below'],
5437            );
5438            $stream->fill();
5439            $stream->restoreGraphicsState();
5440        }
5441    }
5442
5443    /**
5444     * Parse the cascaded `text-shadow` value into layer entries. Returns
5445     * an empty array when text-shadow is `none` or absent.
5446     *
5447     * @return list<array{offsetX: float, offsetY: float, color: Color}>
5448     */
5449    private function collectTextShadowLayers(Box $box, Color $fallback): array
5450    {
5451        $value = $box->style->get('text-shadow');
5452        if ($value === null
5453            || ($value instanceof Keyword && strtolower($value->name) === 'none')
5454        ) {
5455            return [];
5456        }
5457        $layers = [];
5458        $items = $value instanceof \Phpdftk\Css\Value\ValueList
5459            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
5460            ? $value->values
5461            : [$value];
5462        foreach ($items as $item) {
5463            $components = $item instanceof \Phpdftk\Css\Value\ValueList ? $item->values : [$item];
5464            $lengths = [];
5465            $color = null;
5466            foreach ($components as $c) {
5467                if ($c instanceof \Phpdftk\Css\Value\Length) {
5468                    $lengths[] = $c->value;
5469                } elseif ($c instanceof Color) {
5470                    $color = $c;
5471                }
5472            }
5473            if (count($lengths) < 2) {
5474                continue;
5475            }
5476            $layers[] = [
5477                'offsetX' => $lengths[0],
5478                'offsetY' => $lengths[1],
5479                'color' => $color ?? $fallback,
5480            ];
5481        }
5482        return $layers;
5483    }
5484
5485    private function paintLine(
5486        Box $box,
5487        LineBox $line,
5488        ContentStream $stream,
5489        Color $color,
5490        float $offsetX = 0.0,
5491        float $offsetY = 0.0,
5492    ): void {
5493        if ($line->fragments === []) {
5494            return;
5495        }
5496        $stream->saveGraphicsState();
5497        $stream->setFillColorRGB($color->r, $color->g, $color->b);
5498        $stream->beginText();
5499        $activeColor = $color;
5500        foreach ($line->fragments as $fragment) {
5501            // Inline-level `color` override: when the fragment carries its
5502            // own colour (typically `<a>` inheriting blue from the UA
5503            // stylesheet inside a black `<p>`), reseat the fill colour.
5504            $fragColor = $fragment->textColor ?? $color;
5505            if ($fragColor !== $activeColor) {
5506                $stream->setFillColorRGB($fragColor->r, $fragColor->g, $fragColor->b);
5507                $activeColor = $fragColor;
5508            }
5509            $this->paintFragment($box, $line, $fragment, $stream, $fragColor, $offsetX, $offsetY);
5510        }
5511        // Reset rendering mode in case the last fragment left it set.
5512        $stream->setTextRenderingMode(0);
5513        $stream->endText();
5514        $stream->restoreGraphicsState();
5515    }
5516
5517    /**
5518     * Paint text-decoration lines (underline / overline / line-through) for
5519     * every fragment whose parent style sets `text-decoration-line` to a
5520     * non-`none` value.
5521     *
5522     * Position approximation per CSS Text Decoration 3 Â§3:
5523     *  - underline:   baseline + 0.15 Ã— fontSize
5524     *  - overline:    baseline âˆ’ ascent (top of em box)
5525     *  - line-through: baseline âˆ’ 0.3 Ã— fontSize (~x-height middle)
5526     * Thickness: `fontSize / 14`.
5527     *
5528     * The fallback approximations stand in until `phpdftk/font-parser`
5529     * exposes the OS/2 sTypoUnderlinePosition / underlineThickness fields.
5530     */
5531    /**
5532     * Resolved `text-decoration-skip-spaces` mode: `none` (decorate
5533     * through all white space), `all` (skip every white-space run), or
5534     * `start end` â€” the initial value, and the fallback for any value
5535     * we do not model.
5536     *
5537     * @return 'none'|'all'|'start end'
5538     */
5539    private function decorationSkipSpaces(Box $box): string
5540    {
5541        $value = $box->style->get('text-decoration-skip-spaces');
5542        if (!($value instanceof Keyword)) {
5543            return 'start end';
5544        }
5545        return match (strtolower($value->name)) {
5546            'none' => 'none',
5547            'all' => 'all',
5548            default => 'start end',
5549        };
5550    }
5551
5552    private function paintTextDecorations(Box $box, LineBox $line, ContentStream $stream, Color $color): void
5553    {
5554        $blockLines = $this->textDecorationLines($box);
5555        $decoColor = $this->textDecorationColor($box, $color);
5556        // CSS Text Decoration 4 Â§5 â€” `text-decoration-skip-spaces`.
5557        // The initial `start end` keeps decorations off the white space
5558        // at each END of the line while still drawing through interior
5559        // spaces, so locate the first and last fragments with ink.
5560        $skipSpaces = $this->decorationSkipSpaces($box);
5561        $firstInk = null;
5562        $lastInk = null;
5563        if ($skipSpaces !== 'none') {
5564            foreach ($line->fragments as $inkIndex => $inkFragment) {
5565                if (!$inkFragment->isWhitespace) {
5566                    $firstInk ??= $inkIndex;
5567                    $lastInk = $inkIndex;
5568                }
5569            }
5570        }
5571        foreach ($line->fragments as $fragmentIndex => $fragment) {
5572            if ($fragment->isWhitespace && $skipSpaces !== 'none') {
5573                if ($skipSpaces === 'all'
5574                    || $firstInk === null
5575                    || $lastInk === null
5576                    || $fragmentIndex < $firstInk
5577                    || $fragmentIndex > $lastInk
5578                ) {
5579                    continue;
5580                }
5581            }
5582            // CSS Text Decoration 4 Â§2: a fragment's effective decoration
5583            // is the union of inherited (from inline ancestors) + block-
5584            // level lines. Block-level wins for color since the value
5585            // doesn't inherit through inlines.
5586            $lines = array_values(array_unique(array_merge($blockLines, $fragment->decorationLines)));
5587            if ($lines === []) {
5588                continue;
5589            }
5590            $shapedRun = $fragment->shapedRun;
5591            if ($shapedRun->glyphs === [] && $fragment->width <= 0.0) {
5592                continue;
5593            }
5594            $fontSize = $shapedRun->fontSizePt;
5595            $font = $shapedRun->font;
5596            $unitsPerEm = max(1, $font->unitsPerEm);
5597            $ascent = ($font->ascent / $unitsPerEm) * $fontSize;
5598            // Real OS/2 underline metrics when available; fall back to the
5599            // 1G.3 approximation otherwise.
5600            $underlineOffset = $font->underlinePosition !== null
5601                ? -($font->underlinePosition / $unitsPerEm) * $fontSize
5602                : 0.15 * $fontSize;
5603            $thickness = $font->underlineThickness !== null
5604                ? max(0.5, ($font->underlineThickness / $unitsPerEm) * $fontSize)
5605                : max(0.5, $fontSize / 14.0);
5606            // CSS Text Decoration 4 Â§4 â€” `text-decoration-thickness`
5607            // explicit Length / Percentage overrides the font metric.
5608            // `auto` defers to the metric above.
5609            // CSS Text Decoration 4 Â§4 â€” these belong to the box that
5610            // INTRODUCED the decoration. When the block itself declares
5611            // no lines, the decoration came from an inline ancestor via
5612            // `$fragment->decorationLines`, and applying the block's
5613            // own thickness / style to it is simply reading the wrong
5614            // box â€” the same reasoning `$fragment->decorationColor`
5615            // already follows below.
5616            $explicitThickness = $blockLines === []
5617                ? null
5618                : $this->resolveDecorationThickness($box, $fontSize);
5619            if ($explicitThickness !== null) {
5620                $thickness = max(0.5, $explicitThickness);
5621            }
5622            // `text-underline-offset` shifts the underline ONLY (not
5623            // overline or line-through). Positive values push the line
5624            // further below the baseline.
5625            $explicitUnderlineOffset = $this->resolveUnderlineOffset($box, $fontSize);
5626            $x = $box->geometry->x + $fragment->x;
5627            $width = $fragment->width;
5628            // Anchor decorations to the fragment's painted baseline: the
5629            // line's shared baseline plus the fragment's own vertical-align
5630            // shift (matching paintFragment). The underline/overline offsets
5631            // below stay relative to the fragment's own ascent.
5632            $baselineY = $box->geometry->y + $line->y + $line->baseline + $fragment->baselineShift;
5633            $style = $blockLines === [] ? 'solid' : $this->textDecorationStyle($box);
5634            // Per CSS Text Decoration 4 Â§3, the decoration colour follows
5635            // the *originating* element's `text-decoration-color` (when
5636            // explicitly set) â€” fall back to the fragment's `color` (so an
5637            // inline `<a>` with cascaded `color: blue` paints a blue
5638            // underline) and finally to the block-level value resolved at
5639            // the outer paint context.
5640            $effectiveColor = $fragment->decorationColor
5641                ?? $fragment->textColor
5642                ?? $decoColor;
5643            foreach ($lines as $lineKind) {
5644                $offsetY = match ($lineKind) {
5645                    'underline' => $underlineOffset + ($explicitUnderlineOffset ?? 0.0),
5646                    'overline' => -$ascent,
5647                    // The offset is the band's TOP, so centring a
5648                    // line-through on the x-height middle means lifting
5649                    // it by half its own thickness â€” otherwise a thick
5650                    // line drifts downward off the glyphs.
5651                    'line-through' => -0.3 * $fontSize - $thickness / 2.0,
5652                    default => 0.0,
5653                };
5654                $layoutY = $baselineY + $offsetY;
5655                $pdfY = $this->pageHeight - $layoutY - $thickness;
5656                $this->emitDecorationStyled($stream, $x, $pdfY, $width, $thickness, $effectiveColor, $style);
5657            }
5658        }
5659    }
5660
5661    /**
5662     * Emit one text-decoration line in the given style. `solid` is one
5663     * rect; `double` is two parallel rects with a small gap; `dashed`
5664     * and `dotted` emit a series of segment rects; `wavy` strokes a
5665     * cubic-Bezier-approximated sine wave at the decoration position.
5666     */
5667    private function emitDecorationStyled(
5668        ContentStream $stream,
5669        float $x,
5670        float $pdfY,
5671        float $width,
5672        float $thickness,
5673        Color $color,
5674        string $style,
5675    ): void {
5676        if ($style === 'wavy') {
5677            $this->emitWavyDecoration($stream, $x, $pdfY, $width, $thickness, $color);
5678            return;
5679        }
5680        $stream->saveGraphicsState();
5681        $stream->setFillColorRGB($color->r, $color->g, $color->b);
5682        switch ($style) {
5683            case 'double':
5684                $gap = max(0.5, $thickness);
5685                $stream->rectangle($x, $pdfY, $width, $thickness);
5686                $stream->rectangle($x, $pdfY - $gap - $thickness, $width, $thickness);
5687                $stream->fill();
5688                break;
5689            case 'dashed':
5690                $segment = max(2.0, $thickness * 3);
5691                $gap = max(1.5, $thickness * 2);
5692                for ($cx = $x; $cx < $x + $width; $cx += $segment + $gap) {
5693                    $w = min($segment, $x + $width - $cx);
5694                    $stream->rectangle($cx, $pdfY, $w, $thickness);
5695                }
5696                $stream->fill();
5697                break;
5698            case 'dotted':
5699                $dotSize = max(1.0, $thickness);
5700                $gap = $dotSize * 1.2;
5701                for ($cx = $x; $cx < $x + $width; $cx += $dotSize + $gap) {
5702                    $w = min($dotSize, $x + $width - $cx);
5703                    $stream->rectangle($cx, $pdfY, $w, $thickness);
5704                }
5705                $stream->fill();
5706                break;
5707            default: // solid
5708                $stream->rectangle($x, $pdfY, $width, $thickness);
5709                $stream->fill();
5710        }
5711        $stream->restoreGraphicsState();
5712    }
5713
5714    /**
5715     * Stroke a sine-wave-shaped text decoration line, approximated by
5716     * cubic Bezier curves. Two Beziers per period (one half-cycle up,
5717     * one half-cycle down). The wave's period is `thickness Ã— 6` and
5718     * amplitude is `thickness Ã— 0.7` â€” these tune the look to match
5719     * the wavy spell-check underlines that browsers render.
5720     */
5721    private function emitWavyDecoration(
5722        ContentStream $stream,
5723        float $x,
5724        float $pdfY,
5725        float $width,
5726        float $thickness,
5727        Color $color,
5728    ): void {
5729        if ($width <= 0.0 || $thickness <= 0.0) {
5730            return;
5731        }
5732        $period = max(4.0, $thickness * 6.0);
5733        $amp = max(1.0, $thickness * 0.7);
5734        $strokeWidth = max(0.5, $thickness * 0.7);
5735        $stream->saveGraphicsState();
5736        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
5737        $stream->setLineWidth($strokeWidth);
5738        // Centerline of the wave sits at $pdfY + thickness/2 so the
5739        // visible band stays within the decoration's allocated band.
5740        $centerY = $pdfY + $thickness / 2.0;
5741        $stream->moveTo($x, $centerY);
5742        $halfPeriod = $period / 2.0;
5743        // Bezier control offset for a sine half-cycle (well-known
5744        // approximation: control points at 1/3 and 2/3 of the half).
5745        $cx1Offset = $halfPeriod / 3.0;
5746        $cx2Offset = ($halfPeriod * 2.0) / 3.0;
5747        $end = $x + $width;
5748        $curX = $x;
5749        $up = true;
5750        while ($curX < $end) {
5751            $segmentEnd = min($curX + $halfPeriod, $end);
5752            $controlY = $up ? $centerY + $amp : $centerY - $amp;
5753            $stream->curveTo(
5754                $curX + $cx1Offset,
5755                $controlY,
5756                $curX + $cx2Offset,
5757                $controlY,
5758                $segmentEnd,
5759                $centerY,
5760            );
5761            $curX = $segmentEnd;
5762            $up = !$up;
5763        }
5764        $stream->stroke();
5765        $stream->restoreGraphicsState();
5766    }
5767
5768    /**
5769     * Resolve CSS Text Decoration 4 Â§4 `text-decoration-thickness`.
5770     * Returns the resolved pixel value when an explicit Length or
5771     * Percentage is set (percentage is relative to the font size per
5772     * CSS UI 4 Â§6); returns null when the value is `auto` so the font
5773     * metric stays in effect.
5774     */
5775    private function resolveDecorationThickness(Box $box, float $fontSize): ?float
5776    {
5777        $value = $box->style->get('text-decoration-thickness');
5778        if ($value instanceof \Phpdftk\Css\Value\Length) {
5779            return $value->value;
5780        }
5781        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
5782            return $value->value / 100.0 * $fontSize;
5783        }
5784        return null;
5785    }
5786
5787    /**
5788     * Resolve CSS Text Decoration 4 Â§4.2 `text-underline-offset`.
5789     * Positive values push the underline further below the baseline;
5790     * `auto` (null) defers to the font-metric default.
5791     */
5792    private function resolveUnderlineOffset(Box $box, float $fontSize): ?float
5793    {
5794        $value = $box->style->get('text-underline-offset');
5795        if ($value instanceof \Phpdftk\Css\Value\Length) {
5796            return $value->value;
5797        }
5798        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
5799            return $value->value / 100.0 * $fontSize;
5800        }
5801        return null;
5802    }
5803
5804    private function textDecorationStyle(Box $box): string
5805    {
5806        $value = $box->style->get('text-decoration-style');
5807        if ($value instanceof Keyword) {
5808            $name = strtolower($value->name);
5809            if (in_array($name, ['solid', 'double', 'dashed', 'dotted', 'wavy'], true)) {
5810                return $name;
5811            }
5812        }
5813        return 'solid';
5814    }
5815
5816    /** @return list<string> */
5817    private function textDecorationLines(Box $box): array
5818    {
5819        $value = $box->style->get('text-decoration-line');
5820        if ($value === null) {
5821            return [];
5822        }
5823        $items = $value instanceof \Phpdftk\Css\Value\ValueList ? $value->values : [$value];
5824        $out = [];
5825        foreach ($items as $item) {
5826            if (!$item instanceof Keyword) {
5827                continue;
5828            }
5829            $lower = strtolower($item->name);
5830            if ($lower === 'none' || $lower === 'blink') {
5831                continue;
5832            }
5833            if (in_array($lower, ['underline', 'overline', 'line-through'], true)) {
5834                $out[] = $lower;
5835            }
5836        }
5837        return $out;
5838    }
5839
5840    private function textDecorationColor(Box $box, Color $fallback): Color
5841    {
5842        $value = $box->style->get('text-decoration-color');
5843        return $value instanceof Color ? $value : $fallback;
5844    }
5845
5846    private function paintFragment(
5847        Box $box,
5848        LineBox $line,
5849        InlineFragment $fragment,
5850        ContentStream $stream,
5851        Color $color,
5852        float $offsetX = 0.0,
5853        float $offsetY = 0.0,
5854    ): void {
5855        $shapedRun = $fragment->shapedRun;
5856        if ($shapedRun->glyphs === []) {
5857            return;
5858        }
5859        $font = $shapedRun->font;
5860        $ascent = ($font->ascent / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt;
5861        $x = $box->geometry->x + $fragment->x + $offsetX;
5862        // CSS2 Â§10.8 â€” every baseline-aligned fragment on the line shares the
5863        // line's single baseline (`line.baseline`, distance from line top),
5864        // so mixed-size runs line up instead of each sitting at its own
5865        // ascent. `baselineShift` layers `vertical-align: sub`/`super` (and,
5866        // later, the keyword offsets) on top; negative raises, positive
5867        // lowers. Add in layout-Y space, then flip to PDF-Y.
5868        $baselineY = $box->geometry->y + $line->y + $line->baseline + $offsetY + $fragment->baselineShift;
5869        $pdfY = $this->pageHeight - $baselineY;
5870
5871        // Pick the RegisteredFont matching this fragment's shaped font.
5872        // The map is keyed by OpenType postScriptName; the shaping context
5873        // chose the font, so this lookup just hands the painter the right
5874        // PDF Tf resource. Falls back to defaultFont when the fragment's
5875        // font wasn't registered (e.g., the resolver returned defaultFont
5876        // and there's no alt map).
5877        $registered = $this->registeredFonts[$font->postScriptName] ?? $this->defaultFont;
5878        if ($registered === null) {
5879            // No registered Tf resource matches this fragment's font and
5880            // no default font is set either. Emitting a `Tf` op with a
5881            // bare postScriptName yields garbage in the viewer (some
5882            // fall back to an arbitrary font, others render nothing) â€”
5883            // both cases regress reftests that previously produced an
5884            // empty page. Skip the fragment instead.
5885            return;
5886        }
5887        $stream->setFont($registered, $shapedRun->fontSizePt);
5888        // Fake-italic via a 12° skew in the Tm `c` slot (≈ tan(12°) = 0.213).
5889        // CSS Fonts 4 Â§6.4.1 lets browsers synthesise oblique from regular
5890        // when no real italic face is registered; this is the same trick.
5891        $skew = $fragment->isItalic ? 0.213 : 0.0;
5892        // CSS Writing Modes 4 Â§5 â€” for vertical writing modes,
5893        // rotate the text matrix 90° clockwise so the line's
5894        // horizontal advance (text-space x) becomes a vertical
5895        // descent in PDF (page-space -y). Anchor at the line's
5896        // TOP-LEFT corner â€” `applyVerticalLineShift` placed every
5897        // line at y = 0 and shifted fragments along x to stack
5898        // lines as parallel columns right-to-left for vrl; the
5899        // anchor here matches that placement.
5900        $wm = WritingMode::fromStyle($box->style);
5901        if ($wm->isVertical()) {
5902            // The 90°-clockwise rotation maps the glyph's ascent to
5903            // +deviceX (physical right / line-over) and its descent to
5904            // -deviceX (physical left / line-under) for BOTH vertical-lr
5905            // and vertical-rl. The alphabetic drawing baseline therefore
5906            // sits a `descent` in from the column's line-under (left)
5907            // edge, centred inside the column's cross-size (its line box
5908            // extent) with symmetric half-leading â€” so the em box lands
5909            // centred in the column rather than flush to the left edge.
5910            $descent = (abs($font->descent) / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt;
5911            $halfLeading = max(0.0, ($line->height - ($ascent + $descent)) / 2.0);
5912            if ($wm->mode === WritingMode::SIDEWAYS_LR) {
5913                // CSS Writing Modes 4 Â§3.1 â€” `sideways-lr` is the one mode
5914                // whose inline axis runs BOTTOM-to-TOP. Its glyphs rotate 90°
5915                // COUNTER-clockwise, which puts the ascent on -deviceX
5916                // (physical left / line-over) and makes the run advance up
5917                // the page, starting at the line's inline-start edge: the
5918                // container's content BOTTOM, `blockOffset` in.
5919                $columnX = $box->geometry->x + $fragment->x + $offsetX + $halfLeading + $ascent;
5920                $runStartLayoutY = $box->geometry->y + $box->geometry->height
5921                    - $line->y - $fragment->blockOffset + $offsetY;
5922                $stream->setTextMatrix(
5923                    0.0,
5924                    1.0,
5925                    -1.0,
5926                    0.0,
5927                    $columnX,
5928                    $this->pageHeight - $runStartLayoutY,
5929                );
5930            } else {
5931                $columnX = $box->geometry->x + $fragment->x + $offsetX + $halfLeading + $descent;
5932                // Increment 2: `blockOffset` places each fragment DOWN the
5933                // column by its original inline advance, so multiple
5934                // fragments in one source line stack vertically instead of
5935                // sharing the column top.
5936                $columnTopLayoutY = $box->geometry->y + $line->y + $offsetY + $fragment->blockOffset;
5937                $columnTopPdfY = $this->pageHeight - $columnTopLayoutY;
5938                $stream->setTextMatrix(0.0, -1.0, 1.0, 0.0, $columnX, $columnTopPdfY);
5939            }
5940        } else {
5941            // Tm reseats the text matrix at each fragment's left baseline,
5942            // which is simpler than tracking incremental Td offsets between
5943            // fragments.
5944            $stream->setTextMatrix(1, 0, $skew, 1, $x, $pdfY);
5945        }
5946        // Fake-bold via text rendering mode 2 (fill + stroke). Stroke
5947        // contributes â‰ˆ fontSize Ã— 0.04 of extra thickness â€” visually close
5948        // to the design-weight increment for bold. Match the stroke color
5949        // to the cascaded fill color so the bold outline doesn't bleed in a
5950        // different hue. Always re-emit the Tr so a non-bold fragment that
5951        // follows a bold one resets to fill-only.
5952        if ($color->a <= 0.0) {
5953            // CSS Color 4 â€” fully-transparent text (`color: transparent`,
5954            // alpha 0) paints no marks. `setFillColorRGB` drops alpha, so a
5955            // transparent colour reaches here as opaque black (rgb 0,0,0)
5956            // and would fill the glyphs solid. Switch to PDF text rendering
5957            // mode 3 (invisible): the glyphs still emit â€” so the text stays
5958            // extractable and contributes to the tagged-PDF structure â€” but
5959            // they leave no visible ink, matching how browsers print
5960            // transparent text. This is an extremely common WPT idiom
5961            // (Ahem "filler" text under `color: transparent`).
5962            $stream->setTextRenderingMode(3);
5963        } elseif ($fragment->isBold) {
5964            $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
5965            $stream->setLineWidth($shapedRun->fontSizePt * 0.04);
5966            $stream->setTextRenderingMode(2);
5967        } else {
5968            $stream->setTextRenderingMode(0);
5969        }
5970
5971        // Per-font GID translation: CFF subsetting renumbers glyphs in the
5972        // embedded font, so the shaper's full-font GIDs must be mapped to
5973        // the subset GIDs before emission. Each registered font has its
5974        // own gid map.
5975        $gidMap = $registered instanceof WriterFont
5976            ? $registered->getOldToNewGidMap()
5977            : [];
5978        $unitsPerEm = max(1, $font->unitsPerEm);
5979        $fontSize = $shapedRun->fontSizePt;
5980
5981        // Build a TJ array if the shaper's advances diverge from the font's
5982        // natural hmtx widths â€” that gap is the kern adjustment to encode.
5983        // Otherwise emit a plain Tj for the whole run.
5984        $items = [];
5985        $hex = '';
5986        $hasKern = false;
5987        foreach ($shapedRun->glyphs as $g) {
5988            $emitted = $gidMap[$g->glyphId] ?? $g->glyphId;
5989            $hex .= sprintf('%04X', $emitted);
5990
5991            $natural = ($font->glyphWidths[$g->glyphId] ?? 0) / $unitsPerEm * $fontSize;
5992            $delta = $natural - $g->advanceX; // positive = shaper pulled the glyph in (kern)
5993            // CSS Fonts 4 Â§3.5: `font-size: 0` is legal â€” the glyphs
5994            // still emit, but at zero advance. Skip the kern fixup so
5995            // we don't divide by zero; nothing to nudge in PDF text
5996            // space when each glyph already advances 0.
5997            $kern = $fontSize > 0.0 ? $delta * 1000.0 / $fontSize : 0.0;
5998            if (abs($kern) >= 0.5) {
5999                $items[] = $hex;
6000                $items[] = $this->snapKern($kern);
6001                $hex = '';
6002                $hasKern = true;
6003            }
6004        }
6005        if ($hex !== '') {
6006            $items[] = $hex;
6007        }
6008
6009        if ($hasKern) {
6010            $stream->showTextArrayHex($items);
6011        } else {
6012            $stream->showTextHex(implode('', array_filter($items, 'is_string')));
6013        }
6014
6015        // Inline `<a href>` â€” record the fragment's rect for /Link emission.
6016        // We only collect on the "real text" pass (offsetX / offsetY == 0)
6017        // so multi-layer text-shadow doesn't multiply the link count.
6018        if ($fragment->href !== null && $offsetX === 0.0 && $offsetY === 0.0) {
6019            $descent = abs($font->descent) / max(1, $unitsPerEm) * $fontSize;
6020            $this->collectedLinks[] = [
6021                'href' => $fragment->href,
6022                'llx' => $x,
6023                'lly' => $pdfY - $descent,
6024                'urx' => $x + $fragment->width,
6025                'ury' => $pdfY + $ascent,
6026                'title' => $fragment->linkTitle,
6027            ];
6028        }
6029    }
6030
6031    /**
6032     * Block-level `<a href>` â€” emit a single link rect covering the box's
6033     * border box. Inline `<a>` is handled inside {@see paintFragment()}.
6034     */
6035    private function collectBlockLinkRect(Box $box): void
6036    {
6037        if ($box->element === null
6038            || strtolower($box->element->localName) !== 'a'
6039        ) {
6040            return;
6041        }
6042        $href = $box->element->getAttribute('href');
6043        if ($href === null || $href === '') {
6044            return;
6045        }
6046        // Inline `<a>` already produces per-fragment rects via paintFragment;
6047        // skip when there's nothing to do at the block level.
6048        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox)
6049            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AnonymousBlockBox)
6050            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)
6051        ) {
6052            return;
6053        }
6054        $g = $box->geometry;
6055        if ($g->width <= 0.0 || $g->outerHeight() <= 0.0) {
6056            return;
6057        }
6058        $llx = $g->x;
6059        $urx = $g->x + $g->width;
6060        $ury = $this->pageHeight - $g->y;
6061        $lly = $this->pageHeight - ($g->y + $g->outerHeight());
6062        $this->collectedLinks[] = [
6063            'href' => $href,
6064            'llx' => $llx,
6065            'lly' => $lly,
6066            'urx' => $urx,
6067            'ury' => $ury,
6068            'title' => $box->element->getAttribute('title'),
6069        ];
6070    }
6071
6072    /**
6073     * Round to the nearest 0.1 unit so the emitted PDF stays compact.
6074     * PDF readers don't visually distinguish sub-tenth-unit kerns.
6075     */
6076    private function snapKern(float $kern): float|int
6077    {
6078        $rounded = round($kern, 1);
6079        return $rounded == (int) $rounded ? (int) $rounded : $rounded;
6080    }
6081
6082    private function paintBackground(Box $box, ContentStream $stream): void
6083    {
6084        // Inline-level backgrounds (InlineBox, TextBox, LineBreakBox) are
6085        // painted per-fragment by {@see paintInlineBackgrounds()}; their
6086        // own geometry is meaningless for block-style background painting
6087        // (layout doesn't size them as a single rect). AtomicInlineBox /
6088        // BlockBox / AnonymousBlockBox keep the block-style fill.
6089        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
6090            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
6091            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
6092        ) {
6093            return;
6094        }
6095        // CSS Backgrounds 3 Â§3.11.2 â€” the background-source box (root,
6096        // or the body when the root is transparent) was already painted
6097        // across the entire canvas before the tree walk; skip its own
6098        // per-box paint to avoid double-fill.
6099        if ($box === $this->propagatedBgBox) {
6100            return;
6101        }
6102        $color = $this->resolveColorWithCurrentColor(
6103            $box->style->get('background-color'),
6104            $box,
6105        );
6106        // CSS Backgrounds 3 Â§2.1 â€” `background-image` may resolve to a
6107        // comma-separated list of images (layers). The first-listed image
6108        // paints on top; later images paint below. Build the layer list
6109        // and reject any non-paintable entries (e.g. `none`).
6110        $bgImage = $box->style->get('background-image');
6111        $layers = $this->extractBackgroundLayers($bgImage);
6112        $hasColor = $color instanceof Color && $color->a > 0.0;
6113        $hasAnyLayer = $layers !== [];
6114        if (!$hasColor && !$hasAnyLayer) {
6115            return;
6116        }
6117        $geo = $box->geometry;
6118        // CSS Backgrounds 3 Â§3.5 â€” `background-clip` controls which
6119        // box edge the background paint extends to. `border-box`
6120        // (initial) reaches the outer border edge; `padding-box`
6121        // stops at the inner border edge; `content-box` stays inside
6122        // padding. We honour all three keywords.
6123        $clip = $this->resolveBackgroundClip($box);
6124        switch ($clip) {
6125            case 'content-box':
6126                $x = $geo->x;
6127                $top = $geo->y;
6128                $width = $geo->width;
6129                $height = $geo->height;
6130                break;
6131            case 'padding-box':
6132                $x = $geo->x - $geo->paddingLeft;
6133                $top = $geo->y - $geo->paddingTop;
6134                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight;
6135                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom;
6136                break;
6137            default: // 'border-box' or 'border-area' â€” both use border-box dims as the bounding rect
6138                $x = $geo->x - $geo->paddingLeft - $geo->borderLeft;
6139                $top = $geo->y - $geo->paddingTop - $geo->borderTop;
6140                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
6141                    + $geo->borderLeft + $geo->borderRight;
6142                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
6143                    + $geo->borderTop + $geo->borderBottom;
6144        }
6145        if ($hasColor) {
6146            // Border radii resolve against the border box, then reduce inward
6147            // to the background-clip box (CSS Backgrounds 3 Â§5.3), so a
6148            // `padding-box` / `content-box` background rounds its (smaller)
6149            // corners rather than reusing the full border-box radius.
6150            $borderBoxW = $geo->paddingLeft + $geo->width + $geo->paddingRight
6151                + $geo->borderLeft + $geo->borderRight;
6152            $borderBoxH = $geo->paddingTop + $geo->height + $geo->paddingBottom
6153                + $geo->borderTop + $geo->borderBottom;
6154            $radii = $this->reduceRadiiToBox(
6155                $this->borderRadiiXY($box, $borderBoxW, $borderBoxH),
6156                $clip,
6157                $geo,
6158            );
6159            // CSS Backgrounds 4 â€” `background-clip: border-area`
6160            // paints only on the border ring. Emit border-box rect
6161            // âˆª padding-box rect with the even-odd fill rule so the
6162            // inner padding-box is left unfilled (a ring of bg).
6163            // Radii are ignored on this branch â€” rounded-corner
6164            // ring support is a follow-up.
6165            if ($clip === 'border-area') {
6166                $padX = $geo->x - $geo->paddingLeft;
6167                $padTop = $geo->y - $geo->paddingTop;
6168                $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
6169                $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
6170                $outerPdfY = $this->pageHeight - $top - $height;
6171                $padPdfY = $this->pageHeight - $padTop - $padHeight;
6172                $stream->saveGraphicsState();
6173                $stream->setFillColorRGB($color->r, $color->g, $color->b);
6174                $stream->rectangle($x, $outerPdfY, $width, $height);
6175                $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight);
6176                $stream->fillEvenOdd();
6177                $stream->restoreGraphicsState();
6178            } elseif ($this->radiiAnyPositive($radii)) {
6179                $this->emitRoundedFill($stream, $x, $top, $width, $height, $radii, $color, $this->cornerShapes($box));
6180            } else {
6181                $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
6182            }
6183        }
6184        // All three image-class properties resolve against the same
6185        // bg-origin / bg-size / bg-position trio. Hoist once so the
6186        // gradient branches can reuse the rects computed for the
6187        // raster path.
6188        $needBgImageProps = $hasAnyLayer && $width > 0.0 && $height > 0.0;
6189        // CSS Backgrounds 4 Â§3.5 â€” `border-area` paints bg-image
6190        // ONLY inside the border ring. Wrap the image-paint cluster
6191        // in a graphics state + even-odd clip path so paint inside
6192        // the padding-box is masked off. Rectangle radii are still
6193        // a follow-up (the rounded-ring case needs Bezier path
6194        // intersection beyond the rect-only clip below).
6195        $needBorderAreaClip = $needBgImageProps && $clip === 'border-area';
6196        if ($needBorderAreaClip) {
6197            $padX = $geo->x - $geo->paddingLeft;
6198            $padTop = $geo->y - $geo->paddingTop;
6199            $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
6200            $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
6201            $outerPdfYClip = $this->pageHeight - $top - $height;
6202            $padPdfYClip = $this->pageHeight - $padTop - $padHeight;
6203            $stream->saveGraphicsState();
6204            $stream->rectangle($x, $outerPdfYClip, $width, $height);
6205            $stream->rectangle($padX, $padPdfYClip, $padWidth, $padHeight);
6206            $stream->clipEvenOdd();
6207            $stream->endPath();
6208        }
6209        if ($needBgImageProps) {
6210            // Per CSS 2.1 Â§14.2.1, when fewer values are supplied than
6211            // images the values cycle. Split each property into a comma
6212            // list and index modulo its length.
6213            $sizeList = $this->extractCommaList($box->style->get('background-size'));
6214            $positionList = $this->extractCommaList($box->style->get('background-position'));
6215            $repeatList = $this->extractCommaList($box->style->get('background-repeat'));
6216            $originRect = $this->backgroundOriginRect(
6217                $box,
6218                $this->resolveBackgroundOrigin($box),
6219            );
6220            // CSS Backgrounds 3 Â§3.10 â€” first-listed image is topmost;
6221            // walk the layer list in reverse so the topmost ends up
6222            // painted last.
6223            $count = count($layers);
6224            for ($i = $count - 1; $i >= 0; $i--) {
6225                $layer = $layers[$i];
6226                $sizeValue = $sizeList === [] ? null : $sizeList[$i % count($sizeList)];
6227                $positionValue = $positionList === [] ? null : $positionList[$i % count($positionList)];
6228                $repeatValue = $repeatList === [] ? null : $repeatList[$i % count($repeatList)];
6229                if ($layer instanceof \Phpdftk\Css\Value\Url) {
6230                    $this->paintBackgroundImage(
6231                        $layer,
6232                        $stream,
6233                        $x,
6234                        $top,
6235                        $width,
6236                        $height,
6237                        $sizeValue,
6238                        $positionValue,
6239                        $repeatValue,
6240                        $originRect,
6241                    );
6242                } elseif ($layer instanceof \Phpdftk\Css\Value\LinearGradient) {
6243                    if ($this->isDefaultGradientSize($sizeValue)) {
6244                        $this->paintLinearGradient($layer, $stream, $x, $top, $width, $height);
6245                    } else {
6246                        // Non-default `background-size` tiles the gradient
6247                        // across the box per `background-repeat` (no-repeat
6248                        // reduces to one positioned tile).
6249                        $this->paintTiledLinearGradient(
6250                            $layer,
6251                            $stream,
6252                            $x,
6253                            $top,
6254                            $width,
6255                            $height,
6256                            $originRect,
6257                            $sizeValue,
6258                            $positionValue,
6259                            $repeatValue,
6260                        );
6261                    }
6262                } elseif ($layer instanceof \Phpdftk\Css\Value\RadialGradient) {
6263                    // A non-default `background-size` tiles the gradient into
6264                    // cells; we tile linear gradients (above) but not radial
6265                    // yet â€” a radial with translucent stops (the common tiled
6266                    // case) still floods rather than fades, so painting the
6267                    // tiled grid would unmask worse than skipping. Paint the
6268                    // single box-filling gradient only at the default size.
6269                    if ($this->isDefaultGradientSize($sizeValue)) {
6270                        $this->paintRadialGradient($layer, $stream, $x, $top, $width, $height);
6271                    }
6272                } elseif ($layer instanceof \Phpdftk\Css\Value\ConicGradient) {
6273                    $this->paintConicGradient($layer, $stream, $x, $top, $width, $height);
6274                } elseif (($imgArg = $this->imageFunctionColorArg($layer)) !== null) {
6275                    $imgColor = $this->resolveColorWithCurrentColor($imgArg, $box);
6276                    if ($imgColor instanceof Color) {
6277                        $this->paintColorImage($stream, $imgColor, $sizeValue, $positionValue, $repeatValue, $x, $top, $width, $height, $originRect);
6278                    }
6279                }
6280            }
6281        }
6282        if ($needBorderAreaClip) {
6283            $stream->restoreGraphicsState();
6284        }
6285    }
6286
6287    /**
6288     * Flatten a `background-image` value into the list of paintable
6289     * layers (Url / LinearGradient / RadialGradient). A bare value
6290     * becomes a single-element list; a comma-separated `ValueList`
6291     * is expanded. `none` keywords and other unsupported entries
6292     * are skipped.
6293     *
6294     * @return list<\Phpdftk\Css\Value\Url|\Phpdftk\Css\Value\LinearGradient|\Phpdftk\Css\Value\RadialGradient|\Phpdftk\Css\Value\ConicGradient|\Phpdftk\Css\Value\CssFunction>
6295     */
6296    private function extractBackgroundLayers(mixed $value): array
6297    {
6298        if ($value instanceof \Phpdftk\Css\Value\ValueList
6299            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
6300        ) {
6301            $layers = [];
6302            foreach ($value->values as $v) {
6303                $layer = $this->resolveBackgroundLayer($v);
6304                if ($layer !== null) {
6305                    $layers[] = $layer;
6306                }
6307            }
6308            return $layers;
6309        }
6310        $layer = $this->resolveBackgroundLayer($value);
6311        return $layer !== null ? [$layer] : [];
6312    }
6313
6314    /**
6315     * Classify a single `background-image` list entry into the paintable
6316     * layer value the painter understands, or null when it's not one we
6317     * render. `image-set()` is unwrapped to its selected option's image
6318     * (CSS Images 4 Â§6) so a gradient / url wrapped in `image-set()`
6319     * paints identically to the bare image.
6320     *
6321     * @return \Phpdftk\Css\Value\Url|\Phpdftk\Css\Value\LinearGradient|\Phpdftk\Css\Value\RadialGradient|\Phpdftk\Css\Value\ConicGradient|\Phpdftk\Css\Value\CssFunction|null
6322     */
6323    private function resolveBackgroundLayer(mixed $value): ?\Phpdftk\Css\Value\Value
6324    {
6325        if ($value instanceof \Phpdftk\Css\Value\ImageSet) {
6326            $value = $this->selectImageSetOption($value);
6327        }
6328        // A bare-string `image-set()` option â€” `image-set("/img.png" 1x)` â€”
6329        // selects a StringValue whose <string> is equivalent to url() per
6330        // CSS Images 4 Â§6. Map it to a Url so it paints like the wrapped form.
6331        // (A StringValue can only reach here via the image-set unwrap above;
6332        // a top-level `background-image: "string"` is invalid and never yields
6333        // a bare StringValue layer.)
6334        if ($value instanceof \Phpdftk\Css\Value\StringValue) {
6335            $value = new \Phpdftk\Css\Value\Url($value->value);
6336        }
6337        if ($value instanceof \Phpdftk\Css\Value\Url
6338            || $value instanceof \Phpdftk\Css\Value\LinearGradient
6339            || $value instanceof \Phpdftk\Css\Value\RadialGradient
6340            || $value instanceof \Phpdftk\Css\Value\ConicGradient
6341            || ($value instanceof \Phpdftk\Css\Value\CssFunction
6342                && $this->imageFunctionColorArg($value) !== null)
6343        ) {
6344            return $value;
6345        }
6346        return null;
6347    }
6348
6349    /**
6350     * Pick the {@see \Phpdftk\Css\Value\ImageSetOption} that best matches
6351     * the print target (1 device-pixel-per-CSS-pixel). Prefers the entry
6352     * whose resolution is closest to 1x; a missing resolution counts as
6353     * 1x. Returns the option's image, or null for an empty set.
6354     */
6355    private function selectImageSetOption(\Phpdftk\Css\Value\ImageSet $set): ?\Phpdftk\Css\Value\Value
6356    {
6357        // CSS Images 4 Â§6: before choosing, remove every option that names an
6358        // unknown/unsupported MIME type in its `type()` hint or carries a
6359        // non-positive resolution. If none survive, the image-set() resolves
6360        // to no image (paints nothing) â€” the function stays valid, so it still
6361        // wins the cascade over an earlier `background-image`.
6362        $best = null;
6363        $bestDelta = null;
6364        foreach ($set->options as $option) {
6365            if ($option->mimeType !== null
6366                && !self::isSupportedImageSetMimeType($option->mimeType)
6367            ) {
6368                continue;
6369            }
6370            if ($option->resolutionDppx !== null && $option->resolutionDppx <= 0.0) {
6371                continue;
6372            }
6373            $delta = abs(($option->resolutionDppx ?? 1.0) - 1.0);
6374            if ($bestDelta === null || $delta < $bestDelta) {
6375                $best = $option;
6376                $bestDelta = $delta;
6377            }
6378        }
6379        return $best?->image;
6380    }
6381
6382    /**
6383     * Whether an `image-set()` `type()` MIME hint names an image format the
6384     * renderer can plausibly decode (CSS Images 4 Â§6 â€” options with an
6385     * unknown/unsupported type are removed from the set). The allowlist mirrors
6386     * the formats {@see \Phpdftk\ImageMetadata} recognises plus common raster
6387     * aliases; any non-image or invented subtype (e.g. `image/unsupported`) is
6388     * treated as unsupported.
6389     */
6390    private static function isSupportedImageSetMimeType(string $mime): bool
6391    {
6392        return in_array(strtolower(trim($mime)), [
6393            'image/png', 'image/apng', 'image/jpeg', 'image/jpg', 'image/pjpeg',
6394            'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp', 'image/tiff',
6395            'image/x-icon', 'image/vnd.microsoft.icon', 'image/avif', 'image/jp2',
6396        ], true);
6397    }
6398
6399    private function imageFunctionColorArg(mixed $value): ?\Phpdftk\Css\Value\Value
6400    {
6401        if (!($value instanceof \Phpdftk\Css\Value\CssFunction)
6402            || strtolower($value->name) !== 'image'
6403        ) {
6404            return null;
6405        }
6406        foreach ($value->arguments as $arg) {
6407            if ($arg instanceof \Phpdftk\Css\Value\Url) {
6408                return null; // url-primary image() â€” not a solid colour.
6409            }
6410            if ($arg instanceof Color) {
6411                return $arg;
6412            }
6413            // `image(currentcolor)` resolves against the element's `color`
6414            // at paint time (via resolveColorWithCurrentColor).
6415            if ($arg instanceof Keyword && strtolower($arg->name) === 'currentcolor') {
6416                return $arg;
6417            }
6418        }
6419        return null;
6420    }
6421
6422    /**
6423     * @param array{x: float, top: float, width: float, height: float} $originRect
6424     */
6425    private function paintColorImage(
6426        ContentStream $stream,
6427        Color $color,
6428        ?\Phpdftk\Css\Value\Value $sizeValue,
6429        ?\Phpdftk\Css\Value\Value $positionValue,
6430        ?\Phpdftk\Css\Value\Value $repeatValue,
6431        float $x,
6432        float $top,
6433        float $width,
6434        float $height,
6435        array $originRect,
6436    ): void {
6437        if ($color->a <= 0.0) {
6438            return;
6439        }
6440        $ow = $originRect['width'];
6441        $oh = $originRect['height'];
6442        $size = $this->resolveBackgroundSize($sizeValue, '', $ow, $oh);
6443        $tw = $size['w'];
6444        $th = $size['h'];
6445        if ($tw <= 0.0 || $th <= 0.0) {
6446            return;
6447        }
6448        if ($positionValue !== null) {
6449            $pos = $this->resolveBackgroundPosition($positionValue, $tw, $th, $ow, $oh);
6450            $offX = $pos['offsetX'];
6451            $offY = $pos['offsetY'];
6452        } else {
6453            $offX = $size['offsetX'];
6454            $offY = $size['offsetY'];
6455        }
6456        $repeat = $this->repeatAxes($repeatValue);
6457        $rx = $repeat['x'] ? $x : $originRect['x'] + $offX;
6458        $rw = $repeat['x'] ? $width : $tw;
6459        $ry = $repeat['y'] ? $top : $originRect['top'] + $offY;
6460        $rh = $repeat['y'] ? $height : $th;
6461        $stream->saveGraphicsState();
6462        $stream->rectangle($x, $this->pageHeight - $top - $height, $width, $height);
6463        $stream->clip();
6464        $stream->endPath();
6465        $this->emitRect($stream, $rx, $ry, $rw, $rh, fill: $color);
6466        $stream->restoreGraphicsState();
6467    }
6468
6469    /**
6470     * Split a CSS property value into a list of per-layer values. A
6471     * comma `ValueList` is exploded into its components; any other
6472     * value is wrapped in a single-element list. Null inputs return
6473     * an empty list so the caller can detect "no value supplied".
6474     *
6475     * @return list<\Phpdftk\Css\Value\Value>
6476     */
6477    private function extractCommaList(mixed $value): array
6478    {
6479        if (!$value instanceof \Phpdftk\Css\Value\Value) {
6480            return [];
6481        }
6482        if ($value instanceof \Phpdftk\Css\Value\ValueList
6483            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
6484        ) {
6485            return array_values($value->values);
6486        }
6487        return [$value];
6488    }
6489
6490    /**
6491     * Paint a CSS `radial-gradient([<shape> <size>] [at <position>], <stops>)`
6492     * as the box's background. Phase-1 simplification: only the first
6493     * and last stops are honoured (PDF's basic ShadingType3 is two-stop),
6494     * the box centre is used when no `at <position>` is supplied, and
6495     * `circle` shapes default to half the box's smaller side while
6496     * `ellipse` shapes get half the box's dimensions per axis.
6497     *
6498     * Because PDF's radial shading is a *circular* primitive, ellipse
6499     * gradients are approximated by scaling the user-space matrix so a
6500     * unit circle becomes an ellipse â€” the gradient still expands
6501     * outward proportionally.
6502     */
6503    /**
6504     * CSS Images 4 Â§3.5 â€” paint a `conic-gradient()` over the box rect.
6505     * PDF has no angular shading, so this routes through a function-based
6506     * ShadingType-1 whose PostScript calculator maps each point to its
6507     * sweep angle and interpolates the stops
6508     * ({@see \Phpdftk\Pdf\Writer\PdfDoc::addConicShadingStops()}).
6509     */
6510    private function paintConicGradient(
6511        \Phpdftk\Css\Value\ConicGradient $gradient,
6512        ContentStream $stream,
6513        float $x,
6514        float $top,
6515        float $width,
6516        float $height,
6517    ): void {
6518        if ($gradient->stops === []) {
6519            return;
6520        }
6521        if (count($gradient->stops) === 1) {
6522            $this->fillGradientSolidStop($gradient->stops[0], $stream, $x, $top, $width, $height);
6523            return;
6524        }
6525        if ($this->writer === null || $this->page === null) {
6526            return;
6527        }
6528        if ($width <= 0.0 || $height <= 0.0) {
6529            return;
6530        }
6531        $pdfY = $this->pageHeight - $top - $height;
6532        // Centre: `at <position>` fractions (default 50% 50%). centerY is
6533        // measured from the CSS top, so flip into the PDF y-up box.
6534        $cx = $x + ($gradient->centerX ?? 0.5) * $width;
6535        $cy = $pdfY + ($height - ($gradient->centerY ?? 0.5) * $height);
6536        // Conic stop positions are angular percentages of the full sweep,
6537        // so they resolve to [0,1] offsets directly (length line = 1).
6538        $stopList = $this->resolveGradientStops($gradient->stops, 1.0);
6539        if (count($stopList) < 2) {
6540            return;
6541        }
6542        try {
6543            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
6544            $pattern = $doc->addConicShadingStops(
6545                new \Phpdftk\Geometry\Point($cx, $cy),
6546                $gradient->fromAngleDeg,
6547                $stopList,
6548                [$x, $x + $width, $pdfY, $pdfY + $height],
6549                $gradient->repeating,
6550            );
6551        } catch (\Throwable) {
6552            return;
6553        }
6554        $patternName = $this->page->useGradient($pattern);
6555        $stream->saveGraphicsState();
6556        $stream->rectangle($x, $pdfY, $width, $height);
6557        $stream->clip();
6558        $stream->endPath();
6559        $stream->setFillColorSpace('Pattern');
6560        $stream->setFillColor($patternName);
6561        $stream->rectangle($x, $pdfY, $width, $height);
6562        $stream->fill();
6563        $stream->restoreGraphicsState();
6564    }
6565
6566    private function paintRadialGradient(
6567        \Phpdftk\Css\Value\RadialGradient $gradient,
6568        ContentStream $stream,
6569        float $x,
6570        float $top,
6571        float $width,
6572        float $height,
6573    ): void {
6574        if ($gradient->stops === []) {
6575            return;
6576        }
6577        // CSS Images 3 Â§3.5.1 â€” single-stop gradient is a solid fill
6578        // (no PDF writer needed, so runs before the shading writer guard).
6579        if (count($gradient->stops) === 1) {
6580            $this->fillGradientSolidStop($gradient->stops[0], $stream, $x, $top, $width, $height);
6581            return;
6582        }
6583        if ($this->writer === null || $this->page === null) {
6584            return;
6585        }
6586        if ($width <= 0.0 || $height <= 0.0) {
6587            return;
6588        }
6589        $pdfY = $this->pageHeight - $top - $height;
6590        // Centre: default to the box centre when no `at <position>` is
6591        // supplied. Author-supplied length values resolve relative to
6592        // the box's content rect. centerY is measured from the CSS top,
6593        // so flip into the PDF y-up box.
6594        $cx = $x + ($gradient->centerX !== null ? $gradient->centerX->value : $width / 2);
6595        $cy = $pdfY + ($height - ($gradient->centerY !== null ? $gradient->centerY->value : $height / 2));
6596        // Radii: prefer author lengths, otherwise default to the box
6597        // half-extent per axis. PDF's ShadingType-3 primitive is circular,
6598        // so we build a circle of radius r = max(rx, ry) and, for ellipses
6599        // (rx != ry), scale pattern space via the pattern /Matrix.
6600        //
6601        // A single authored `<length>` (`radial-gradient(25px â€¦)`) is a
6602        // *circle* of that radius per CSS Images 3 Â§3.5.1 â€” an ellipse
6603        // needs two radii â€” so mirror sizeX onto the missing sizeY rather
6604        // than falling back to the box half-height (which would stretch
6605        // the circle into a tall ellipse).
6606        $rx = $gradient->sizeX !== null ? $gradient->sizeX->value : $width / 2;
6607        if ($gradient->sizeY !== null) {
6608            $ry = $gradient->sizeY->value;
6609        } elseif ($gradient->sizeX !== null) {
6610            $ry = $rx;
6611        } else {
6612            $ry = $height / 2;
6613        }
6614        if ($rx <= 0.0 || $ry <= 0.0) {
6615            return;
6616        }
6617        $r = max($rx, $ry);
6618        // For radial, the gradient line is from centre to the ending
6619        // shape â€” its length is the outer radius (the larger axis).
6620        $stopList = $this->resolveGradientStops($gradient->stops, $r, $gradient->interpolationSpace, $gradient->hueInterpolation);
6621        // Beyond the ending shape, CSS Images 3 Â§3.5.1 paints the last
6622        // stop's colour outward to the box edge. Extend the shading only
6623        // when that last stop is opaque: a translucent final stop (e.g.
6624        // `…, transparent`) must fade OUT rather than flood the box with
6625        // the stop's RGB â€” and PDF colour shadings carry no alpha, so
6626        // extending it would paint opaque black. Leaving extend off there
6627        // stops painting past the outer circle, which reads as the
6628        // intended transparency. (Full per-stop alpha is a later soft-mask
6629        // pass â€” see the note below.)
6630        $lastStop = $gradient->stops[count($gradient->stops) - 1];
6631        $extend = $lastStop->color->a >= 1.0;
6632        // Build the concentric-circle shading in ABSOLUTE page coordinates
6633        // (inner radius 0, outer r at the box centre) with an identity
6634        // pattern matrix. A shading pattern anchors via its own /Matrix
6635        // relative to the page default coordinate system and IGNORES the
6636        // current CTM, so â€” like the linear path â€” we must place the
6637        // shading at real page coordinates rather than relying on
6638        // `concatMatrix`. (The old CTM-based placement left the shading at
6639        // the page origin, painting nothing.)
6640        try {
6641            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
6642            $pattern = $doc->addRadialGradientStops(
6643                new \Phpdftk\Geometry\Point($cx, $cy),
6644                0.0,
6645                new \Phpdftk\Geometry\Point($cx, $cy),
6646                $r,
6647                $stopList,
6648                $extend,
6649            );
6650        } catch (\Throwable) {
6651            return;
6652        }
6653        // Elliptical ending shape: scale pattern space about the centre so
6654        // the radius-r circle becomes an rx Ã— ry ellipse. Scaling P about
6655        // C by s is P' = s·P + C(1−s); the pattern /Matrix carries that.
6656        if ($rx !== $ry) {
6657            $sx = $rx / $r;
6658            $sy = $ry / $r;
6659            $pattern->matrix = new \Phpdftk\Pdf\Core\PdfArray([
6660                new \Phpdftk\Pdf\Core\PdfNumber($sx),
6661                new \Phpdftk\Pdf\Core\PdfNumber(0.0),
6662                new \Phpdftk\Pdf\Core\PdfNumber(0.0),
6663                new \Phpdftk\Pdf\Core\PdfNumber($sy),
6664                new \Phpdftk\Pdf\Core\PdfNumber($cx - $sx * $cx),
6665                new \Phpdftk\Pdf\Core\PdfNumber($cy - $sy * $cy),
6666            ]);
6667        }
6668        // NOTE: per-stop alpha is not yet honoured for radial gradients.
6669        // A Luminosity soft mask analogous to the linear path (see
6670        // {@see paintLinearGradient()}) would reuse the same absolute
6671        // anchoring; the writer primitive
6672        // ({@see \Phpdftk\Pdf\Writer\PdfDoc::addRadialAlphaShading()})
6673        // exists for when that is wired up.
6674        $patternName = $this->page->useGradient($pattern);
6675        $stream->saveGraphicsState();
6676        $stream->rectangle($x, $pdfY, $width, $height);
6677        $stream->clip();
6678        $stream->endPath();
6679        $stream->setFillColorSpace('Pattern');
6680        $stream->setFillColor($patternName);
6681        $stream->rectangle($x, $pdfY, $width, $height);
6682        $stream->fill();
6683        $stream->restoreGraphicsState();
6684    }
6685
6686    /**
6687     * CSS Images 3 Â§3.5.1 â€” paint a single-colour-stop gradient as a
6688     * solid rectangle fill of that stop's colour. `$top` / `$height` are
6689     * CSS coordinates (y down); flip to PDF space for the rectangle.
6690     */
6691    private function fillGradientSolidStop(
6692        \Phpdftk\Css\Value\GradientStop $stop,
6693        ContentStream $stream,
6694        float $x,
6695        float $top,
6696        float $width,
6697        float $height,
6698    ): void {
6699        if ($width <= 0.0 || $height <= 0.0) {
6700            return;
6701        }
6702        $color = $stop->color;
6703        $stream->saveGraphicsState();
6704        $stream->setFillColorRGB($color->r, $color->g, $color->b);
6705        $stream->rectangle($x, $this->pageHeight - $top - $height, $width, $height);
6706        $stream->fill();
6707        $stream->restoreGraphicsState();
6708    }
6709
6710    /**
6711     * Normalise CSS gradient stops to PDF `{offset, rgb}` tuples per
6712     * CSS Images 3 Â§3.5.1: unspecified positions distribute evenly
6713     * between adjacent positioned stops; the first stop defaults to
6714     * offset 0 and the last to 1. After normalisation, offsets are
6715     * monotonically non-decreasing in [0, 1].
6716     *
6717     * @param list<\Phpdftk\Css\Value\GradientStop> $stops
6718     * @param float $gradientLineLength Length of the gradient line in
6719     *   user units. Used to convert `<length>` stop positions to
6720     *   fractional [0, 1] offsets. Pass 0 to skip length-position
6721     *   resolution (degrades length-positioned stops to "no authored
6722     *   position" â€” the spec's interpolation algorithm fills them in).
6723     * @return list<array{offset: float, rgb: array{float, float, float}, alpha: float}>
6724     */
6725    private function resolveGradientStops(
6726        array $stops,
6727        float $gradientLineLength = 0.0,
6728        ?ColorSpace $interpSpace = null,
6729        ?HueInterpolation $hueInterp = null,
6730    ): array {
6731        $count = count($stops);
6732        if ($count === 0) {
6733            return [];
6734        }
6735        // Step 1: pull positions where authored. `<percentage>` â†’
6736        // [0,1] fraction directly. `<length>` divides by the gradient
6737        // line length to get a fraction; when the line length is
6738        // unknown (0), length-positioned stops fall through to the
6739        // interpolation step like unset positions.
6740        $offsets = array_fill(0, $count, null);
6741        foreach ($stops as $i => $s) {
6742            if ($s->position instanceof \Phpdftk\Css\Value\Percentage) {
6743                $offsets[$i] = max(0.0, min(1.0, $s->position->value / 100.0));
6744            } elseif ($s->position instanceof \Phpdftk\Css\Value\Length && $gradientLineLength > 0.0) {
6745                $offsets[$i] = max(0.0, min(1.0, $s->position->value / $gradientLineLength));
6746            }
6747        }
6748        // Step 2: anchor unset endpoints at 0/1.
6749        if ($offsets[0] === null) {
6750            $offsets[0] = 0.0;
6751        }
6752        if ($offsets[$count - 1] === null) {
6753            $offsets[$count - 1] = 1.0;
6754        }
6755        // Step 3: monotonic clamp â€” each stop's offset must be â‰¥ the
6756        // previous stop's offset.
6757        $prev = 0.0;
6758        foreach ($offsets as $i => $o) {
6759            if ($o !== null) {
6760                $offsets[$i] = max($o, $prev);
6761                $prev = $offsets[$i];
6762            }
6763        }
6764        // Step 4: linearly interpolate runs of unset offsets between
6765        // adjacent anchored stops.
6766        $i = 0;
6767        while ($i < $count) {
6768            if ($offsets[$i] !== null) {
6769                $i++;
6770                continue;
6771            }
6772            $start = $i - 1;
6773            $end = $i;
6774            while ($end < $count && $offsets[$end] === null) {
6775                $end++;
6776            }
6777            // $start â‰¥ 0 (anchored), $end < $count (anchored at last).
6778            $startOffset = $offsets[$start];
6779            $endOffset = $offsets[$end];
6780            $span = $endOffset - $startOffset;
6781            $gap = $end - $start;
6782            for ($j = $start + 1; $j < $end; $j++) {
6783                $offsets[$j] = $startOffset + $span * ($j - $start) / $gap;
6784            }
6785            $i = $end;
6786        }
6787        // Step 5: build entries carrying the authored Colour + resolved
6788        // offset (the colour is converted to sRGB â€” or resampled in the
6789        // interpolation space â€” in step 7).
6790        $entries = [];
6791        foreach ($stops as $i => $s) {
6792            $entries[] = ['offset' => (float) $offsets[$i], 'color' => $s->color];
6793        }
6794        // Step 6: pad the ends so the stop list always spans [0, 1] with the
6795        // terminal colours held flat. CSS Images 3 Â§3.5.1 â€” before the first
6796        // stop the gradient is the first colour, after the last stop it is
6797        // the last colour. The PDF stitching function assumes its input runs
6798        // first-stop â†’ last-stop, so a last stop authored at e.g. 70% (`…,
6799        // green 0` clamped up, or `…, green 70%`) would otherwise stretch
6800        // the final segment across the remaining 30% instead of holding a
6801        // solid band. Anchoring synthetic end stops keeps the colour flat.
6802        if ($entries[0]['offset'] > 0.0) {
6803            array_unshift($entries, ['offset' => 0.0, 'color' => $entries[0]['color']]);
6804        }
6805        $lastEntry = $entries[count($entries) - 1];
6806        if ($lastEntry['offset'] < 1.0) {
6807            $entries[] = ['offset' => 1.0, 'color' => $lastEntry['color']];
6808        }
6809        // Step 7: flatten to {offset, rgb, alpha}, resampling in the
6810        // interpolation space when the gradient names one.
6811        return $this->flattenGradientStops($entries, $interpSpace, $hueInterp);
6812    }
6813
6814    /**
6815     * Flatten resolved stop entries to the `{offset, rgb, alpha}` tuples the
6816     * PDF writer consumes. Each colour is converted to sRGB. When `$space`
6817     * is an explicit interpolation space PDF cannot express natively (i.e.
6818     * anything but sRGB), each segment is densely resampled via
6819     * {@see ColorConverter::interpolate()} so the shading's linear DeviceRGB
6820     * interpolation between samples tracks the true in-space curve
6821     * (CSS Color 4 Â§12 / CSS Images 4 Â§3.1). Otherwise stops are emitted
6822     * verbatim and the shading interpolates them linearly â€” the default.
6823     *
6824     * @param  list<array{offset: float, color: Color}> $entries
6825     * @return list<array{offset: float, rgb: array{float, float, float}, alpha: float}>
6826     */
6827    private function flattenGradientStops(array $entries, ?ColorSpace $space, ?HueInterpolation $hue): array
6828    {
6829        if ($space === null || !$this->interpolationSpaceNeedsResample($space)) {
6830            $out = [];
6831            foreach ($entries as $e) {
6832                $c = ColorConverter::toSrgb($e['color']);
6833                $out[] = ['offset' => $e['offset'], 'rgb' => [$c->r, $c->g, $c->b], 'alpha' => $c->a];
6834            }
6835            return $out;
6836        }
6837        $out = [];
6838        $n = count($entries);
6839        for ($k = 0; $k < $n - 1; $k++) {
6840            $a = $entries[$k];
6841            $b = $entries[$k + 1];
6842            if ($k === 0) {
6843                $ca = ColorConverter::toSrgb($a['color']);
6844                $out[] = ['offset' => $a['offset'], 'rgb' => [$ca->r, $ca->g, $ca->b], 'alpha' => $ca->a];
6845            }
6846            $span = $b['offset'] - $a['offset'];
6847            if ($span > 1e-9) {
6848                $steps = $this->gradientSampleSteps($space, $hue);
6849                for ($m = 1; $m < $steps; $m++) {
6850                    $t = $m / $steps;
6851                    $ci = ColorConverter::interpolate($a['color'], $b['color'], $t, $space, $hue);
6852                    $out[] = ['offset' => $a['offset'] + $span * $t, 'rgb' => [$ci->r, $ci->g, $ci->b], 'alpha' => $ci->a];
6853                }
6854            }
6855            $cb = ColorConverter::toSrgb($b['color']);
6856            $out[] = ['offset' => $b['offset'], 'rgb' => [$cb->r, $cb->g, $cb->b], 'alpha' => $cb->a];
6857        }
6858        return $out;
6859    }
6860
6861    /**
6862     * Whether interpolating in `$space` requires pre-sampling. sRGB (and any
6863     * space without a forward conversion) matches PDF's native DeviceRGB
6864     * interpolation, so no resampling is needed.
6865     */
6866    private function interpolationSpaceNeedsResample(ColorSpace $space): bool
6867    {
6868        return match ($space) {
6869            ColorSpace::OKLab, ColorSpace::OKLCH, ColorSpace::Lab, ColorSpace::Lch,
6870            ColorSpace::HSL, ColorSpace::HWB, ColorSpace::sRGBLinear,
6871            ColorSpace::XYZ, ColorSpace::XYZD65, ColorSpace::DisplayP3 => true,
6872            default => false,
6873        };
6874    }
6875
6876    /**
6877     * Samples per gradient segment when resampling in `$space`. Polar spaces
6878     * (hue channel) get more samples â€” and `longer`/`increasing`/`decreasing`
6879     * hue can traverse up to a full turn â€” so the piecewise-linear sRGB
6880     * approximation stays smooth.
6881     */
6882    private function gradientSampleSteps(ColorSpace $space, ?HueInterpolation $hue): int
6883    {
6884        $isPolar = match ($space) {
6885            ColorSpace::OKLCH, ColorSpace::Lch, ColorSpace::HSL, ColorSpace::HWB => true,
6886            default => false,
6887        };
6888        if (!$isPolar) {
6889            return 16;
6890        }
6891        return $hue === HueInterpolation::Longer
6892            || $hue === HueInterpolation::Increasing
6893            || $hue === HueInterpolation::Decreasing ? 64 : 32;
6894    }
6895
6896    /**
6897     * True when any stop of a gradient carries alpha < 1 â€” the case a
6898     * plain PDF colour shading cannot express (shadings have no alpha
6899     * channel). Such gradients need a Luminosity soft mask built from
6900     * the per-stop alpha (see {@see applyGradientAlphaMask()}).
6901     *
6902     * @param list<\Phpdftk\Css\Value\GradientStop> $stops
6903     */
6904    private function gradientHasAlpha(array $stops): bool
6905    {
6906        foreach ($stops as $s) {
6907            if ($s->color->a < 1.0) {
6908                return true;
6909            }
6910        }
6911        return false;
6912    }
6913
6914    /**
6915     * Map a resolved stop list (offset + rgb + alpha) to the gray-stop
6916     * list {@see \Phpdftk\Pdf\Writer\PdfDoc::addLinearAlphaShading()}
6917     * consumes: gray = alpha, so the Luminosity mask is opaque where
6918     * the gradient is opaque and transparent where it is transparent.
6919     *
6920     * @param list<array{offset: float, rgb: array{float, float, float}, alpha: float}> $stopList
6921     * @return list<array{offset: float, gray: float}>
6922     */
6923    private function alphaStopsFrom(array $stopList): array
6924    {
6925        $out = [];
6926        foreach ($stopList as $s) {
6927            $out[] = ['offset' => $s['offset'], 'gray' => $s['alpha']];
6928        }
6929        return $out;
6930    }
6931
6932    /**
6933     * Build a Luminosity soft-mask ExtGState carrying a gradient's
6934     * per-stop alpha and return its resource name (or null when the
6935     * mask can't be built). `$registerShading` registers the DeviceGray
6936     * alpha shading (linear or radial) on the PdfDoc and returns its
6937     * object number; the mask group paints it with `sh`, clipped to the
6938     * clip rect, in absolute page coordinates â€” a soft-mask group
6939     * inherits the CTM in effect when its `gs` is set, and the painter
6940     * paints gradients under an identity CTM. Emit `/<name> gs` inside
6941     * the gradient's save/restore scope before the pattern fill so the
6942     * alpha modulates the colour shading (ISO 32000-2 Â§11.6.5.2).
6943     *
6944     * @param \Closure(\Phpdftk\Pdf\Writer\PdfDoc): int $registerShading
6945     */
6946    private function buildGradientAlphaMaskState(
6947        \Phpdftk\Pdf\Writer\PdfDoc $doc,
6948        \Closure $registerShading,
6949        float $clipX,
6950        float $clipPdfY,
6951        float $clipWidth,
6952        float $clipHeight,
6953    ): ?string {
6954        if ($this->page === null || $clipWidth <= 0.0 || $clipHeight <= 0.0) {
6955            return null;
6956        }
6957        try {
6958            $shadingObjNum = $registerShading($doc);
6959            $bbox = new \Phpdftk\Geometry\Rectangle($clipX, $clipPdfY, $clipWidth, $clipHeight);
6960            $group = $doc->createTransparencyGroup(
6961                $bbox,
6962                function (
6963                    ContentStream $cs,
6964                    \Phpdftk\Pdf\Core\Content\Resources $res,
6965                ) use ($shadingObjNum, $clipX, $clipPdfY, $clipWidth, $clipHeight): void {
6966                    $res->shading['Sh0'] = new \Phpdftk\Pdf\Core\PdfReference($shadingObjNum);
6967                    $cs->rectangle($clipX, $clipPdfY, $clipWidth, $clipHeight);
6968                    $cs->clip();
6969                    $cs->endPath();
6970                    $cs->paintShading('Sh0');
6971                },
6972            );
6973            return $this->page->ensureSoftMaskState($group, 'Luminosity');
6974        } catch (\Throwable) {
6975            return null;
6976        }
6977    }
6978
6979    /**
6980     * Resolve the stop list of a `repeating-linear-gradient` to one
6981     * cycle of in-cycle offsets âˆˆ [0, 1], the cycle length in absolute
6982     * pixels along the gradient line, and the first stop's absolute
6983     * pixel position. Returns `null` when the cycle is degenerate
6984     * (zero-length or fewer than two stops) â€” callers fall through to
6985     * the non-repeating path.
6986     *
6987     * Mirrors the position-resolution algorithm in
6988     * {@see resolveGradientStops()} but keeps positions in absolute
6989     * px (length stops can exceed the gradient line length when an
6990     * author writes `… 100px` on a 50-px ray) rather than clamping to
6991     * [0, 1].
6992     *
6993     * @param  list<\Phpdftk\Css\Value\GradientStop> $stops
6994     * @return array{list<array{offset: float, rgb: array{float, float, float}}>, float, float}|null
6995     */
6996    private function resolveRepeatingLinearCycle(array $stops, float $gradientLineLength): ?array
6997    {
6998        $count = count($stops);
6999        if ($count < 2) {
7000            return null;
7001        }
7002        $positions = array_fill(0, $count, null);
7003        foreach ($stops as $i => $s) {
7004            if ($s->position instanceof \Phpdftk\Css\Value\Length) {
7005                $positions[$i] = $s->position->value;
7006            } elseif ($s->position instanceof \Phpdftk\Css\Value\Percentage) {
7007                $positions[$i] = $s->position->value / 100.0 * $gradientLineLength;
7008            }
7009        }
7010        if ($positions[0] === null) {
7011            $positions[0] = 0.0;
7012        }
7013        if ($positions[$count - 1] === null) {
7014            $positions[$count - 1] = $gradientLineLength;
7015        }
7016        // Monotonic clamp â€” each explicit position must be â‰¥ prior.
7017        $prev = -INF;
7018        foreach ($positions as $i => $p) {
7019            if ($p !== null) {
7020                $positions[$i] = max($p, $prev);
7021                $prev = $positions[$i];
7022            }
7023        }
7024        // Interpolate runs of unset positions between anchored stops.
7025        $i = 0;
7026        while ($i < $count) {
7027            if ($positions[$i] !== null) {
7028                $i++;
7029                continue;
7030            }
7031            $start = $i - 1;
7032            $end = $i;
7033            while ($end < $count && $positions[$end] === null) {
7034                $end++;
7035            }
7036            $span = $positions[$end] - $positions[$start];
7037            $gap = $end - $start;
7038            for ($j = $start + 1; $j < $end; $j++) {
7039                $positions[$j] = $positions[$start] + $span * ($j - $start) / $gap;
7040            }
7041            $i = $end;
7042        }
7043        $firstPos = (float) $positions[0];
7044        $lastPos = (float) $positions[$count - 1];
7045        $cycleLength = $lastPos - $firstPos;
7046        if ($cycleLength <= 0.0) {
7047            return null;
7048        }
7049        $inCycle = [];
7050        foreach ($stops as $i => $s) {
7051            $inCycle[] = [
7052                'offset' => ((float) $positions[$i] - $firstPos) / $cycleLength,
7053                'rgb' => [$s->color->r, $s->color->g, $s->color->b],
7054            ];
7055        }
7056        return [$inCycle, $cycleLength, $firstPos];
7057    }
7058
7059    /**
7060     * Extend the gradient axis to cover the clip rect's projection onto
7061     * the gradient line in whole cycles, and emit a stop list that
7062     * replicates the in-cycle stops once per cycle across the extended
7063     * [0, 1] domain.
7064     *
7065     * @param  array{list<array{offset: float, rgb: array{float, float, float}}>, float, float} $cycle
7066     * @return array{float, float, float, float, list<array{offset: float, rgb: array{float, float, float}}>}
7067     */
7068    private function buildRepeatingLinearAxis(
7069        array $cycle,
7070        float $startPdfX,
7071        float $startPdfY,
7072        float $dx,
7073        float $dy,
7074        float $clipX,
7075        float $clipPdfY,
7076        float $clipWidth,
7077        float $clipHeight,
7078    ): array {
7079        [$inCycleStops, $cycleLength, $firstPos] = $cycle;
7080        $tMin = INF;
7081        $tMax = -INF;
7082        foreach (
7083            [
7084                [$clipX,             $clipPdfY],
7085                [$clipX + $clipWidth, $clipPdfY],
7086                [$clipX,             $clipPdfY + $clipHeight],
7087                [$clipX + $clipWidth, $clipPdfY + $clipHeight],
7088            ] as [$px, $py]
7089        ) {
7090            $t = ($px - $startPdfX) * $dx + ($py - $startPdfY) * $dy;
7091            if ($t < $tMin) {
7092                $tMin = $t;
7093            }
7094            if ($t > $tMax) {
7095                $tMax = $t;
7096            }
7097        }
7098        $kMin = (int) floor(($tMin - $firstPos) / $cycleLength);
7099        $kMax = (int) ceil(($tMax - $firstPos) / $cycleLength) - 1;
7100        if ($kMax < $kMin) {
7101            $kMax = $kMin;
7102        }
7103        // Defensive cap. A clip-extent / cycle ratio over 500 means
7104        // either a wildly short cycle or a huge clip rect; either way
7105        // the result is visually indistinguishable past that count and
7106        // not worth the pattern bytes.
7107        $maxCycles = 500;
7108        $cycles = $kMax - $kMin + 1;
7109        if ($cycles > $maxCycles) {
7110            $kMax = $kMin + $maxCycles - 1;
7111            $cycles = $maxCycles;
7112        }
7113        $extStartPos = $firstPos + $kMin * $cycleLength;
7114        $extEndPos = $firstPos + ($kMax + 1) * $cycleLength;
7115        $extStartX = $startPdfX + $extStartPos * $dx;
7116        $extStartY = $startPdfY + $extStartPos * $dy;
7117        $extEndX = $startPdfX + $extEndPos * $dx;
7118        $extEndY = $startPdfY + $extEndPos * $dy;
7119        $stopList = [];
7120        for ($k = 0; $k < $cycles; $k++) {
7121            foreach ($inCycleStops as $s) {
7122                $stopList[] = [
7123                    'offset' => ($k + $s['offset']) / $cycles,
7124                    'rgb' => $s['rgb'],
7125                ];
7126            }
7127        }
7128        return [$extStartX, $extStartY, $extEndX, $extEndY, $stopList];
7129    }
7130
7131    /**
7132     * Paint a CSS `linear-gradient(<angle>|to <side>, <stops>)` as the
7133     * box's background. Phase-1 simplification: only the first and last
7134     * stop colours are honoured (PDF's basic shading dictionary is
7135     * two-stop). The gradient line orientation comes from the CSS angle
7136     * (CSS direction: 0deg = upward, 90deg = rightward, 180deg = down,
7137     * 270deg = leftward; angles increase clockwise).
7138     */
7139    /**
7140     * Paint a CSS `linear-gradient(...)` as a box background.
7141     *
7142     * The (tileX, tileTop, tileWidth, tileHeight) rect is the
7143     * **gradient's positioning + sizing area** â€” gradient line and
7144     * stop offsets resolve against it (CSS Images 3 Â§3.1, Â§3.5.1).
7145     * `clipRect`, when supplied, scopes the actual paint to a
7146     * different area (e.g. the background-clip rect when bg-size
7147     * specifies an explicit tile smaller than the box). Defaults to
7148     * the tile rect so callers that pass only one rect get the
7149     * previous behaviour.
7150     *
7151     * @param array{0: float, 1: float, 2: float, 3: float}|null $clipRect
7152     *   Layout-space rect [x, top, width, height]. Null = same as tile.
7153     */
7154    private function paintLinearGradient(
7155        \Phpdftk\Css\Value\LinearGradient $gradient,
7156        ContentStream $stream,
7157        float $tileX,
7158        float $tileTop,
7159        float $tileWidth,
7160        float $tileHeight,
7161        ?array $clipRect = null,
7162    ): void {
7163        if ($gradient->stops === []) {
7164            return;
7165        }
7166        if ($tileWidth <= 0.0 || $tileHeight <= 0.0) {
7167            return;
7168        }
7169        [$clipX, $clipTop, $clipWidth, $clipHeight] = $clipRect
7170            ?? [$tileX, $tileTop, $tileWidth, $tileHeight];
7171        // CSS Images 3 Â§3.5.1 â€” a gradient with a single colour-stop
7172        // renders as a solid fill of that colour. PDF's ShadingType-2/3
7173        // stitching function needs â‰¥2 stops, so short-circuit to a plain
7174        // rectangle fill (the shading path would otherwise throw and paint
7175        // nothing, exposing the element's fallback background). This needs
7176        // no PDF writer, so it runs before the shading-only writer guard.
7177        if (count($gradient->stops) === 1) {
7178            $this->fillGradientSolidStop(
7179                $gradient->stops[0],
7180                $stream,
7181                $clipX,
7182                $clipTop,
7183                $clipWidth,
7184                $clipHeight,
7185            );
7186            return;
7187        }
7188        if ($this->writer === null) {
7189            return;
7190        }
7191        $tilePdfY = $this->pageHeight - $tileTop - $tileHeight;
7192        $clipPdfY = $this->pageHeight - $clipTop - $clipHeight;
7193        // CSS angle convention: 0deg points up, increases clockwise. The
7194        // gradient line passes through the centre of the *tile*. Compute
7195        // its start and end points on the tile's edge per CSS Images 3 Â§3.1.
7196        // A `to <corner>` direction's angle depends on the tile's aspect
7197        // ratio (the gradient line is perpendicular to the diagonal joining
7198        // the other two corners), so resolve it here where the tile size
7199        // is known rather than using the parser's square-box fallback.
7200        $rawAngle = $gradient->corner !== null
7201            ? $gradient->corner->angleFor($tileWidth, $tileHeight)
7202            : $gradient->angleDeg;
7203        $angle = fmod($rawAngle, 360.0);
7204        if ($angle < 0.0) {
7205            $angle += 360.0;
7206        }
7207        $rad = deg2rad($angle);
7208        $cx = $tileX + $tileWidth / 2;
7209        $cy = $tilePdfY + $tileHeight / 2;
7210        // Gradient line half-length so the endpoints sit on the tile
7211        // boundary corners (CSS spec): l/2 = |W sin Î¸| + |H cos Î¸| / 2
7212        $sin = sin($rad);
7213        $cos = cos($rad);
7214        $halfLen = (abs($tileWidth * $sin) + abs($tileHeight * $cos)) / 2;
7215        // Full line length is twice the half â€” what `<length>` stops
7216        // resolve against (CSS Images 3 Â§3.5.1).
7217        $lineLength = $halfLen * 2;
7218        // The CSS convention rotates the gradient line such that 0deg
7219        // points UP (towards the box top). In PDF space the y-axis
7220        // grows upward already (after our flip), so "up" is +y.
7221        $dx = $sin;
7222        $dy = $cos;
7223        $startPdfX = $cx - $dx * $halfLen;
7224        $startPdfY = $cy - $dy * $halfLen;
7225        $endPdfX = $cx + $dx * $halfLen;
7226        $endPdfY = $cy + $dy * $halfLen;
7227        // CSS Images 4 Â§6.4 â€” `repeating-linear-gradient` replays the
7228        // stop list at `(lastStopPos - firstStopPos)` intervals along
7229        // the gradient ray, infinitely. We approximate that by
7230        // extending the axis to cover the clip projection in whole
7231        // cycles and feeding the shading a stop list that replicates
7232        // the in-cycle stops once per cycle.
7233        $stopList = null;
7234        if ($gradient->repeating) {
7235            $cycle = $this->resolveRepeatingLinearCycle($gradient->stops, $lineLength);
7236            if ($cycle !== null) {
7237                $extended = $this->buildRepeatingLinearAxis(
7238                    $cycle,
7239                    $startPdfX,
7240                    $startPdfY,
7241                    $dx,
7242                    $dy,
7243                    $clipX,
7244                    $clipPdfY,
7245                    $clipWidth,
7246                    $clipHeight,
7247                );
7248                [$startPdfX, $startPdfY, $endPdfX, $endPdfY, $stopList] = $extended;
7249            }
7250        }
7251        $stopList ??= $this->resolveGradientStops($gradient->stops, $lineLength, $gradient->interpolationSpace, $gradient->hueInterpolation);
7252        try {
7253            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
7254            $pattern = $doc->addLinearGradientStops(
7255                new \Phpdftk\Geometry\Point($startPdfX, $startPdfY),
7256                new \Phpdftk\Geometry\Point($endPdfX, $endPdfY),
7257                $stopList,
7258            );
7259        } catch (\Throwable) {
7260            return;
7261        }
7262        // CSS gradient stops can carry alpha (`rgba(c, 0)` â†’ `rgba(c, 1)`),
7263        // but a PDF colour shading has no alpha channel. When any stop is
7264        // translucent, build a Luminosity soft mask from the per-stop
7265        // alpha so the fill fades with opacity instead of rendering solid.
7266        $maskGsName = null;
7267        if ($this->gradientHasAlpha($gradient->stops)) {
7268            $alphaStops = $this->alphaStopsFrom($stopList);
7269            $maskGsName = $this->buildGradientAlphaMaskState(
7270                $doc,
7271                fn(\Phpdftk\Pdf\Writer\PdfDoc $d): int => $d->addLinearAlphaShading(
7272                    new \Phpdftk\Geometry\Point($startPdfX, $startPdfY),
7273                    new \Phpdftk\Geometry\Point($endPdfX, $endPdfY),
7274                    $alphaStops,
7275                )->objectNumber,
7276                $clipX,
7277                $clipPdfY,
7278                $clipWidth,
7279                $clipHeight,
7280            );
7281        }
7282        $stream->saveGraphicsState();
7283        // Clip first to the bg-clip rect, then fill at the (typically
7284        // smaller) tile rect. When tile == clip the two are identical
7285        // and the behaviour is byte-for-byte the same as before.
7286        $stream->rectangle($clipX, $clipPdfY, $clipWidth, $clipHeight);
7287        $stream->clip();
7288        $stream->endPath();
7289        if ($maskGsName !== null) {
7290            $stream->setGraphicsState($maskGsName);
7291        }
7292        $patternName = $this->page?->useGradient($pattern);
7293        if ($patternName !== null) {
7294            $stream->setFillColorSpace('Pattern');
7295            $stream->setFillColor($patternName);
7296            $stream->rectangle($tileX, $tilePdfY, $tileWidth, $tileHeight);
7297            $stream->fill();
7298        }
7299        $stream->restoreGraphicsState();
7300    }
7301
7302    /**
7303     * Paint a CSS `background-image: url(...)` over the box's
7304     * background-positioning area. CSS Backgrounds 3 Â§3.9 `background-size`
7305     * support:
7306     *   - `auto` / unset â†’ stretch to fill (Phase-1 default; the legacy
7307     *     `100% 100%`-equivalent we shipped before).
7308     *   - `cover` â†’ preserve aspect, scale to fully cover the box (image
7309     *     may overflow; clipped to box rect).
7310     *   - `contain` â†’ preserve aspect, scale to fit inside the box;
7311     *     image is centred and may show background-color through the
7312     *     letterbox area.
7313     *   - `<length> <length>` â†’ explicit width Ã— height; centred.
7314     */
7315    /**
7316     * @param array{x: float, top: float, width: float, height: float}|null $originRect
7317     *   Positioning area per CSS Backgrounds 3 Â§3.4 `background-origin`.
7318     *   When null, defaults to the (x, top, width, height) clip rect â€”
7319     *   keeps the Phase-1 behaviour of positioning + clipping against
7320     *   the same rect. When supplied, image sizing + positioning math
7321     *   uses this rect while clipping uses the outer (clip) rect.
7322     */
7323    private function paintBackgroundImage(
7324        \Phpdftk\Css\Value\Url $url,
7325        ContentStream $stream,
7326        float $x,
7327        float $top,
7328        float $width,
7329        float $height,
7330        ?\Phpdftk\Css\Value\Value $sizeValue = null,
7331        ?\Phpdftk\Css\Value\Value $positionValue = null,
7332        ?\Phpdftk\Css\Value\Value $repeatValue = null,
7333        ?array $originRect = null,
7334    ): void {
7335        if ($this->writer === null || $this->page === null) {
7336            return;
7337        }
7338        $src = $url->url;
7339        $svgDoc = null;
7340        $name = null;
7341        if ($this->isSvgSrc($src)) {
7342            $svgDoc = $this->loadSvgDocument($src);
7343            if ($svgDoc === null) {
7344                return;
7345            }
7346        } elseif (isset($this->imageNameCache[$src])) {
7347            $name = $this->imageNameCache[$src];
7348        } else {
7349            $resolved = $this->resolveImageSrc($src);
7350            if ($resolved === null) {
7351                return;
7352            }
7353            try {
7354                $name = $this->writer->addImage($resolved, $this->page);
7355            } catch (\Throwable) {
7356                return;
7357            }
7358            $this->imageNameCache[$src] = $name;
7359        }
7360        // Positioning anchor: $originRect when supplied, else fall
7361        // back to the clip rect (Phase-1 behaviour).
7362        $originX = $originRect['x'] ?? $x;
7363        $originTop = $originRect['top'] ?? $top;
7364        $originWidth = $originRect['width'] ?? $width;
7365        $originHeight = $originRect['height'] ?? $height;
7366        // Resolve final paint rect (final size + offset within the
7367        // positioning area).
7368        $paint = $this->resolveBackgroundSize($sizeValue, $src, $originWidth, $originHeight);
7369        // CSS Backgrounds 3 Â§3.6 â€” `background-position` resolves
7370        // against the positioning area regardless of whether the tile
7371        // is smaller or larger than the area; for an oversized tile,
7372        // the position still anchors its origin (e.g. `0% 0%`
7373        // keeps the tile's top-left at the area's top-left, even if
7374        // the tile overflows). Reapply the author position whenever
7375        // it is supplied so it overrides the size resolver's default
7376        // centred offset.
7377        if ($positionValue !== null) {
7378            $pos = $this->resolveBackgroundPosition(
7379                $positionValue,
7380                $paint['w'],
7381                $paint['h'],
7382                $originWidth,
7383                $originHeight,
7384            );
7385            $paint['offsetX'] = $pos['offsetX'];
7386            $paint['offsetY'] = $pos['offsetY'];
7387        }
7388        $this->drawImageTilesInto(
7389            $stream,
7390            $svgDoc,
7391            $name,
7392            $x,
7393            $top,
7394            $width,
7395            $height,
7396            $paint,
7397            $repeatValue,
7398            $originX,
7399            $originTop,
7400            $originWidth,
7401            $originHeight,
7402            null,
7403        );
7404    }
7405
7406    /**
7407     * Tile an image (an SVG doc OR a raster XObject `$name`) across a
7408     * positioning area, clipped to a rect, per CSS Backgrounds 3 Â§3.7-3.9
7409     * (background-size / -position / -repeat already folded into `$paint`
7410     * and `$repeatValue`). Extracted from {@see paintBackgroundImage} so the
7411     * mask painter can reuse it to draw a `mask-image: url()` into a soft-mask
7412     * transparency group. `$registerXObject`, when given, is called with the
7413     * XObject name before each `Do` so the caller can register the image in
7414     * the target stream's resources (needed for a transparency group, which
7415     * has its own resources); the background path passes null (the image is
7416     * already in the page resources).
7417     *
7418     * @param array{w: float, h: float, offsetX: float, offsetY: float} $paint
7419     * @param \Closure(string): void|null $registerXObject
7420     */
7421    private function drawImageTilesInto(
7422        ContentStream $stream,
7423        ?\Phpdftk\Svg\SvgDocument $svgDoc,
7424        ?string $name,
7425        float $x,
7426        float $top,
7427        float $width,
7428        float $height,
7429        array $paint,
7430        ?\Phpdftk\Css\Value\Value $repeatValue,
7431        float $originX,
7432        float $originTop,
7433        float $originWidth,
7434        float $originHeight,
7435        ?\Closure $registerXObject = null,
7436    ): void {
7437        $pdfY = $this->pageHeight - $top - $height;
7438        $stream->saveGraphicsState();
7439        // `cover` may overflow the box; clip to box rect so the overflow
7440        // doesn't bleed into adjacent boxes.
7441        $stream->rectangle($x, $pdfY, $width, $height);
7442        $stream->clip();
7443        $stream->endPath();
7444        // CSS Backgrounds 3 Â§3.8: `background-repeat` decides whether
7445        // to tile the image when its painted rect doesn't fill the box.
7446        // `no-repeat` paints one instance; `repeat` / `repeat-x` /
7447        // `repeat-y` tile across the relevant axes. The painter's box
7448        // clip handles edge tiles that extend past the box rect.
7449        $repeat = $this->repeatAxes($repeatValue);
7450        $repeatModes = $this->repeatModes($repeatValue);
7451        $tileW = $paint['w'];
7452        $tileH = $paint['h'];
7453        if ($tileW <= 0.0 || $tileH <= 0.0) {
7454            $stream->restoreGraphicsState();
7455            return;
7456        }
7457        // CSS Backgrounds 3 Â§3.7 â€” `round` per axis scales the tile
7458        // so a whole number of tiles fits the positioning area. Apply
7459        // before computing tile offsets so subsequent positioning + the
7460        // repeat loop see the rescaled tile dims.
7461        $tileW = $this->roundTileDim($repeatModes['x'], $tileW, $originWidth);
7462        $tileH = $this->roundTileDim($repeatModes['y'], $tileH, $originHeight);
7463        $paint['w'] = $tileW;
7464        $paint['h'] = $tileH;
7465        // Start positions: shift the anchor backwards by whole tile
7466        // widths until the leftmost / topmost tile sits at or before
7467        // the origin box (NOT the clip box â€” tiles anchor against
7468        // `background-origin`). With `no-repeat`, no shift happens.
7469        // Tile iteration bounds. A repeating axis tiles across the whole
7470        // PAINT/clip rect (which for a propagated root background is the
7471        // entire canvas, larger than the positioning area), anchored to
7472        // the origin rect. A non-repeating axis paints a single tile
7473        // within the origin rect. For a normal box clip == origin, so
7474        // these reduce to the origin bounds (no behaviour change).
7475        $startX = $paint['offsetX'];
7476        $farX = $originWidth;
7477        if ($repeat['x']) {
7478            $farX = $x + $width - $originX;
7479            while ($originX + $startX > $x) {
7480                $startX -= $tileW;
7481            }
7482        }
7483        $startY = $paint['offsetY'];
7484        $farY = $originHeight;
7485        if ($repeat['y']) {
7486            $farY = $top + $height - $originTop;
7487            while ($originTop + $startY > $top) {
7488                $startY -= $tileH;
7489            }
7490        }
7491        $originBottomLayoutY = $originTop + $originHeight;
7492        $originPdfBottom = $this->pageHeight - $originBottomLayoutY;
7493        // Precompute the per-axis tile offsets (relative to the origin
7494        // edge). This unifies repeat / round / no-repeat / space: `space`
7495        // distributes even gaps rather than butting tiles, the others step
7496        // continuously.
7497        $xOffsets = $this->tileOffsets($repeatModes['x'], $repeat['x'], $startX, $farX, $tileW, $paint['offsetX'], $originWidth);
7498        $yOffsets = $this->tileOffsets($repeatModes['y'], $repeat['y'], $startY, $farY, $tileH, $paint['offsetY'], $originHeight);
7499        $maxTiles = 4096;
7500        $tileCount = 0;
7501        foreach ($yOffsets as $offsetY) {
7502            foreach ($xOffsets as $offsetX) {
7503                if ($tileCount >= $maxTiles) {
7504                    break 2;
7505                }
7506                $tileBottomY = $originPdfBottom + ($originHeight - $tileH - $offsetY);
7507                if ($svgDoc !== null) {
7508                    // Route the SVG draw through the caller's stream so
7509                    // it lands INSIDE the bg-clip `q ... clip ... Q`
7510                    // scope this method opened above. Without this the
7511                    // page would attach a fresh content stream and the
7512                    // SVG paint (e.g. a `cover`-overflowed 768×3072
7513                    // tile) escapes the box clip.
7514                    $this->svgRenderer()->draw(
7515                        $svgDoc,
7516                        $originX + $offsetX,
7517                        $tileBottomY,
7518                        $tileW,
7519                        $tileH,
7520                        stream: $stream,
7521                    );
7522                } else {
7523                    assert($name !== null);
7524                    if ($registerXObject !== null) {
7525                        $registerXObject($name);
7526                    }
7527                    $stream->saveGraphicsState();
7528                    $stream->concatMatrix(
7529                        $tileW,
7530                        0.0,
7531                        0.0,
7532                        $tileH,
7533                        $originX + $offsetX,
7534                        $tileBottomY,
7535                    );
7536                    $stream->doXObject($name);
7537                    $stream->restoreGraphicsState();
7538                }
7539                $tileCount++;
7540            }
7541        }
7542        $stream->restoreGraphicsState();
7543    }
7544
7545    /**
7546     * Resolve a CSS `background-repeat` value to a `{x: bool, y: bool}`
7547     * pair indicating whether each axis should tile (true for all
7548     * non-`no-repeat` modes; the per-axis `round` / `space` math
7549     * folds back into the caller via {@see repeatModes}).
7550     *
7551     * Phase-1 handles the simple keyword set:
7552     *   - `repeat` (default) â†’ both axes
7553     *   - `repeat-x` â†’ x only
7554     *   - `repeat-y` â†’ y only
7555     *   - `no-repeat` â†’ neither
7556     *   - `round` / `space` â†’ both axes (loop-active; the actual
7557     *     scale-to-fit / distribute-spacing math is layered on top
7558     *     of the loop)
7559     *   - two-value form (e.g. `repeat no-repeat`): per-axis keywords
7560     *
7561     * @return array{x: bool, y: bool}
7562     */
7563    private function repeatAxes(?\Phpdftk\Css\Value\Value $value): array
7564    {
7565        $modes = $this->repeatModes($value);
7566        return [
7567            'x' => $modes['x'] !== 'no-repeat',
7568            'y' => $modes['y'] !== 'no-repeat',
7569        ];
7570    }
7571
7572    /**
7573     * Per-axis `background-repeat` mode (the richer view repeatAxes
7574     * collapses to bools). Returns one of `repeat` / `repeat-x` (collapsed
7575     * to a single bool above) / `no-repeat` / `round` / `space` per axis.
7576     *
7577     * @return array{x: string, y: string}
7578     */
7579    private function repeatModes(?\Phpdftk\Css\Value\Value $value): array
7580    {
7581        if ($value === null) {
7582            return ['x' => 'repeat', 'y' => 'repeat'];
7583        }
7584        $items = $value instanceof \Phpdftk\Css\Value\ValueList
7585            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
7586                ? $value->values
7587                : [$value];
7588        if (count($items) === 2
7589            && $items[0] instanceof \Phpdftk\Css\Value\Keyword
7590            && $items[1] instanceof \Phpdftk\Css\Value\Keyword
7591        ) {
7592            return [
7593                'x' => strtolower($items[0]->name),
7594                'y' => strtolower($items[1]->name),
7595            ];
7596        }
7597        if ($items[0] instanceof \Phpdftk\Css\Value\Keyword) {
7598            return match (strtolower($items[0]->name)) {
7599                'repeat-x' => ['x' => 'repeat', 'y' => 'no-repeat'],
7600                'repeat-y' => ['x' => 'no-repeat', 'y' => 'repeat'],
7601                'no-repeat' => ['x' => 'no-repeat', 'y' => 'no-repeat'],
7602                'round' => ['x' => 'round', 'y' => 'round'],
7603                'space' => ['x' => 'space', 'y' => 'space'],
7604                default => ['x' => 'repeat', 'y' => 'repeat'],
7605            };
7606        }
7607        return ['x' => 'repeat', 'y' => 'repeat'];
7608    }
7609
7610    /**
7611     * CSS Backgrounds 3 Â§3.7 `round` per-axis rescale: scale the
7612     * tile so a whole number of tiles fits the positioning area,
7613     * preserving aspect when possible. Returns the rescaled tile
7614     * size; passthrough when the mode isn't `round` or the natural
7615     * tile dim is non-positive.
7616     */
7617    private function roundTileDim(string $mode, float $tileDim, float $originDim): float
7618    {
7619        if ($mode !== 'round' || $tileDim <= 0.0 || $originDim <= 0.0) {
7620            return $tileDim;
7621        }
7622        // CSS spec: number of tiles = round(originDim / tileDim).
7623        // At least 1 â€” a tile larger than the box still emits once.
7624        $n = max(1, (int) round($originDim / $tileDim));
7625        return $originDim / $n;
7626    }
7627
7628    /**
7629     * Compute the per-axis list of tile offsets (relative to the
7630     * background-origin edge) for one axis of a repeated background image.
7631     *
7632     *   - `no-repeat` â†’ a single tile at the background-position offset.
7633     *   - `repeat` / `round` â†’ tiles butt together, stepping by `$tileDim`
7634     *     from the back-shifted `$start` across the paint area to `$far`
7635     *     (`round` has already rescaled `$tileDim` so a whole number fit).
7636     *   - `space` (CSS Backgrounds 3 Â§3.7) â†’ as many whole tiles as fit
7637     *     without clipping, the remaining space distributed evenly so the
7638     *     first and last tiles touch the two edges; background-position is
7639     *     ignored on this axis. When 0 or 1 tile fits, a single tile is
7640     *     placed at the background-position offset.
7641     *
7642     * @return list<float>
7643     */
7644    private function tileOffsets(
7645        string $mode,
7646        bool $repeats,
7647        float $start,
7648        float $far,
7649        float $tileDim,
7650        float $positionedOffset,
7651        float $originDim,
7652    ): array {
7653        if (!$repeats) {
7654            return [$positionedOffset];
7655        }
7656        if ($mode === 'space') {
7657            $n = $tileDim > 0.0 ? (int) floor($originDim / $tileDim + 1e-6) : 0;
7658            if ($n >= 2) {
7659                $step = $tileDim + ($originDim - $n * $tileDim) / ($n - 1);
7660                $offsets = [];
7661                for ($i = 0; $i < $n; $i++) {
7662                    $offsets[] = $i * $step;
7663                }
7664                return $offsets;
7665            }
7666            return [$positionedOffset];
7667        }
7668        // repeat / round â€” continuous butting tiles from the shifted start.
7669        $offsets = [];
7670        for ($o = $start; $o < $far; $o += $tileDim) {
7671            $offsets[] = $o;
7672            if (count($offsets) >= 4096) {
7673                break;
7674            }
7675        }
7676        return $offsets === [] ? [$start] : $offsets;
7677    }
7678
7679    /**
7680     * Resolve a CSS `background-position` value to a top-left offset of
7681     * the image rect within the background-positioning area (the box's
7682     * rect). Per CSS Backgrounds 3 Â§3.7, position offsets are
7683     * interpolated such that `0% 0%` puts the image's top-left at the
7684     * box's top-left and `100% 100%` puts the image's bottom-right at
7685     * the box's bottom-right (i.e. `offset = (box - image) Ã— percent`).
7686     *
7687     * Phase-1 surface:
7688     *   - 1 keyword (`center` / `top` / `bottom` / `left` / `right`) â†’ centred on the missing axis
7689     *   - 2 values (keyword | length | percentage), one per axis
7690     *   - Lengths offset directly; percentages use the spec formula.
7691     * Edge syntax (`right 10px bottom 20px`, 4-value form) lands later.
7692     *
7693     * @return array{offsetX: float, offsetY: float}
7694     */
7695    private function resolveBackgroundPosition(
7696        \Phpdftk\Css\Value\Value $value,
7697        float $imageWidth,
7698        float $imageHeight,
7699        float $boxWidth,
7700        float $boxHeight,
7701    ): array {
7702        $items = $value instanceof \Phpdftk\Css\Value\ValueList
7703            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
7704                ? $value->values
7705                : [$value];
7706        // 50% default applies only when entering the single-keyword
7707        // branch below: per CSS Backgrounds 3 Â§3.6, a single keyword
7708        // (e.g. `top`, `left`) pins one axis and centres the other.
7709        // The two-value / empty path enters `else` and lets
7710        // `axisOffsetFromValue` resolve each side from its own value
7711        // (or its null-default of 0%, the spec's initial value).
7712        $xPercent = 0.5;
7713        $yPercent = 0.5;
7714        $xLength = null;
7715        $yLength = null;
7716        // CSS Backgrounds 3 Â§3.6 â€” the 3-4 value edge-offset form: each
7717        // <length-percentage> is an OFFSET FROM a preceding edge keyword
7718        // (`bottom 10px right 20px` = 10px up from bottom, 20px left from
7719        // right). Only triggers at 3+ items so the 1-2 value paths stay
7720        // byte-identical.
7721        if (count($items) >= 3) {
7722            [$xLength, $xPercent, $yLength, $yPercent] = $this->resolveEdgeOffsetPosition(
7723                $items,
7724                $imageWidth,
7725                $imageHeight,
7726                $boxWidth,
7727                $boxHeight,
7728            );
7729            $offsetX = $xLength ?? ($boxWidth - $imageWidth) * $xPercent;
7730            $offsetY = $yLength ?? ($boxHeight - $imageHeight) * $yPercent;
7731            return ['offsetX' => $offsetX, 'offsetY' => $offsetY];
7732        }
7733        // Single keyword: maps to one axis and centres the other.
7734        if (count($items) === 1 && $items[0] instanceof \Phpdftk\Css\Value\Keyword) {
7735            $kw = strtolower($items[0]->name);
7736            // Vertical-only keywords pin y; horizontal-only pin x.
7737            switch ($kw) {
7738                case 'top': $yPercent = 0.0;
7739                    break;
7740                case 'bottom': $yPercent = 1.0;
7741                    break;
7742                case 'left': $xPercent = 0.0;
7743                    break;
7744                case 'right': $xPercent = 1.0;
7745                    break;
7746                case 'center': default: break;
7747            }
7748        } else {
7749            // Two-value form. Per CSS Backgrounds 3 Â§3.6 the components may
7750            // appear in either axis order: `left`/`right` always bind X and
7751            // `top`/`bottom` always bind Y, so `top center` and `center left`
7752            // must be read axis-first, not positionally. Swap when the first
7753            // component is a vertical keyword or the second is a horizontal
7754            // keyword; the common `<x> <y>` order stays byte-identical.
7755            $xItem = $items[0] ?? null;
7756            $yItem = $items[1] ?? null;
7757            if ($this->isVerticalPositionKeyword($xItem)
7758                || $this->isHorizontalPositionKeyword($yItem)
7759            ) {
7760                [$xItem, $yItem] = [$yItem, $xItem];
7761            }
7762            // CSS Backgrounds 3 Â§3.6 â€” when the author specifies exactly
7763            // ONE value (e.g. `background-position: 25%` or `-0px`), the
7764            // second value is `center` (50%), not the unspecified initial
7765            // `0%`. An EMPTY list (count 0) is the unspecified initial and
7766            // keeps `0% 0%`; a single keyword is handled above.
7767            $singleValue = count($items) === 1;
7768            $xAxis = $this->axisOffsetFromValue($xItem, isHorizontal: true);
7769            if ($xAxis['percent'] !== null) {
7770                $xPercent = $xAxis['percent'];
7771            }
7772            if ($xAxis['length'] !== null) {
7773                $xLength = $xAxis['length'];
7774            }
7775            if ($singleValue) {
7776                $yPercent = 0.5;
7777            } else {
7778                $yAxis = $this->axisOffsetFromValue($yItem, isHorizontal: false);
7779                if ($yAxis['percent'] !== null) {
7780                    $yPercent = $yAxis['percent'];
7781                }
7782                if ($yAxis['length'] !== null) {
7783                    $yLength = $yAxis['length'];
7784                }
7785            }
7786        }
7787        $offsetX = $xLength ?? ($boxWidth - $imageWidth) * $xPercent;
7788        $offsetY = $yLength ?? ($boxHeight - $imageHeight) * $yPercent;
7789        return ['offsetX' => $offsetX, 'offsetY' => $offsetY];
7790    }
7791
7792    /**
7793     * CSS Backgrounds 3 Â§3.6 â€” resolve the 3-4 value `background-position`
7794     * edge-offset form into `[xLength, xPercent, yLength, yPercent]` (the
7795     * same shape {@see resolveBackgroundPosition} folds into an offset).
7796     * Each edge keyword (`left`/`right`/`top`/`bottom`/`center`) may be
7797     * followed by a `<length-percentage>` offset measured FROM that edge:
7798     * a `right`/`bottom` offset counts inward from the far edge. `center`
7799     * takes no offset and fills whichever axis the other group left open.
7800     *
7801     * @param list<\Phpdftk\Css\Value\Value> $items
7802     * @return array{0: ?float, 1: float, 2: ?float, 3: float}
7803     */
7804    private function resolveEdgeOffsetPosition(
7805        array $items,
7806        float $imageWidth,
7807        float $imageHeight,
7808        float $boxWidth,
7809        float $boxHeight,
7810    ): array {
7811        $xLength = null;
7812        $yLength = null;
7813        $xPercent = null;
7814        $yPercent = null;
7815        $count = count($items);
7816        $i = 0;
7817        while ($i < $count) {
7818            $item = $items[$i];
7819            if (!($item instanceof \Phpdftk\Css\Value\Keyword)) {
7820                $i++;
7821                continue;
7822            }
7823            $kw = strtolower($item->name);
7824            // Pull an optional trailing offset (not for `center`).
7825            $offset = null;
7826            if ($kw !== 'center' && $i + 1 < $count) {
7827                $next = $items[$i + 1];
7828                if ($next instanceof \Phpdftk\Css\Value\Length
7829                    || $next instanceof \Phpdftk\Css\Value\Percentage
7830                ) {
7831                    $offset = $next;
7832                    $i++;
7833                }
7834            }
7835            $i++;
7836            $far = $kw === 'right' || $kw === 'bottom';
7837            $isX = $kw === 'left' || $kw === 'right';
7838            $isY = $kw === 'top' || $kw === 'bottom';
7839            if ($kw === 'center') {
7840                if ($xPercent === null && $xLength === null) {
7841                    $xPercent = 0.5;
7842                } else {
7843                    $yPercent = 0.5;
7844                }
7845                continue;
7846            }
7847            if ($offset instanceof \Phpdftk\Css\Value\Length) {
7848                if ($isX) {
7849                    $xLength = $far ? ($boxWidth - $imageWidth) - $offset->value : $offset->value;
7850                } elseif ($isY) {
7851                    $yLength = $far ? ($boxHeight - $imageHeight) - $offset->value : $offset->value;
7852                }
7853            } elseif ($offset instanceof \Phpdftk\Css\Value\Percentage) {
7854                $p = $offset->value / 100.0;
7855                $p = $far ? 1.0 - $p : $p;
7856                if ($isX) {
7857                    $xPercent = $p;
7858                } elseif ($isY) {
7859                    $yPercent = $p;
7860                }
7861            } else {
7862                // Bare edge keyword: anchor at that edge.
7863                if ($isX) {
7864                    $xPercent = $far ? 1.0 : 0.0;
7865                } elseif ($isY) {
7866                    $yPercent = $far ? 1.0 : 0.0;
7867                }
7868            }
7869        }
7870        return [$xLength, $xPercent ?? 0.5, $yLength, $yPercent ?? 0.5];
7871    }
7872
7873    /**
7874     * Classify a single `background-position` axis value into a
7875     * `{percent?, length?}` pair. Keywords (`top`/`bottom`/`left`/
7876     * `right`/`center`) become percentages; explicit `<length>` /
7877     * `<percentage>` values come through as-is.
7878     *
7879     * @return array{percent: ?float, length: ?float}
7880     */
7881    /**
7882     * True for the horizontal-axis `background-position` keywords
7883     * (`left` / `right`), which bind the X axis regardless of order.
7884     */
7885    private function isHorizontalPositionKeyword(?\Phpdftk\Css\Value\Value $v): bool
7886    {
7887        return $v instanceof \Phpdftk\Css\Value\Keyword
7888            && in_array(strtolower($v->name), ['left', 'right'], true);
7889    }
7890
7891    /**
7892     * True for the vertical-axis `background-position` keywords
7893     * (`top` / `bottom`), which bind the Y axis regardless of order.
7894     */
7895    private function isVerticalPositionKeyword(?\Phpdftk\Css\Value\Value $v): bool
7896    {
7897        return $v instanceof \Phpdftk\Css\Value\Keyword
7898            && in_array(strtolower($v->name), ['top', 'bottom'], true);
7899    }
7900
7901    /**
7902     * @return array{percent: float|null, length: float|null}
7903     */
7904    private function axisOffsetFromValue(
7905        ?\Phpdftk\Css\Value\Value $value,
7906        bool $isHorizontal,
7907    ): array {
7908        // CSS Backgrounds 3 Â§3.6 â€” `background-position` initial value
7909        // is `0% 0%` (top-left). A missing axis falls back to that
7910        // initial; the previous default of 50% (centre) misrouted any
7911        // explicit-tile case (e.g. `background-size: 12px auto`
7912        // without an explicit position) to centre instead of top-left.
7913        if ($value === null) {
7914            return ['percent' => 0.0, 'length' => null];
7915        }
7916        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
7917            $kw = strtolower($value->name);
7918            $percent = match ($kw) {
7919                'left', 'top' => 0.0,
7920                'right', 'bottom' => 1.0,
7921                'center' => 0.5,
7922                default => 0.5,
7923            };
7924            return ['percent' => $percent, 'length' => null];
7925        }
7926        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
7927            return ['percent' => $value->value / 100.0, 'length' => null];
7928        }
7929        if ($value instanceof \Phpdftk\Css\Value\Length) {
7930            return ['percent' => null, 'length' => $value->value];
7931        }
7932        // CSS Values 4 Â§4.2: bare `0` is treated as `0` in any
7933        // dimensional context, so `background-position: 0 0` resolves
7934        // to top-left anchor (not the default centre).
7935        if (($value instanceof \Phpdftk\Css\Value\Number
7936            || $value instanceof \Phpdftk\Css\Value\Integer)
7937            && (float) $value->value === 0.0
7938        ) {
7939            return ['percent' => null, 'length' => 0.0];
7940        }
7941        return ['percent' => 0.5, 'length' => null];
7942    }
7943
7944    /**
7945     * Compute the final paint rect for a CSS `background-size` value.
7946     * Returns the width / height (in points) and the offset (top-left
7947     * corner) within the containing background-positioning area. Phase 1
7948     * always centres `contain`-sized images; `background-position`
7949     * support lands later with the full Backgrounds 3 Â§3.7 grammar.
7950     *
7951     * @return array{w: float, h: float, offsetX: float, offsetY: float}
7952     */
7953    private function resolveBackgroundSize(
7954        ?\Phpdftk\Css\Value\Value $sizeValue,
7955        string $src,
7956        float $boxWidth,
7957        float $boxHeight,
7958    ): array {
7959        // Default / unset / `auto`: CSS Backgrounds 3 Â§3.9 â€” when both
7960        // axes are `auto` and the image has intrinsic dimensions, use
7961        // those dimensions; only fall back to box dims when the image
7962        // has no intrinsic info.
7963        $isAuto = $sizeValue === null
7964            || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword
7965                && strtolower($sizeValue->name) === 'auto');
7966        if ($isAuto) {
7967            // Per CSS Backgrounds 3 Â§3.9 â€” both auto and the image
7968            // has full intrinsic w+h (raster, or SVG with both axes
7969            // either fixed or derived from viewBox) â†’ use those.
7970            $intrinsic = $this->intrinsicSize($src);
7971            if ($intrinsic !== null && $intrinsic[0] > 0 && $intrinsic[1] > 0) {
7972                return [
7973                    'w' => (float) $intrinsic[0],
7974                    'h' => (float) $intrinsic[1],
7975                    'offsetX' => 0.0,
7976                    'offsetY' => 0.0,
7977                ];
7978            }
7979            // Partial intrinsic (SVG with one fixed dim + no
7980            // viewBox): missing axes fall back to the bg-positioning
7981            // area dimension (CSS Images 3 Â§5.2 default object size).
7982            $partial = $this->intrinsicSizePartial($src);
7983            $w = $partial['w'];
7984            $h = $partial['h'];
7985            if ($w !== null || $h !== null) {
7986                return [
7987                    'w' => $w ?? $boxWidth,
7988                    'h' => $h ?? $boxHeight,
7989                    'offsetX' => 0.0,
7990                    'offsetY' => 0.0,
7991                ];
7992            }
7993            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
7994        }
7995        $keyword = $sizeValue instanceof \Phpdftk\Css\Value\Keyword
7996            ? strtolower($sizeValue->name)
7997            : null;
7998        if ($keyword === 'cover' || $keyword === 'contain') {
7999            $intrinsic = $this->intrinsicSize($src);
8000            if ($intrinsic === null) {
8001                // Fallback to stretch when we can't read natural size.
8002                return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
8003            }
8004            [$natW, $natH] = $intrinsic;
8005            // Extreme viewBox aspect ratios can collapse one axis to
8006            // zero (CSS Backgrounds 3 Â§3.9 considers the ratio
8007            // well-defined, but the derived dimension rounds to 0).
8008            // `contain` resolves cleanly via INF on the impossible
8009            // axis â€” min picks the finite scale and the zero side
8010            // stays zero â€” but `cover` would otherwise produce INF
8011            // dimensions. For cover with a degenerate axis, degrade
8012            // to stretch so the box is at least covered with finite
8013            // dimensions.
8014            if ($keyword === 'cover' && ($natW === 0 || $natH === 0)) {
8015                return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
8016            }
8017            $scaleW = $natW > 0 ? $boxWidth / $natW : INF;
8018            $scaleH = $natH > 0 ? $boxHeight / $natH : INF;
8019            $scale = $keyword === 'cover' ? max($scaleW, $scaleH) : min($scaleW, $scaleH);
8020            $finalW = $natW * $scale;
8021            $finalH = $natH * $scale;
8022            return [
8023                'w' => $finalW,
8024                'h' => $finalH,
8025                'offsetX' => ($boxWidth - $finalW) / 2,
8026                'offsetY' => ($boxHeight - $finalH) / 2,
8027            ];
8028        }
8029        // `<length> <length>` â€” explicit dimensions in a 2-element
8030        // space-separated ValueList. Per CSS Backgrounds 3 Â§3.9, when
8031        // one component is `auto`:
8032        //   â€¢ image has intrinsic ratio â†’ derive from the other side
8033        //   â€¢ image has no intrinsic ratio â†’ use 100% of the
8034        //     corresponding bg-positioning area dimension
8035        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
8036            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
8037        ) {
8038            $w = $sizeValue->values[0] ?? null;
8039            $h = $sizeValue->values[1] ?? null;
8040            $explicitW = $this->backgroundSizeAxisLength($w, $boxWidth);
8041            $explicitH = $this->backgroundSizeAxisLength($h, $boxHeight);
8042            [$finalW, $finalH] = $this->resolveAutoSizePair(
8043                $explicitW,
8044                $explicitH,
8045                $src,
8046                $boxWidth,
8047                $boxHeight,
8048            );
8049            return [
8050                'w' => $finalW,
8051                'h' => $finalH,
8052                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
8053                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
8054            ];
8055        }
8056        // Single-value `background-size`: a bare `<length>` or
8057        // `<percentage>` sets the width; the second axis defaults to
8058        // `auto` per CSS Backgrounds 3 Â§3.9 (derive from intrinsic
8059        // ratio, or fall back to the bg-positioning-area height).
8060        $singleW = $this->backgroundSizeAxisLength($sizeValue, $boxWidth);
8061        if ($singleW !== null) {
8062            [$finalW, $finalH] = $this->resolveAutoSizePair(
8063                $singleW,
8064                null,
8065                $src,
8066                $boxWidth,
8067                $boxHeight,
8068            );
8069            return [
8070                'w' => $finalW,
8071                'h' => $finalH,
8072                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
8073                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
8074            ];
8075        }
8076        return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
8077    }
8078
8079    /**
8080     * Resolve one axis of `background-size` from a single value to a
8081     * concrete pixel length. Lengths come through as-is; percentages
8082     * resolve against the bg-positioning area axis (CSS Backgrounds 3
8083     * Â§3.9). Any other value (including `auto` keywords) returns null
8084     * so the caller can route to the intrinsic-ratio path.
8085     */
8086    private function backgroundSizeAxisLength(
8087        ?\Phpdftk\Css\Value\Value $value,
8088        float $axisExtent,
8089    ): ?float {
8090        if ($value instanceof \Phpdftk\Css\Value\Length) {
8091            return $value->value;
8092        }
8093        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
8094            return $value->value / 100.0 * $axisExtent;
8095        }
8096        return null;
8097    }
8098
8099    /**
8100     * Return true when the `background-size` value resolves to the
8101     * "fill the box" default. Treated as default:
8102     *   â€¢ null (property unset)
8103     *   â€¢ single keyword `auto` / unknown
8104     *   â€¢ two-value `auto auto`
8105     * Anything explicit (length, percentage, `cover`, `contain`,
8106     * `auto <length>`, `<length> auto`) is non-default and the
8107     * gradient renders at the resolved tile rect.
8108     */
8109    private function isDefaultGradientSize(?\Phpdftk\Css\Value\Value $sizeValue): bool
8110    {
8111        if ($sizeValue === null) {
8112            return true;
8113        }
8114        if ($sizeValue instanceof \Phpdftk\Css\Value\Keyword) {
8115            return strtolower($sizeValue->name) === 'auto';
8116        }
8117        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
8118            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
8119        ) {
8120            $allAuto = true;
8121            foreach ($sizeValue->values as $v) {
8122                $isAutoKw = $v instanceof \Phpdftk\Css\Value\Keyword
8123                    && strtolower($v->name) === 'auto';
8124                if (!$isAutoKw) {
8125                    $allAuto = false;
8126                    break;
8127                }
8128            }
8129            return $allAuto && $sizeValue->values !== [];
8130        }
8131        return false;
8132    }
8133
8134    /**
8135     * Compute the rect a CSS gradient tile occupies given the
8136     * background-size + background-position values and the
8137     * background-positioning area (`background-origin` rect).
8138     *
8139     * Gradients have no intrinsic size and no intrinsic ratio, so
8140     * CSS Backgrounds 3 Â§3.9 reduces to:
8141     *   â€¢ both auto / unset / unknown keyword â†’ 100% Ã— 100%
8142     *   â€¢ cover / contain                     â†’ 100% Ã— 100%
8143     *   â€¢ &lt;length&gt; auto                       â†’ length Ã— 100% height
8144     *   â€¢ auto &lt;length&gt;                       â†’ 100% width Ã— length
8145     *   â€¢ &lt;length&gt; &lt;length&gt;                   â†’ explicit pair
8146     * Position resolves the tile's offset within the positioning
8147     * area (CSS Backgrounds 3 Â§3.6 â€” keywords / percentages anchor;
8148     * lengths are direct offsets).
8149     *
8150     * Tile a linear gradient across the box when `background-size` makes
8151     * the tile smaller than the positioning area (CSS Backgrounds 3 Â§3.9).
8152     * Mirrors the image tiling loop: resolve the tile size + positioned
8153     * offset, compute per-axis cell offsets (with `background-repeat`'s
8154     * repeat / round / space handling via {@see tileOffsets}), and paint
8155     * the gradient once per cell, each clipped to the box. `no-repeat`
8156     * reduces to a single positioned tile.
8157     *
8158     * @param array{x: float, top: float, width: float, height: float} $originRect
8159     */
8160    private function paintTiledLinearGradient(
8161        \Phpdftk\Css\Value\LinearGradient $gradient,
8162        ContentStream $stream,
8163        float $boxX,
8164        float $boxTop,
8165        float $boxWidth,
8166        float $boxHeight,
8167        array $originRect,
8168        ?\Phpdftk\Css\Value\Value $sizeValue,
8169        ?\Phpdftk\Css\Value\Value $positionValue,
8170        ?\Phpdftk\Css\Value\Value $repeatValue,
8171    ): void {
8172        $grid = $this->gradientTileCells($originRect, $boxX, $boxTop, $boxWidth, $boxHeight, $sizeValue, $positionValue, $repeatValue);
8173        if ($grid === null) {
8174            // Degenerate or sub-pixel tile â€” a dense grid of tiny tiles
8175            // approximates one box-filling gradient (and avoids blowing the
8176            // tile cap), so paint it once (e.g. `background-size: 0.2px`).
8177            $this->paintLinearGradient($gradient, $stream, $boxX, $boxTop, $boxWidth, $boxHeight);
8178            return;
8179        }
8180        // A linear-gradient shading is confined to the tile rect it's built
8181        // for, so paint each cell clipped to the box (the shading carries
8182        // its own extent).
8183        $clip = [$boxX, $boxTop, $boxWidth, $boxHeight];
8184        foreach ($grid['cells'] as $cell) {
8185            $this->paintLinearGradient($gradient, $stream, $cell['x'], $cell['top'], $grid['tileW'], $grid['tileH'], $clip);
8186        }
8187    }
8188
8189    /**
8190     * Compute the tile size and per-cell top-left positions for tiling a
8191     * gradient across the box under `background-size` / `-position` /
8192     * `-repeat`. Mirrors the image tiling geometry (back-shift on repeating
8193     * axes, {@see tileOffsets} for repeat / round / space). Returns null
8194     * when the tile is degenerate.
8195     *
8196     * @param  array{x: float, top: float, width: float, height: float} $originRect
8197     * @return array{tileW: float, tileH: float, cells: list<array{x: float, top: float}>}|null
8198     */
8199    private function gradientTileCells(
8200        array $originRect,
8201        float $boxX,
8202        float $boxTop,
8203        float $boxWidth,
8204        float $boxHeight,
8205        ?\Phpdftk\Css\Value\Value $sizeValue,
8206        ?\Phpdftk\Css\Value\Value $positionValue,
8207        ?\Phpdftk\Css\Value\Value $repeatValue,
8208    ): ?array {
8209        $originX = $originRect['x'];
8210        $originTop = $originRect['top'];
8211        $originWidth = $originRect['width'];
8212        $originHeight = $originRect['height'];
8213        $repeat = $this->repeatAxes($repeatValue);
8214        $modes = $this->repeatModes($repeatValue);
8215        [$tileW, $tileH] = $this->resolveGradientTileSize($sizeValue, $originWidth, $originHeight);
8216        // `round` rescales the tile so a whole number fits (same as the
8217        // image path); apply before computing offsets.
8218        $tileW = $this->roundTileDim($modes['x'], $tileW, $originWidth);
8219        $tileH = $this->roundTileDim($modes['y'], $tileH, $originHeight);
8220        // Degenerate or sub-pixel tiles can't be tiled meaningfully (they'd
8221        // demand thousands of tiles); the caller falls back to a single fill.
8222        if ($tileW < 1.0 || $tileH < 1.0) {
8223            return null;
8224        }
8225        $offsets = $positionValue === null
8226            ? ['offsetX' => max(0.0, ($originWidth - $tileW) / 2), 'offsetY' => max(0.0, ($originHeight - $tileH) / 2)]
8227            : $this->resolveBackgroundPosition($positionValue, $tileW, $tileH, $originWidth, $originHeight);
8228        // Back-shift the anchor and extend the far bound to the box edge on
8229        // repeating axes, exactly as paintBackgroundImage does.
8230        $startX = $offsets['offsetX'];
8231        $farX = $originWidth;
8232        if ($repeat['x']) {
8233            $farX = $boxX + $boxWidth - $originX;
8234            while ($originX + $startX > $boxX) {
8235                $startX -= $tileW;
8236            }
8237        }
8238        $startY = $offsets['offsetY'];
8239        $farY = $originHeight;
8240        if ($repeat['y']) {
8241            $farY = $boxTop + $boxHeight - $originTop;
8242            while ($originTop + $startY > $boxTop) {
8243                $startY -= $tileH;
8244            }
8245        }
8246        $xOffsets = $this->tileOffsets($modes['x'], $repeat['x'], $startX, $farX, $tileW, $offsets['offsetX'], $originWidth);
8247        $yOffsets = $this->tileOffsets($modes['y'], $repeat['y'], $startY, $farY, $tileH, $offsets['offsetY'], $originHeight);
8248        // Gradient tiles each register a shading pattern, heavier than an
8249        // XObject draw â€” cap lower than the image loop.
8250        $cells = [];
8251        foreach ($yOffsets as $oy) {
8252            foreach ($xOffsets as $ox) {
8253                if (count($cells) >= 512) {
8254                    break 2;
8255                }
8256                $cells[] = ['x' => $originX + $ox, 'top' => $originTop + $oy];
8257            }
8258        }
8259        return ['tileW' => $tileW, 'tileH' => $tileH, 'cells' => $cells];
8260    }
8261
8262    /**
8263     * Resolve a CSS `background-size` value to a concrete (w, h)
8264     * for a gradient (no intrinsic dimensions, no intrinsic ratio).
8265     * See {@see gradientTileCells} for how the tile is positioned.
8266     *
8267     * @return array{0: float, 1: float}
8268     */
8269    private function resolveGradientTileSize(
8270        ?\Phpdftk\Css\Value\Value $sizeValue,
8271        float $originWidth,
8272        float $originHeight,
8273    ): array {
8274        if ($sizeValue === null) {
8275            return [$originWidth, $originHeight];
8276        }
8277        if ($sizeValue instanceof \Phpdftk\Css\Value\Keyword) {
8278            $kw = strtolower($sizeValue->name);
8279            // `cover` / `contain` need an intrinsic ratio to do
8280            // anything useful; without one they degrade to stretch.
8281            return [$originWidth, $originHeight];
8282        }
8283        if ($sizeValue instanceof \Phpdftk\Css\Value\Length) {
8284            // Single length sets width; height = auto = 100% of
8285            // positioning area (no intrinsic ratio for gradients).
8286            return [$sizeValue->value, $originHeight];
8287        }
8288        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
8289            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
8290        ) {
8291            $w = $sizeValue->values[0] ?? null;
8292            $h = $sizeValue->values[1] ?? null;
8293            $tileW = $w instanceof \Phpdftk\Css\Value\Length ? $w->value : $originWidth;
8294            $tileH = $h instanceof \Phpdftk\Css\Value\Length ? $h->value : $originHeight;
8295            // Percentage tile dims resolve against the positioning area.
8296            if ($w instanceof \Phpdftk\Css\Value\Percentage) {
8297                $tileW = $originWidth * ($w->value / 100.0);
8298            }
8299            if ($h instanceof \Phpdftk\Css\Value\Percentage) {
8300                $tileH = $originHeight * ($h->value / 100.0);
8301            }
8302            return [$tileW, $tileH];
8303        }
8304        return [$originWidth, $originHeight];
8305    }
8306
8307    /**
8308     * Resolve a two-component `background-size` where one or both
8309     * sides may be `auto`. Implements CSS Backgrounds 3 Â§3.9 auto
8310     * resolution: intrinsic ratio derives the missing side; if no
8311     * ratio, auto resolves to 100% of the positioning area.
8312     *
8313     * @return array{0: float, 1: float}
8314     */
8315    private function resolveAutoSizePair(
8316        ?float $explicitW,
8317        ?float $explicitH,
8318        string $src,
8319        float $boxWidth,
8320        float $boxHeight,
8321    ): array {
8322        if ($explicitW !== null && $explicitH !== null) {
8323            return [$explicitW, $explicitH];
8324        }
8325        // Prefer the FULL intrinsic (raster, or SVG with both axes
8326        // either fixed or derivable from viewBox). Only fall to the
8327        // partial helper when no complete intrinsic is available â€”
8328        // e.g. an SVG with just one fixed axis and no viewBox.
8329        $intrinsic = $this->intrinsicSize($src);
8330        $hasFullIntrinsic = $intrinsic !== null && $intrinsic[0] > 0 && $intrinsic[1] > 0;
8331        if ($hasFullIntrinsic) {
8332            $natW = (float) $intrinsic[0];
8333            $natH = (float) $intrinsic[1];
8334            if ($explicitW === null && $explicitH === null) {
8335                return [$natW, $natH];
8336            }
8337            if ($explicitW !== null) {
8338                return [$explicitW, $explicitW * ($natH / $natW)];
8339            }
8340            assert($explicitH !== null);
8341            return [$explicitH * ($natW / $natH), $explicitH];
8342        }
8343        $partial = $this->intrinsicSizePartial($src);
8344        $intW = $partial['w'];
8345        $intH = $partial['h'];
8346        if ($explicitW === null && $explicitH === null) {
8347            return [$intW ?? $boxWidth, $intH ?? $boxHeight];
8348        }
8349        if ($explicitW !== null) {
8350            return [$explicitW, $intH ?? $boxHeight];
8351        }
8352        assert($explicitH !== null);
8353        return [$intW ?? $boxWidth, $explicitH];
8354    }
8355
8356    /**
8357     * Partial intrinsic dimensions of an image. Unlike
8358     * {@see intrinsicSize}, this returns nullable per-axis values
8359     * plus a `hasRatio` flag so callers (notably the `bg-size: auto`
8360     * branches of {@see resolveBackgroundSize} / {@see resolveAutoSizePair})
8361     * can honour SVGs that declare only one of width / height /
8362     * viewBox per CSS Backgrounds 3 Â§3.9. For raster images the
8363     * shape is always full (both axes set, hasRatio derived).
8364     *
8365     * @return array{w: ?float, h: ?float, hasRatio: bool}
8366     */
8367    private function intrinsicSizePartial(string $src): array
8368    {
8369        if ($this->isSvgSrc($src)) {
8370            $svg = $this->loadSvgDocument($src);
8371            if ($svg === null) {
8372                return ['w' => null, 'h' => null, 'hasRatio' => false];
8373            }
8374            $w = self::parseSvgLengthAttribute($svg->widthAttribute());
8375            $h = self::parseSvgLengthAttribute($svg->heightAttribute());
8376            $viewBox = $svg->viewBox();
8377            // A ratio comes from any of: viewBox, fixed width+height,
8378            // or viewBox alone â€” anything that gives both axes
8379            // mathematically. The earlier `intrinsicSvgSize` already
8380            // backfills missing axes from ratio, so by the time
8381            // we're here for "partial intrinsic" we typically have
8382            // at most one fixed dim. Mirror the same ratio sources.
8383            $hasRatio = ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0)
8384                || ($w !== null && $h !== null && $h > 0.0);
8385            return ['w' => $w, 'h' => $h, 'hasRatio' => $hasRatio];
8386        }
8387        $intrinsic = $this->intrinsicSize($src);
8388        if ($intrinsic === null) {
8389            return ['w' => null, 'h' => null, 'hasRatio' => false];
8390        }
8391        return [
8392            'w' => (float) $intrinsic[0],
8393            'h' => (float) $intrinsic[1],
8394            'hasRatio' => $intrinsic[0] > 0 && $intrinsic[1] > 0,
8395        ];
8396    }
8397
8398    /**
8399     * Read the intrinsic pixel dimensions of an image referenced by an
8400     * `<img src>` or `background-image: url(...)` value. Tolerates both
8401     * `data:image/...` URIs and resolved local-file paths via the
8402     * painter's existing `resolveImageSrc`. Returns null when the bytes
8403     * can't be read or parsed.
8404     *
8405     * @return array{int, int}|null
8406     */
8407    private function intrinsicSize(string $src): ?array
8408    {
8409        if ($this->isSvgSrc($src)) {
8410            $svg = $this->loadSvgDocument($src);
8411            if ($svg === null) {
8412                return null;
8413            }
8414            return $this->intrinsicSvgSize($svg);
8415        }
8416        try {
8417            if (str_starts_with($src, 'data:image/')) {
8418                if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $src, $m) !== 1) {
8419                    return null;
8420                }
8421                $payload = $m[2] === 'base64,'
8422                    ? base64_decode($m[3], strict: true)
8423                    : urldecode($m[3]);
8424                if ($payload === false || $payload === '') {
8425                    return null;
8426                }
8427                $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload);
8428            } else {
8429                $resolved = $this->resolveImageSrc($src);
8430                if ($resolved === null) {
8431                    return null;
8432                }
8433                $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved);
8434            }
8435        } catch (\Throwable) {
8436            return null;
8437        }
8438        return [$info->width, $info->height];
8439    }
8440
8441    /**
8442     * Detect SVG background sources: `.svg` URLs and `data:image/svg+xml`
8443     * URIs. Case-insensitive on the extension to match CSS / file-system
8444     * conventions.
8445     */
8446    private function isSvgSrc(string $src): bool
8447    {
8448        if (str_starts_with(strtolower($src), 'data:image/svg+xml')) {
8449            return true;
8450        }
8451        $path = parse_url($src, PHP_URL_PATH);
8452        if (!is_string($path) || $path === '') {
8453            $path = $src;
8454        }
8455        return strtolower((string) pathinfo($path, PATHINFO_EXTENSION)) === 'svg';
8456    }
8457
8458    /**
8459     * Parse an SVG background source into an `SvgDocument`, memoising by
8460     * `$src` so each unique URL is read + parsed once per Painter. Returns
8461     * `null` on read / parse failure (the caller paints nothing â€” same
8462     * fallback as raster sources that fail to parse).
8463     */
8464    private function loadSvgDocument(string $src): ?\Phpdftk\Svg\SvgDocument
8465    {
8466        if (array_key_exists($src, $this->svgDocumentCache)) {
8467            $cached = $this->svgDocumentCache[$src];
8468            return $cached === false ? null : $cached;
8469        }
8470        $bytes = null;
8471        try {
8472            if (str_starts_with($src, 'data:')) {
8473                $bytes = $this->decodeSvgDataUri($src);
8474            } else {
8475                $resolved = $this->resolveImageSrc($src);
8476                if ($resolved !== null) {
8477                    $bytes = \Phpdftk\Filesystem\LocalFilesystem::readFile($resolved, 'SVG background-image');
8478                }
8479            }
8480            if ($bytes === null || $bytes === '') {
8481                $this->svgDocumentCache[$src] = false;
8482                return null;
8483            }
8484            $doc = (new \Phpdftk\Svg\Parser())->parse($bytes);
8485        } catch (\Throwable) {
8486            $this->svgDocumentCache[$src] = false;
8487            return null;
8488        }
8489        $this->svgDocumentCache[$src] = $doc;
8490        return $doc;
8491    }
8492
8493    private function decodeSvgDataUri(string $src): ?string
8494    {
8495        if (preg_match('~^data:image/svg\+xml(?:;[^,]*)?,(.*)$~is', $src, $m) !== 1) {
8496            return null;
8497        }
8498        $payload = $m[1];
8499        if (str_contains(strtolower($src), ';base64,')) {
8500            $decoded = base64_decode($payload, strict: true);
8501            return $decoded === false ? null : $decoded;
8502        }
8503        return rawurldecode($payload);
8504    }
8505
8506    /**
8507     * Derive an intrinsic pixel size from an `<svg>` element per
8508     * CSS Images 3 Â§5.2:
8509     *   - both width + height set in absolute units    â†’ use them
8510     *   - only width  + intrinsic aspect ratio         â†’ (w, w/aspect)
8511     *   - only height + intrinsic aspect ratio         â†’ (h*aspect, h)
8512     *   - viewBox only                                 â†’ viewBox dims
8513     *   - nothing useful                               â†’ null
8514     *
8515     * Returns null when the SVG has no intrinsic dimensions AND no
8516     * intrinsic ratio. The caller (`resolveBackgroundSize` / friends)
8517     * then falls back to the background-positioning area for `cover`
8518     * / `contain` (CSS Backgrounds 3 Â§3.9 â€” "no intrinsic ratio â†’
8519     * 100% Ã— 100% of the positioning area"). Earlier this method
8520     * returned `[300, 150]` (the CSS Images 3 Â§5.3 default object
8521     * size), but that constant misroutes the cover/contain math for
8522     * SVGs without intrinsic information.
8523     *
8524     * @return array{int, int}|null
8525     */
8526    private function intrinsicSvgSize(\Phpdftk\Svg\SvgDocument $svg): ?array
8527    {
8528        $w = self::parseSvgLengthAttribute($svg->widthAttribute());
8529        $h = self::parseSvgLengthAttribute($svg->heightAttribute());
8530        $viewBox = $svg->viewBox();
8531        $aspect = null;
8532        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
8533            $aspect = $viewBox[2] / $viewBox[3];
8534        } elseif ($w !== null && $h !== null && $h > 0.0) {
8535            $aspect = $w / $h;
8536        }
8537        if ($w !== null && $h !== null) {
8538            return [max(1, (int) round($w)), max(1, (int) round($h))];
8539        }
8540        // Derived-from-aspect: keep the clamp on the *explicit* side
8541        // (so a real `width="8"` survives rounding), but let the
8542        // derived side fall to zero when an extreme viewBox aspect
8543        // (e.g. `2147483647:1`) makes the other dimension
8544        // mathematically negligible. CSS Backgrounds 3 Â§3.9 then
8545        // resolves `contain` to a zero-extent tile â€” matching the
8546        // browser-visible "renders as empty" outcome that the
8547        // `tall--contain--height` / `wide--contain--height` reftests
8548        // expect for SVGs with extreme viewBox aspect ratios.
8549        if ($w !== null && $aspect !== null && $aspect > 0.0) {
8550            return [max(1, (int) round($w)), max(0, (int) round($w / $aspect))];
8551        }
8552        if ($h !== null && $aspect !== null && $aspect > 0.0) {
8553            return [max(0, (int) round($h * $aspect)), max(1, (int) round($h))];
8554        }
8555        if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) {
8556            return [max(1, (int) round($viewBox[2])), max(1, (int) round($viewBox[3]))];
8557        }
8558        return null;
8559    }
8560
8561    /**
8562     * Parse an SVG length attribute like `"8"`, `"8px"`, `"50%"`. Returns
8563     * a float when the value is an absolute length (with no unit or `px`);
8564     * returns null for percentages, em / rem / vw etc. â€” those aren't
8565     * intrinsic for sizing purposes (CSS Images 3 Â§5.2).
8566     */
8567    private static function parseSvgLengthAttribute(?string $raw): ?float
8568    {
8569        if ($raw === null) {
8570            return null;
8571        }
8572        $raw = trim($raw);
8573        if ($raw === '') {
8574            return null;
8575        }
8576        if (preg_match('/^(-?\d+(?:\.\d+)?)(px)?$/', $raw, $m) !== 1) {
8577            return null;
8578        }
8579        return (float) $m[1];
8580    }
8581
8582    /**
8583     * Paint a CSS `border-image` 9-slice grid in place of the per-side
8584     * border colours/styles (CSS Backgrounds 3 Â§6). Returns true when
8585     * a border-image was painted; the caller skips legacy border
8586     * painting in that case.
8587     *
8588     * Phase-1 scope:
8589     *   â€¢ `border-image-source`: `url(...)` raster (PNG/JPG) only
8590     *   â€¢ `border-image-slice`: a single number (px from each edge)
8591     *     or `<n> fill` â€” single-percentage and four-value variants
8592     *     are a follow-up
8593     *   â€¢ `border-image-width`: implicit (use the box's border-width)
8594     *   â€¢ `border-image-outset`: implicit (zero)
8595     *   â€¢ `border-image-repeat`: `stretch` (initial), `repeat`, `round`
8596     *
8597     * Anything outside that surface falls through to the legacy
8598     * paint-borders path.
8599     */
8600    private function paintBorderImage(Box $box, ContentStream $stream): bool
8601    {
8602        if ($this->writer === null || $this->page === null) {
8603            return false;
8604        }
8605        $source = $box->style->get('border-image-source');
8606        if (!$source instanceof \Phpdftk\Css\Value\Url) {
8607            return false;
8608        }
8609        // Raster only for the first cut â€” SVG border-image needs the
8610        // SvgRenderer slicing path, deferred.
8611        if ($this->isSvgSrc($source->url)) {
8612            return false;
8613        }
8614        if (isset($this->imageNameCache[$source->url])) {
8615            $name = $this->imageNameCache[$source->url];
8616        } else {
8617            $resolved = $this->resolveImageSrc($source->url);
8618            if ($resolved === null) {
8619                return false;
8620            }
8621            try {
8622                $name = $this->writer->addImage($resolved, $this->page);
8623            } catch (\Throwable) {
8624                return false;
8625            }
8626            $this->imageNameCache[$source->url] = $name;
8627        }
8628        $intrinsic = $this->intrinsicSize($source->url);
8629        if ($intrinsic === null) {
8630            return false;
8631        }
8632        [$srcW, $srcH] = $intrinsic;
8633        if ($srcW <= 0 || $srcH <= 0) {
8634            return false;
8635        }
8636        // Slice dimensions: 1–4 values in `<number>` / `<length>` /
8637        // `<percentage>`, applied to top/right/bottom/left in the
8638        // standard "TRBL fill-in" rule (CSS Backgrounds 3 Â§6.4.3).
8639        // Horizontal slices (top, bottom) resolve their percentages
8640        // against the image's height; vertical slices (left, right)
8641        // resolve against width.
8642        $sliceValue = $box->style->get('border-image-slice');
8643        $slices = $this->resolveBorderImageSliceSides($sliceValue, (float) $srcW, (float) $srcH);
8644        if ($slices === null) {
8645            return false;
8646        }
8647        [$st, $sr, $sb, $sl] = $slices;
8648        $st = min($st, (float) $srcH / 2.0);
8649        $sb = min($sb, (float) $srcH / 2.0);
8650        $sl = min($sl, (float) $srcW / 2.0);
8651        $sr = min($sr, (float) $srcW / 2.0);
8652        // `border-image-slice ... fill` keeps the middle image region and
8653        // paints it into the content box (CSS Backgrounds 3 Â§6.2). With
8654        // `slice: 0 fill` every edge slice is zero yet the middle must still
8655        // paint, so don't bail on all-zero slices when `fill` is present.
8656        $hasFill = $this->borderImageSliceHasFill($sliceValue);
8657        if (!$hasFill && $st <= 0.0 && $sr <= 0.0 && $sb <= 0.0 && $sl <= 0.0) {
8658            return false;
8659        }
8660
8661        [$repeatH, $repeatV] = $this->parseBorderImageRepeatModes($box->style->get('border-image-repeat'));
8662
8663        // Destination border area: the box's border-box rect, grown by
8664        // `border-image-outset` (CSS Backgrounds 3 Â§6.6 â€” extends the area
8665        // outward past the border edge, into the margin).
8666        $geo = $box->geometry;
8667        $bx = $geo->x - $geo->paddingLeft - $geo->borderLeft;
8668        $by = $geo->y - $geo->paddingTop - $geo->borderTop;
8669        $bw = $geo->paddingLeft + $geo->width + $geo->paddingRight
8670            + $geo->borderLeft + $geo->borderRight;
8671        $bh = $geo->paddingTop + $geo->height + $geo->paddingBottom
8672            + $geo->borderTop + $geo->borderBottom;
8673        [$ot, $or, $ob, $ol] = $this->resolveBorderImageOutset(
8674            $box->style->get('border-image-outset'),
8675            $geo->borderTop,
8676            $geo->borderRight,
8677            $geo->borderBottom,
8678            $geo->borderLeft,
8679        );
8680        $bx -= $ol;
8681        $by -= $ot;
8682        $bw += $ol + $or;
8683        $bh += $ot + $ob;
8684        // `border-image-width` sizes the destination border slices; it
8685        // defaults to the border-widths but may be a multiple / length /
8686        // percentage that extends inward past the border into the padding.
8687        [$bt, $br, $bb, $bl] = $this->resolveBorderImageWidth(
8688            $box->style->get('border-image-width'),
8689            $geo->borderTop,
8690            $geo->borderRight,
8691            $geo->borderBottom,
8692            $geo->borderLeft,
8693            $bw,
8694            $bh,
8695        );
8696        if ($bw <= 0.0 || $bh <= 0.0) {
8697            return false;
8698        }
8699
8700        // 9 destination + source rects. Source coords use the SVG/PNG
8701        // convention: (0, 0) top-left, y grows down. Destination coords
8702        // here are layout-space (Y down too); we convert to PDF
8703        // (Y up) inside `emitImageSlice`.
8704        $midSrcW = max(0.0, (float) $srcW - $sl - $sr);
8705        $midSrcH = max(0.0, (float) $srcH - $st - $sb);
8706        $midDstW = max(0.0, $bw - $bl - $br);
8707        $midDstH = max(0.0, $bh - $bt - $bb);
8708
8709        // Corners â€” always stretched (no tile / round per spec Â§6.3).
8710        $this->emitImageSlice($stream, $name, $srcW, $srcH, 0.0, 0.0, $sl, $st, $bx, $by, $bl, $bt);
8711        $this->emitImageSlice($stream, $name, $srcW, $srcH, (float) $srcW - $sr, 0.0, $sr, $st, $bx + $bw - $br, $by, $br, $bt);
8712        $this->emitImageSlice($stream, $name, $srcW, $srcH, 0.0, (float) $srcH - $sb, $sl, $sb, $bx, $by + $bh - $bb, $bl, $bb);
8713        $this->emitImageSlice($stream, $name, $srcW, $srcH, (float) $srcW - $sr, (float) $srcH - $sb, $sr, $sb, $bx + $bw - $br, $by + $bh - $bb, $br, $bb);
8714
8715        // Edges â€” apply repeat mode to the tile axis only. Each edge also
8716        // needs a non-zero SOURCE slice thickness ($st/$sb for the
8717        // horizontal edges, $sl/$sr for the vertical ones); a zero-thickness
8718        // slice (e.g. `slice: 0 fill`) has no edge image to sample and would
8719        // divide by zero in emitImageEdge.
8720        if ($midSrcW > 0.0 && $midDstW > 0.0 && $bt > 0.0 && $st > 0.0) {
8721            $this->emitImageEdge(
8722                $stream,
8723                $name,
8724                $srcW,
8725                $srcH,
8726                $sl,
8727                0.0,
8728                $midSrcW,
8729                $st,
8730                $bx + $bl,
8731                $by,
8732                $midDstW,
8733                $bt,
8734                $repeatH,
8735                horizontal: true,
8736            );
8737        }
8738        if ($midSrcW > 0.0 && $midDstW > 0.0 && $bb > 0.0 && $sb > 0.0) {
8739            $this->emitImageEdge(
8740                $stream,
8741                $name,
8742                $srcW,
8743                $srcH,
8744                $sl,
8745                (float) $srcH - $sb,
8746                $midSrcW,
8747                $sb,
8748                $bx + $bl,
8749                $by + $bh - $bb,
8750                $midDstW,
8751                $bb,
8752                $repeatH,
8753                horizontal: true,
8754            );
8755        }
8756        if ($midSrcH > 0.0 && $midDstH > 0.0 && $bl > 0.0 && $sl > 0.0) {
8757            $this->emitImageEdge(
8758                $stream,
8759                $name,
8760                $srcW,
8761                $srcH,
8762                0.0,
8763                $st,
8764                $sl,
8765                $midSrcH,
8766                $bx,
8767                $by + $bt,
8768                $bl,
8769                $midDstH,
8770                $repeatV,
8771                horizontal: false,
8772            );
8773        }
8774        if ($midSrcH > 0.0 && $midDstH > 0.0 && $br > 0.0 && $sr > 0.0) {
8775            $this->emitImageEdge(
8776                $stream,
8777                $name,
8778                $srcW,
8779                $srcH,
8780                (float) $srcW - $sr,
8781                $st,
8782                $sr,
8783                $midSrcH,
8784                $bx + $bw - $br,
8785                $by + $bt,
8786                $br,
8787                $midDstH,
8788                $repeatV,
8789                horizontal: false,
8790            );
8791        }
8792
8793        // Middle fill (region 5) â€” CSS Backgrounds 3 Â§6.2 `fill`. The middle
8794        // image is drawn into the box's content area governed by the same
8795        // repeat modes as the edges: `stretch` scales it to fill; `repeat` /
8796        // `round` / `space` tile it at its natural (intrinsic) size, centred,
8797        // and clip to the content area â€” so an over-large middle shows only
8798        // its centre.
8799        if ($hasFill
8800            && $midSrcW > 0.0 && $midSrcH > 0.0
8801            && $midDstW > 0.0 && $midDstH > 0.0
8802        ) {
8803            $this->emitImageMiddleFill(
8804                $stream,
8805                $name,
8806                $srcW,
8807                $srcH,
8808                $sl,
8809                $st,
8810                $midSrcW,
8811                $midSrcH,
8812                $bx + $bl,
8813                $by + $bt,
8814                $midDstW,
8815                $midDstH,
8816                $repeatH,
8817                $repeatV,
8818            );
8819        }
8820        return true;
8821    }
8822
8823    /**
8824     * Paint the `border-image` middle region (the `fill` keyword) into the
8825     * content area, tiling per axis. `stretch` scales the middle to fill that
8826     * axis; `repeat`/`round`/`space` tile it at its intrinsic size (the layout
8827     * geometry is in CSS px, so 1 source px = 1 unit), centred and clipped to
8828     * the content area.
8829     */
8830    private function emitImageMiddleFill(
8831        ContentStream $stream,
8832        string $imageName,
8833        int $srcW,
8834        int $srcH,
8835        float $sx,
8836        float $sy,
8837        float $sw,
8838        float $sh,
8839        float $dx,
8840        float $dy,
8841        float $dw,
8842        float $dh,
8843        string $repeatH,
8844        string $repeatV,
8845    ): void {
8846        if ($sw <= 0.0 || $sh <= 0.0 || $dw <= 0.0 || $dh <= 0.0) {
8847            return;
8848        }
8849        [$tileW, $offX] = $this->borderImageFillAxis($repeatH, $dw, $sw);
8850        [$tileH, $offY] = $this->borderImageFillAxis($repeatV, $dh, $sh);
8851        if ($tileW <= 0.0 || $tileH <= 0.0 || $offX === [] || $offY === []) {
8852            return;
8853        }
8854        $pdfDy = $this->pageHeight - $dy - $dh;
8855        $stream->saveGraphicsState();
8856        $stream->rectangle($dx, $pdfDy, $dw, $dh);
8857        $stream->clip();
8858        $stream->endPath();
8859        foreach ($offY as $oy) {
8860            foreach ($offX as $ox) {
8861                $this->emitImageSlice(
8862                    $stream,
8863                    $imageName,
8864                    $srcW,
8865                    $srcH,
8866                    $sx,
8867                    $sy,
8868                    $sw,
8869                    $sh,
8870                    $dx + $ox,
8871                    $dy + $oy,
8872                    $tileW,
8873                    $tileH,
8874                );
8875            }
8876        }
8877        $stream->restoreGraphicsState();
8878    }
8879
8880    /**
8881     * Tile-placement for one axis of the border-image middle fill. Returns
8882     * `[tileLength, [offsets…]]` in the destination axis. Mirrors the edge
8883     * repeat rules (CSS Backgrounds 3 Â§6.2): `stretch` â†’ one dest-spanning
8884     * tile; `round` â†’ rescaled to a whole count; `space` â†’ whole tiles with
8885     * distributed gaps; `repeat` â†’ natural size, centred, partial tiles clipped.
8886     *
8887     * @return array{0: float, 1: list<float>}
8888     */
8889    private function borderImageFillAxis(string $mode, float $axisDest, float $natLen): array
8890    {
8891        if ($mode === 'stretch' || $natLen <= 0.0) {
8892            return [$axisDest, [0.0]];
8893        }
8894        if ($mode === 'round') {
8895            $n = max(1, (int) round($axisDest / $natLen));
8896            $tile = $axisDest / $n;
8897            $offsets = [];
8898            for ($i = 0; $i < $n; $i++) {
8899                $offsets[] = $i * $tile;
8900            }
8901            return [$tile, $offsets];
8902        }
8903        if ($mode === 'space') {
8904            $n = (int) floor($axisDest / $natLen + 1e-6);
8905            if ($n < 1) {
8906                return [$natLen, []];
8907            }
8908            $gap = ($axisDest - $n * $natLen) / ($n + 1);
8909            $offsets = [];
8910            for ($i = 0; $i < $n; $i++) {
8911                $offsets[] = ($i + 1) * $gap + $i * $natLen;
8912            }
8913            return [$natLen, $offsets];
8914        }
8915        // repeat â€” natural tile, centred, partial end tiles clipped.
8916        $remainder = fmod($axisDest, $natLen);
8917        $start = $remainder > 1e-6 ? -($natLen - $remainder) / 2.0 : 0.0;
8918        $offsets = [];
8919        for ($pos = $start; $pos < $axisDest - 1e-6 && count($offsets) < 4096; $pos += $natLen) {
8920            $offsets[] = $pos;
8921        }
8922        return [$natLen, $offsets];
8923    }
8924
8925    /**
8926     * Whether a `border-image-slice` value carries the `fill` keyword, which
8927     * preserves and paints the middle image region into the content box
8928     * (CSS Backgrounds 3 Â§6.2).
8929     */
8930    private function borderImageSliceHasFill(?\Phpdftk\Css\Value\Value $value): bool
8931    {
8932        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
8933            return strtolower($value->name) === 'fill';
8934        }
8935        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
8936            foreach ($value->values as $v) {
8937                if ($v instanceof \Phpdftk\Css\Value\Keyword
8938                    && strtolower($v->name) === 'fill'
8939                ) {
8940                    return true;
8941                }
8942            }
8943        }
8944        return false;
8945    }
8946
8947    /**
8948     * Emit a single image slice â€” the source rect (sx, sy, sw, sh) in
8949     * source pixel coords lands at destination rect (dx, dy, dw, dh)
8950     * in layout coords. Stretched (no tiling) â€” used for corners.
8951     */
8952    private function emitImageSlice(
8953        ContentStream $stream,
8954        string $imageName,
8955        int $srcW,
8956        int $srcH,
8957        float $sx,
8958        float $sy,
8959        float $sw,
8960        float $sh,
8961        float $dx,
8962        float $dy,
8963        float $dw,
8964        float $dh,
8965    ): void {
8966        if ($sw <= 0.0 || $sh <= 0.0 || $dw <= 0.0 || $dh <= 0.0) {
8967            return;
8968        }
8969        // PDF y is up; image unit-y=0 is bottom (after the y-flip
8970        // implicit in addImage). Source rect's bottom in source-px:
8971        // `srcH - (sy + sh)`. Place the FULL image such that this
8972        // bottom-of-slice falls on PDF dy.
8973        $pdfDy = $this->pageHeight - $dy - $dh;
8974        $sliceBotSrc = (float) $srcH - $sy - $sh;
8975        $scaleX = $dw * (float) $srcW / $sw;
8976        $scaleY = $dh * (float) $srcH / $sh;
8977        $tx = $dx - $dw * $sx / $sw;
8978        $ty = $pdfDy - $dh * $sliceBotSrc / $sh;
8979        $stream->saveGraphicsState();
8980        $stream->rectangle($dx, $pdfDy, $dw, $dh);
8981        $stream->clip();
8982        $stream->endPath();
8983        $stream->concatMatrix($scaleX, 0.0, 0.0, $scaleY, $tx, $ty);
8984        $stream->doXObject($imageName);
8985        $stream->restoreGraphicsState();
8986    }
8987
8988    /**
8989     * Emit an edge slice (top/right/bottom/left) of a border-image.
8990     * `horizontal: true` tiles along the X axis; false tiles along Y.
8991     * `repeatMode`: 'stretch' (default) â€” single stretched draw;
8992     * 'repeat' â€” tile at natural size from the leading edge; 'round'
8993     * â€” scale-to-fit-whole-tiles.
8994     */
8995    private function emitImageEdge(
8996        ContentStream $stream,
8997        string $imageName,
8998        int $srcW,
8999        int $srcH,
9000        float $sx,
9001        float $sy,
9002        float $sw,
9003        float $sh,
9004        float $dx,
9005        float $dy,
9006        float $dw,
9007        float $dh,
9008        string $repeatMode,
9009        bool $horizontal,
9010    ): void {
9011        if ($repeatMode === 'stretch') {
9012            $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx, $dy, $dw, $dh);
9013            return;
9014        }
9015        // Natural tile length along the variable axis (the source edge
9016        // slice scaled to the border thickness); the other dim is fixed.
9017        $tileLen = $horizontal ? $dh * $sw / $sh : $dw * $sh / $sw;
9018        $axisDest = $horizontal ? $dw : $dh;
9019        if ($tileLen <= 0.0 || $axisDest <= 0.0) {
9020            return;
9021        }
9022        // Per CSS Backgrounds 3 Â§6.2, compute the tile start offsets along
9023        // the axis for each repeat mode:
9024        //   round  â€” rescale so a whole number fits, butted, no gaps.
9025        //   space  â€” as many whole tiles as fit, leftover distributed as
9026        //            equal gaps BETWEEN and AROUND the tiles ((n+1) gaps),
9027        //            so a single tile is centred; no whole tile â†’ nothing.
9028        //   repeat â€” natural size, the tiling centred and partial end
9029        //            tiles clipped.
9030        $offsets = [];
9031        if ($repeatMode === 'round') {
9032            $n = max(1, (int) round($axisDest / $tileLen));
9033            $tileLen = $axisDest / $n;
9034            for ($i = 0; $i < $n; $i++) {
9035                $offsets[] = $i * $tileLen;
9036            }
9037        } elseif ($repeatMode === 'space') {
9038            $n = (int) floor($axisDest / $tileLen + 1e-6);
9039            if ($n < 1) {
9040                return;
9041            }
9042            $gap = ($axisDest - $n * $tileLen) / ($n + 1);
9043            for ($i = 0; $i < $n; $i++) {
9044                $offsets[] = ($i + 1) * $gap + $i * $tileLen;
9045            }
9046        } else {
9047            $remainder = fmod($axisDest, $tileLen);
9048            $start = $remainder > 1e-6 ? -($tileLen - $remainder) / 2.0 : 0.0;
9049            for ($pos = $start; $pos < $axisDest - 1e-6 && count($offsets) < 4096; $pos += $tileLen) {
9050                $offsets[] = $pos;
9051            }
9052        }
9053        // Clip to the destination edge so partial / spaced tiles crop cleanly.
9054        $pdfDy = $this->pageHeight - $dy - $dh;
9055        $stream->saveGraphicsState();
9056        $stream->rectangle($dx, $pdfDy, $dw, $dh);
9057        $stream->clip();
9058        $stream->endPath();
9059        foreach ($offsets as $off) {
9060            if ($horizontal) {
9061                $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx + $off, $dy, $tileLen, $dh);
9062            } else {
9063                $this->emitImageSlice($stream, $imageName, $srcW, $srcH, $sx, $sy, $sw, $sh, $dx, $dy + $off, $dw, $tileLen);
9064            }
9065        }
9066        $stream->restoreGraphicsState();
9067    }
9068
9069    private function parseBorderImageSliceNumber(?\Phpdftk\Css\Value\Value $value): ?float
9070    {
9071        if ($value instanceof \Phpdftk\Css\Value\Number
9072            || $value instanceof \Phpdftk\Css\Value\Integer
9073        ) {
9074            return (float) $value->value;
9075        }
9076        if ($value instanceof \Phpdftk\Css\Value\Length) {
9077            return $value->value;
9078        }
9079        // Single number wrapped in a ValueList with an optional `fill`
9080        // keyword â€” accept the leading number; the `fill` middle paint
9081        // is a follow-up.
9082        if ($value instanceof \Phpdftk\Css\Value\ValueList && $value->values !== []) {
9083            return $this->parseBorderImageSliceNumber($value->values[0]);
9084        }
9085        return null;
9086    }
9087
9088    /**
9089     * Resolve a single `border-image-slice` component (number, length
9090     * or percentage). Percentages resolve against the supplied axis
9091     * extent per CSS Backgrounds 3 Â§6.4.3. Returns null when the value
9092     * isn't a slice-shape we recognise.
9093     */
9094    private function resolveBorderImageSliceComponent(
9095        ?\Phpdftk\Css\Value\Value $value,
9096        float $axisExtent,
9097    ): ?float {
9098        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
9099            return $value->value / 100.0 * $axisExtent;
9100        }
9101        return $this->parseBorderImageSliceNumber($value);
9102    }
9103
9104    /**
9105     * Expand the 1–4-value `border-image-slice` shorthand into the
9106     * per-side `[top, right, bottom, left]` tuple. Drops a trailing
9107     * `fill` keyword (handled separately when middle-fill paint
9108     * lands).
9109     *
9110     * @return array{0:float, 1:float, 2:float, 3:float}|null
9111     */
9112    private function resolveBorderImageSliceSides(
9113        ?\Phpdftk\Css\Value\Value $value,
9114        float $srcW,
9115        float $srcH,
9116    ): ?array {
9117        $components = [];
9118        if ($value instanceof \Phpdftk\Css\Value\ValueList
9119            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
9120        ) {
9121            foreach ($value->values as $v) {
9122                if ($v instanceof \Phpdftk\Css\Value\Keyword
9123                    && strtolower($v->name) === 'fill'
9124                ) {
9125                    continue;
9126                }
9127                $components[] = $v;
9128            }
9129        } elseif ($value !== null) {
9130            $components[] = $value;
9131        }
9132        if ($components === []) {
9133            return null;
9134        }
9135        // Horizontal slices (top, bottom) resolve `%` against srcH;
9136        // vertical (right, left) against srcW.
9137        $sides = [
9138            'top' => $this->resolveBorderImageSliceComponent($components[0], $srcH),
9139        ];
9140        if (isset($components[1])) {
9141            $sides['right'] = $this->resolveBorderImageSliceComponent($components[1], $srcW);
9142        } else {
9143            $sides['right'] = $sides['top'];
9144        }
9145        if (isset($components[2])) {
9146            $sides['bottom'] = $this->resolveBorderImageSliceComponent($components[2], $srcH);
9147        } else {
9148            $sides['bottom'] = $sides['top'];
9149        }
9150        if (isset($components[3])) {
9151            $sides['left'] = $this->resolveBorderImageSliceComponent($components[3], $srcW);
9152        } else {
9153            $sides['left'] = $sides['right'];
9154        }
9155        foreach ($sides as $side) {
9156            if ($side === null) {
9157                return null;
9158            }
9159        }
9160        return [$sides['top'], $sides['right'], $sides['bottom'], $sides['left']];
9161    }
9162
9163    /**
9164     * Resolve `border-image-width` to the per-side used widths `[top, right,
9165     * bottom, left]` of the destination border-image area. A `<number>` is a
9166     * multiple of the corresponding computed border-width; `<length>` is
9167     * absolute; `<percentage>` is relative to the border-image area (its
9168     * height for the top/bottom edges, its width for left/right); `auto`
9169     * falls back to the border-width. Opposite pairs that overflow the area
9170     * are reduced together (CSS Backgrounds 3 Â§6.5). Unset â†’ the border
9171     * widths (the initial `1`).
9172     *
9173     * @return array{0: float, 1: float, 2: float, 3: float}
9174     */
9175    private function resolveBorderImageWidth(
9176        ?\Phpdftk\Css\Value\Value $value,
9177        float $borderTop,
9178        float $borderRight,
9179        float $borderBottom,
9180        float $borderLeft,
9181        float $areaWidth,
9182        float $areaHeight,
9183    ): array {
9184        $borders = [$borderTop, $borderRight, $borderBottom, $borderLeft];
9185        // Top/bottom offsets are vertical â†’ resolve `%` against the area
9186        // height; left/right are horizontal â†’ against the width.
9187        $areaForEdge = [$areaHeight, $areaWidth, $areaHeight, $areaWidth];
9188        $components = [];
9189        if ($value instanceof \Phpdftk\Css\Value\ValueList
9190            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
9191        ) {
9192            $components = $value->values;
9193        } elseif ($value !== null) {
9194            $components = [$value];
9195        }
9196        // TRBL fill-in.
9197        $sides = [
9198            $components[0] ?? null,
9199            $components[1] ?? ($components[0] ?? null),
9200            $components[2] ?? ($components[0] ?? null),
9201            $components[3] ?? ($components[1] ?? ($components[0] ?? null)),
9202        ];
9203        $resolveOne = static function (?\Phpdftk\Css\Value\Value $v, int $edge) use ($borders, $areaForEdge): float {
9204            if ($v instanceof \Phpdftk\Css\Value\Keyword && strtolower($v->name) === 'auto') {
9205                return $borders[$edge];
9206            }
9207            if ($v instanceof \Phpdftk\Css\Value\Number || $v instanceof \Phpdftk\Css\Value\Integer) {
9208                return max(0.0, (float) $v->value) * $borders[$edge];
9209            }
9210            if ($v instanceof \Phpdftk\Css\Value\Length) {
9211                return max(0.0, $v->value);
9212            }
9213            if ($v instanceof \Phpdftk\Css\Value\Percentage) {
9214                return max(0.0, $areaForEdge[$edge] * $v->value / 100.0);
9215            }
9216            return $borders[$edge];
9217        };
9218        $wt = $resolveOne($sides[0], 0);
9219        $wr = $resolveOne($sides[1], 1);
9220        $wb = $resolveOne($sides[2], 2);
9221        $wl = $resolveOne($sides[3], 3);
9222        $f = 1.0;
9223        if ($wt + $wb > 0.0) {
9224            $f = min($f, $areaHeight / ($wt + $wb));
9225        }
9226        if ($wl + $wr > 0.0) {
9227            $f = min($f, $areaWidth / ($wl + $wr));
9228        }
9229        if ($f < 1.0) {
9230            $wt *= $f;
9231            $wr *= $f;
9232            $wb *= $f;
9233            $wl *= $f;
9234        }
9235        return [$wt, $wr, $wb, $wl];
9236    }
9237
9238    /**
9239     * Resolve `border-image-outset` to the per-side outward extension
9240     * `[top, right, bottom, left]`. A `<number>` is a multiple of the
9241     * corresponding computed border-width; `<length>` is absolute.
9242     * Unset â†’ 0. CSS Backgrounds 3 Â§6.6.
9243     *
9244     * @return array{0: float, 1: float, 2: float, 3: float}
9245     */
9246    private function resolveBorderImageOutset(
9247        ?\Phpdftk\Css\Value\Value $value,
9248        float $borderTop,
9249        float $borderRight,
9250        float $borderBottom,
9251        float $borderLeft,
9252    ): array {
9253        $borders = [$borderTop, $borderRight, $borderBottom, $borderLeft];
9254        $components = [];
9255        if ($value instanceof \Phpdftk\Css\Value\ValueList
9256            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
9257        ) {
9258            $components = $value->values;
9259        } elseif ($value !== null) {
9260            $components = [$value];
9261        }
9262        $sides = [
9263            $components[0] ?? null,
9264            $components[1] ?? ($components[0] ?? null),
9265            $components[2] ?? ($components[0] ?? null),
9266            $components[3] ?? ($components[1] ?? ($components[0] ?? null)),
9267        ];
9268        $resolveOne = static function (?\Phpdftk\Css\Value\Value $v, int $edge) use ($borders): float {
9269            if ($v instanceof \Phpdftk\Css\Value\Number || $v instanceof \Phpdftk\Css\Value\Integer) {
9270                return max(0.0, (float) $v->value) * $borders[$edge];
9271            }
9272            if ($v instanceof \Phpdftk\Css\Value\Length) {
9273                return max(0.0, $v->value);
9274            }
9275            return 0.0;
9276        };
9277        return [
9278            $resolveOne($sides[0], 0),
9279            $resolveOne($sides[1], 1),
9280            $resolveOne($sides[2], 2),
9281            $resolveOne($sides[3], 3),
9282        ];
9283    }
9284
9285    /**
9286     * `border-image-repeat` is one or two keywords: the first applies to the
9287     * horizontal edges (top / bottom), the second to the vertical edges
9288     * (left / right); a single value applies to both. Returns
9289     * `[horizontal, vertical]`. CSS Backgrounds 3 Â§6.2.
9290     *
9291     * @return array{0: string, 1: string}
9292     */
9293    private function parseBorderImageRepeatModes(?\Phpdftk\Css\Value\Value $value): array
9294    {
9295        $map = static fn(string $kw): string => match ($kw) {
9296            'repeat' => 'repeat',
9297            'round' => 'round',
9298            'space' => 'space',
9299            default => 'stretch',
9300        };
9301        $kw = static fn(?\Phpdftk\Css\Value\Value $v): ?string => $v instanceof \Phpdftk\Css\Value\Keyword
9302            ? $map(strtolower($v->name))
9303            : null;
9304        if ($value instanceof \Phpdftk\Css\Value\ValueList && $value->values !== []) {
9305            $h = $kw($value->values[0]) ?? 'stretch';
9306            $v = $kw($value->values[1] ?? null) ?? $h;
9307            return [$h, $v];
9308        }
9309        $single = $kw($value) ?? 'stretch';
9310        return [$single, $single];
9311    }
9312
9313    private function svgRenderer(): \Phpdftk\SvgToPdf\SvgRenderer
9314    {
9315        if ($this->svgRenderer === null) {
9316            assert($this->page !== null && $this->writer !== null);
9317            $this->svgRenderer = new \Phpdftk\SvgToPdf\SvgRenderer(
9318                $this->page,
9319                $this->writer,
9320                new \Phpdftk\SvgToPdf\Translator(
9321                    documentFontProvider: $this->documentFontBridge(),
9322                ),
9323            );
9324        }
9325        return $this->svgRenderer;
9326    }
9327
9328    /**
9329     * Build the SVG-side font provider from this page's registered
9330     * fonts, or null when the document has no font machinery wired
9331     * (standalone / font-less rendering) - SVG text then keeps the
9332     * translator's standard-14 fallback.
9333     */
9334    private function documentFontBridge(): ?\Phpdftk\HtmlToPdf\Svg\DocumentFontBridge
9335    {
9336        if ($this->fontResolver === null || $this->registeredFonts === []) {
9337            return null;
9338        }
9339        return new \Phpdftk\HtmlToPdf\Svg\DocumentFontBridge(
9340            $this->fontResolver,
9341            $this->registeredFonts,
9342        );
9343    }
9344
9345    private function inlineSvgAdapter(): \Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter
9346    {
9347        if ($this->inlineSvgAdapter === null) {
9348            $this->inlineSvgAdapter = new \Phpdftk\HtmlToPdf\Svg\InlineSvgAdapter();
9349        }
9350        return $this->inlineSvgAdapter;
9351    }
9352
9353    private function inlineMathmlAdapter(): \Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter
9354    {
9355        if ($this->inlineMathmlAdapter === null) {
9356            $this->inlineMathmlAdapter = new \Phpdftk\HtmlToPdf\Mathml\InlineMathmlAdapter();
9357        }
9358        return $this->inlineMathmlAdapter;
9359    }
9360
9361    private function mathmlRenderer(): \Phpdftk\MathmlToPdf\MathmlRenderer
9362    {
9363        if ($this->mathmlRenderer === null) {
9364            assert($this->page !== null && $this->writer !== null);
9365            $this->mathmlRenderer = new \Phpdftk\MathmlToPdf\MathmlRenderer($this->page, $this->writer);
9366        }
9367        return $this->mathmlRenderer;
9368    }
9369
9370    /**
9371     * Resolve the math-font {@see OpenTypeData} for `$box` by
9372     * reading the cascaded `font-family` and looking it up in
9373     * `$fontDataByFamily`. Returns null when no family matches,
9374     * the matching font has no MATH table, or the cascade hasn't
9375     * resolved a usable family name.
9376     *
9377     * Used by paintInlineMath to thread the cascade-loaded font
9378     * (typically via `@font-face`) into the MathmlRenderer so its
9379     * MATH-table constants (FractionRuleThickness, axis height,
9380     * etc.) drive layout. Without this hook MathmlRenderer falls
9381     * back to its tracer-bullet defaults regardless of what
9382     * `font-family` the cascade resolved.
9383     */
9384    private function mathFontDataFor(
9385        Box $box,
9386    ): ?\Phpdftk\FontParser\FontFaceData {
9387        if ($this->fontDataByFamily === []) {
9388            return null;
9389        }
9390        $family = $box->style->get('font-family');
9391        $names = [];
9392        if ($family instanceof \Phpdftk\Css\Value\StringValue) {
9393            $names[] = $family->value;
9394        } elseif ($family instanceof \Phpdftk\Css\Value\Keyword) {
9395            $names[] = $family->name;
9396        } elseif ($family instanceof \Phpdftk\Css\Value\ValueList) {
9397            foreach ($family->values as $entry) {
9398                if ($entry instanceof \Phpdftk\Css\Value\StringValue) {
9399                    $names[] = $entry->value;
9400                } elseif ($entry instanceof \Phpdftk\Css\Value\Keyword) {
9401                    $names[] = $entry->name;
9402                }
9403            }
9404        }
9405        foreach ($names as $name) {
9406            $key = strtolower(trim($name));
9407            $data = $this->fontDataByFamily[$key] ?? null;
9408            if ($data !== null
9409                && $data->mathTable !== null
9410                && $data->mathTable->hasMathConstants()
9411            ) {
9412                return $data;
9413            }
9414        }
9415        return null;
9416    }
9417
9418    /**
9419     * Build a fresh {@see MathmlRenderer} for `$box` with its
9420     * math-font data threaded in. Falls back to the cached default
9421     * renderer when no MATH-table font matches the cascade -
9422     * keeps the common "no math font" path zero-cost.
9423     *
9424     * Wired into the paint path: without it every document laid its
9425     * maths out on the tracer-bullet constants no matter what font the
9426     * author specified, because the cached default renderer carries no
9427     * font at all.
9428     */
9429    private function mathmlRendererFor(
9430        Box $box,
9431    ): \Phpdftk\MathmlToPdf\MathmlRenderer {
9432        $fontData = $this->mathFontDataFor($box);
9433        // The MathML renderer needs CFF outlines for its math-table-
9434        // driven glyph variants. TrueType math fonts exist (`STIXTwoMath`
9435        // ships both), but the math path is wired through the OT-CFF
9436        // subset/embed flow today. Drop back to the cached default
9437        // renderer for non-CFF data â€” the math layout still runs, just
9438        // without the size-variant / assembly-part substitution layer.
9439        if (!$fontData instanceof \Phpdftk\FontParser\OpenTypeData) {
9440            return $this->mathmlRenderer();
9441        }
9442        assert($this->page !== null && $this->writer !== null);
9443        return new \Phpdftk\MathmlToPdf\MathmlRenderer(
9444            $this->page,
9445            $this->writer,
9446            mathFontData: $fontData,
9447        );
9448    }
9449
9450    /**
9451     * Paint an inline `<math>` HTML element by adapting its subtree
9452     * into a typed MathmlDocument and handing the result to the
9453     * MathmlRenderer.
9454     *
9455     * Sizing precedence mirrors the inline-SVG painter (CSS Display
9456     * Â§3.5):
9457     *
9458     *   1. Box geometry from the CSS-cascaded `width` / `height` â€”
9459     *      only populated when InlineLayout actually laid the box
9460     *      out (see #39).
9461     *   2. Cascade values read directly (covers the no-font case
9462     *      where InlineLayout returns early).
9463     *
9464     * MathML doesn't have a viewBox or intrinsic-attribute shortcut
9465     * the way SVG does â€” when nothing in the cascade declares a
9466     * size, math content has an intrinsic size derived from its
9467     * glyph metrics. For the tracer-bullet renderer we default to
9468     * a one-line strip the same height as the renderer's default
9469     * font (12 pt Ã— 1 line â‰ˆ 14 pt) and an arbitrary width band
9470     * (200 pt) so a sized-from-glyphs <math> still produces output.
9471     * Real intrinsic sizing lands once MathmlRenderer learns to
9472     * measure its own glyphs (separate follow-up).
9473     *
9474     * A parse failure here is swallowed and the MathML silently
9475     * skipped â€” one malformed inline expression shouldn't poison
9476     * the whole page render.
9477     */
9478    private function paintInlineMath(
9479        \Phpdftk\Html\Dom\Element $element,
9480        Box $box,
9481        ContentStream $stream,
9482    ): void {
9483        $geo = $box->geometry;
9484        $width = $geo->width;
9485        $height = $geo->height;
9486        // Layer 2: read directly from the cascade when layout left
9487        // geometry zero. Same fix the SVG painter applies for the
9488        // no-font case (#39).
9489        if ($width <= 0.0) {
9490            $cascaded = $box->style->get('width');
9491            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
9492                $width = $cascaded->value;
9493            }
9494        }
9495        if ($height <= 0.0) {
9496            $cascaded = $box->style->get('height');
9497            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
9498                $height = $cascaded->value;
9499            }
9500        }
9501        // Parse the inline MathML before deriving any intrinsic
9502        // size so we can ask MathmlRenderer for its natural
9503        // dimensions when the cascade left both axes zero. Layer
9504        // 3 (intrinsic) sits between the cascade-explicit values
9505        // and the typographic fallback.
9506        try {
9507            $mathDoc = $this->inlineMathmlAdapter()->adapt($element);
9508        } catch (\Throwable) {
9509            return;
9510        }
9511        // Resolve the CSS-cascaded font size for the <math> element
9512        // so em-relative children (mpadded height="3em", mspace
9513        // depth="2em", ...) measure against the right base. WPT
9514        // fixtures expect CSS default of 16px (== 16pt under
9515        // html-to-pdf's 1px = 1pt cascade); the painter falls back
9516        // to MathmlRenderer::DEFAULT_FONT_SIZE when nothing is set.
9517        $fontSize = $this->dominantFontSize($box);
9518        if ($width <= 0.0 || $height <= 0.0) {
9519            [$intrinsicW, $intrinsicH] = $this->mathmlRenderer()
9520                ->intrinsicSize($mathDoc, $fontSize);
9521            if ($width <= 0.0 && $intrinsicW > 0.0) {
9522                $width = $intrinsicW;
9523            }
9524            if ($height <= 0.0 && $intrinsicH > 0.0) {
9525                $height = $intrinsicH;
9526            }
9527        }
9528        // Final fallback: a typographically sensible default sized
9529        // strip. 14 pt tall is one line of 12 pt math + a sliver
9530        // of leading; 200 pt wide is wider than any common single-
9531        // expression token sequence but the renderer just stops
9532        // emitting glyphs when content runs out.
9533        if ($height <= 0.0) {
9534            $height = 14.0;
9535        }
9536        if ($width <= 0.0) {
9537            $width = 200.0;
9538        }
9539        // CSS position: absolute / fixed on inline foreign content
9540        // is not currently honoured by InlineLayout (which always
9541        // places these along the line box). For the WPT MathML
9542        // fixtures that use `<math style="position: absolute;
9543        // top: 0; left: 0;">` to anchor the math to the page edge,
9544        // override geo->x / geo->y with the cascaded left / top
9545        // when present. Treats left / top as absolute page
9546        // coordinates - good enough when the math sits inside a
9547        // top-level positioned div (the common WPT pattern); a
9548        // proper containing-block calculation lives behind a
9549        // bigger layout fix.
9550        [$layoutX, $layoutY] = $this->resolveInlineAbsoluteOrigin(
9551            $box,
9552            $geo->x,
9553            $geo->y,
9554        );
9555        $pdfY = $this->pageHeight - $layoutY - $height;
9556        // Hand the cascade-resolved MATH font to the renderer so the
9557        // font's own MathConstants drive layout. Falls back to the
9558        // cached default renderer when the resolved family has no
9559        // MATH table, which keeps the no-math-font path unchanged.
9560        $renderer = $this->mathmlRendererFor($box);
9561        $ascentPt = $renderer->intrinsicAscent($mathDoc, $fontSize);
9562        $renderer->draw(
9563            $mathDoc,
9564            $layoutX,
9565            $pdfY,
9566            $width,
9567            $height,
9568            stream: $stream,
9569            fontSize: $fontSize,
9570            ascentPt: $ascentPt,
9571        );
9572    }
9573
9574    /**
9575     * Resolve the layout-space origin for an inline foreign
9576     * element when CSS `position` is `absolute` / `fixed`.
9577     * Returns `[x, y]` in layout (top-down) coordinates.
9578     *
9579     * Falls back to the box's layout-derived geometry when the
9580     * position keyword isn't a positioned form. When it IS
9581     * positioned, reads cascaded `left` / `top` Length values and
9582     * treats them as absolute page coordinates - sufficient for
9583     * the common case where the foreign content is inside the
9584     * initial containing block (or close enough that the
9585     * containing-block resolution from BlockLayout has already
9586     * shifted ancestors into position).
9587     *
9588     * @return array{0: float, 1: float}
9589     */
9590    private function resolveInlineAbsoluteOrigin(
9591        Box $box,
9592        float $defaultX,
9593        float $defaultY,
9594    ): array {
9595        $position = $box->style->get('position');
9596        if (!($position instanceof \Phpdftk\Css\Value\Keyword)) {
9597            return [$defaultX, $defaultY];
9598        }
9599        $keyword = strtolower($position->name);
9600        if ($keyword !== 'absolute' && $keyword !== 'fixed') {
9601            return [$defaultX, $defaultY];
9602        }
9603        $left = $box->style->get('left');
9604        $top = $box->style->get('top');
9605        $x = $left instanceof \Phpdftk\Css\Value\Length
9606            ? $left->value
9607            : $defaultX;
9608        $y = $top instanceof \Phpdftk\Css\Value\Length
9609            ? $top->value
9610            : $defaultY;
9611        return [$x, $y];
9612    }
9613
9614    /**
9615     * Paint an inline `<svg>` HTML element by adapting its subtree
9616     * into a typed SvgDocument and handing the result to the existing
9617     * SvgRenderer.
9618     *
9619     * Dimensions: prefer the box's resolved geometry (CSS-cascaded
9620     * `width` / `height` win over intrinsic). If geometry is zero
9621     * because the cascade left dimensions unresolved, fall back to
9622     * the `<svg width="…" height="…">` attributes (treated as CSS
9623     * pixels) â€” same precedence the inline-SVG sizing algorithm uses
9624     * in CSS Display Â§3.5.
9625     *
9626     * A parse failure here is swallowed and the SVG silently skipped
9627     * â€” one malformed inline-SVG shouldn't poison the whole page
9628     * render. The pdf consumer sees an empty box where the SVG would
9629     * have been; the rest of the document is unaffected.
9630     */
9631    /**
9632     * Paint an `<img src="*.svg">` (or `data:image/svg+xml`)
9633     * replaced-element by loading the external SVG and rendering it
9634     * into the box's CSS-laid-out geometry.
9635     *
9636     * Unlike `paintInlineSvg`, the SVG document here is an external
9637     * resource: the box's geometry is already resolved by the layout
9638     * engine from the cascade plus the intrinsic dimensions reported
9639     * by `SvgParser`. We just clip to the box rect, apply
9640     * `object-fit` / `object-position`, and delegate to the SvgRenderer.
9641     */
9642    private function paintImgSvg(
9643        \Phpdftk\Html\Dom\Element $element,
9644        Box $box,
9645        ContentStream $stream,
9646        string $src,
9647    ): void {
9648        if ($this->writer === null || $this->page === null) {
9649            return;
9650        }
9651        $geo = $box->geometry;
9652        if ($geo->width <= 0.0) {
9653            return;
9654        }
9655        $svgDoc = $this->loadSvgDocument($src);
9656        if ($svgDoc === null) {
9657            return;
9658        }
9659        $height = $geo->height > 0.0 ? $geo->height : $geo->width;
9660        // CSS Images 3 Â§5.3 â€” `object-fit` decides the painted SVG's
9661        // scale within the box; `object-position` decides where the
9662        // slack sits. Defaults: `fill` + centre.
9663        $fit = $this->objectFitKeyword($box);
9664        $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height);
9665        $positionValue = $box->style->get('object-position');
9666        if ($positionValue !== null
9667            && ($rect['w'] !== $geo->width || $rect['h'] !== $height)
9668        ) {
9669            $pos = $this->resolveBackgroundPosition(
9670                $positionValue,
9671                $rect['w'],
9672                $rect['h'],
9673                $geo->width,
9674                $height,
9675            );
9676            $rect['offsetX'] = $pos['offsetX'];
9677            $rect['offsetY'] = $pos['offsetY'];
9678        }
9679        $pdfY = $this->pageHeight - $geo->y - $height;
9680        $stream->saveGraphicsState();
9681        // Clip to the box rect so `cover` overflow doesn't bleed into
9682        // sibling boxes â€” same posture as `paintImage` for raster.
9683        $stream->rectangle($geo->x, $pdfY, $geo->width, $height);
9684        $stream->clip();
9685        $stream->endPath();
9686        try {
9687            $this->svgRenderer()->draw(
9688                $svgDoc,
9689                $geo->x + $rect['offsetX'],
9690                $pdfY + ($height - $rect['h'] - $rect['offsetY']),
9691                $rect['w'],
9692                $rect['h'],
9693                stream: $stream,
9694            );
9695        } catch (\Throwable) {
9696            // Swallow paint failures so one malformed SVG doesn't kill
9697            // the document render. Mirrors the raster path's catch.
9698        }
9699        $stream->restoreGraphicsState();
9700    }
9701
9702    private function paintInlineSvg(
9703        \Phpdftk\Html\Dom\Element $element,
9704        Box $box,
9705        ContentStream $stream,
9706    ): void {
9707        $geo = $box->geometry;
9708        $width = $geo->width;
9709        $height = $geo->height;
9710        // Sizing precedence for the inline SVG:
9711        //   1. Box geometry from CSS-cascaded width/height â€” only
9712        //      populated when InlineLayout actually laid the box out.
9713        //   2. Cascade values read directly (covers the no-font case
9714        //      where InlineLayout returns early before tokenisation,
9715        //      but the cascade still has Length values from author CSS).
9716        //   3. The svg element's own `width` / `height` attributes,
9717        //      parsed as CSS pixels.
9718        // Without precedence #2 a document with `#s { width: 50pt }`
9719        // and no embedded font would render the SVG at zero size.
9720        // Precedence #3 catches the bare `<svg width="80" height="60">`
9721        // case where neither CSS nor the cascade has a Length.
9722        if ($width <= 0.0) {
9723            $cascaded = $box->style->get('width');
9724            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
9725                $width = $cascaded->value;
9726            }
9727        }
9728        if ($height <= 0.0) {
9729            $cascaded = $box->style->get('height');
9730            if ($cascaded instanceof \Phpdftk\Css\Value\Length && $cascaded->value > 0.0) {
9731                $height = $cascaded->value;
9732            }
9733        }
9734        if ($width <= 0.0) {
9735            $attr = $element->getAttribute('width');
9736            if ($attr !== null) {
9737                $width = $this->parseSvgLength($attr);
9738            }
9739        }
9740        if ($height <= 0.0) {
9741            $attr = $element->getAttribute('height');
9742            if ($attr !== null) {
9743                $height = $this->parseSvgLength($attr);
9744            }
9745        }
9746        // Final fallback: intrinsic dimensions from the viewBox's
9747        // width/height columns. SVG 2 Â§8.2 â€” when neither CSS nor a
9748        // width/height attr declares a size, the viewBox supplies the
9749        // intrinsic aspect ratio AND, in browsers, an intrinsic
9750        // pixel size for replaced-element layout (third + fourth
9751        // viewBox values treated as CSS pixels). The viewBox parser
9752        // lives in `ViewportElement::viewBox()` but we duplicate the
9753        // tiny extraction here to avoid forcing the SVG parser to
9754        // run on a zero-size SVG we'd otherwise have dropped.
9755        if ($width <= 0.0 || $height <= 0.0) {
9756            $vb = $this->parseViewBox($element->getAttribute('viewBox'));
9757            if ($vb !== null) {
9758                if ($width <= 0.0) {
9759                    $width = $vb[0] * 0.75;
9760                }
9761                if ($height <= 0.0) {
9762                    $height = $vb[1] * 0.75;
9763                }
9764            }
9765        }
9766        if ($width <= 0.0 || $height <= 0.0) {
9767            return;
9768        }
9769        try {
9770            $svgDoc = $this->inlineSvgAdapter()->adapt($element);
9771        } catch (\Throwable) {
9772            return;
9773        }
9774        // PDF y-axis runs bottom-up; the box geometry's `y` is the
9775        // top edge in CSS coords, so we flip relative to pageHeight.
9776        $pdfY = $this->pageHeight - $geo->y - $height;
9777        // SVG 2 Â§8.2 / CSS Overflow 3 Â§3.3 â€” the SVG viewport clips its
9778        // content by default (UA `svg { overflow: clip }`). SvgRenderer's
9779        // own draw() only clips for slice/preserveAspectRatio, so the
9780        // caller must confine content larger than the viewport (a 150×150
9781        // rect in a 100×100 svg). Honour per-axis overflow, contain:paint,
9782        // and overflow-clip-margin via the shared overflow-clip machinery.
9783        $needsClip = $this->shouldOverflowClip($box);
9784        if ($needsClip) {
9785            $stream->saveGraphicsState();
9786            $this->emitOverflowClipPath($stream, $box);
9787        }
9788        $this->svgRenderer()->draw(
9789            $svgDoc,
9790            $geo->x,
9791            $pdfY,
9792            $width,
9793            $height,
9794            stream: $stream,
9795        );
9796        if ($needsClip) {
9797            $stream->restoreGraphicsState();
9798        }
9799    }
9800
9801    /**
9802     * Pull the width/height columns out of an SVG `viewBox` attribute
9803     * for the intrinsic-sizing fallback. Returns `[width, height]` in
9804     * CSS pixels (viewBox values are unitless user-space coordinates,
9805     * which in the absence of any other sizing input are interpreted
9806     * as CSS pixels per SVG 2 Â§8.2). Returns null on parse failure.
9807     *
9808     * @return array{0: float, 1: float}|null
9809     */
9810    private function parseViewBox(?string $raw): ?array
9811    {
9812        if ($raw === null) {
9813            return null;
9814        }
9815        $parts = preg_split('/[\s,]+/', trim($raw)) ?: [];
9816        if (count($parts) !== 4) {
9817            return null;
9818        }
9819        foreach ($parts as $p) {
9820            if (!is_numeric($p)) {
9821                return null;
9822            }
9823        }
9824        $w = (float) $parts[2];
9825        $h = (float) $parts[3];
9826        if ($w <= 0.0 || $h <= 0.0) {
9827            return null;
9828        }
9829        return [$w, $h];
9830    }
9831
9832    /**
9833     * Tiny SVG-length parser used by the inline-SVG fallback path.
9834     * Strips an optional `px` / `pt` suffix and clamps to a non-
9835     * negative float. We accept only `px` and `pt` for now â€” any
9836     * other unit (cm, em, %) returns 0 and lets the dimension lookup
9837     * fall through to "skip".
9838     */
9839    private function parseSvgLength(string $value): float
9840    {
9841        $trimmed = trim($value);
9842        if ($trimmed === '') {
9843            return 0.0;
9844        }
9845        if (preg_match('/^([\d.]+)\s*(px|pt)?$/i', $trimmed, $m) !== 1) {
9846            return 0.0;
9847        }
9848        $n = (float) $m[1];
9849        if ($n <= 0.0) {
9850            return 0.0;
9851        }
9852        // `pt` arrives as PDF points already; `px` and the bare form
9853        // are CSS pixels at 96 dpi, which is 0.75 pt per px.
9854        $unit = isset($m[2]) ? strtolower($m[2]) : 'px';
9855        return $unit === 'pt' ? $n : $n * 0.75;
9856    }
9857
9858    private function paintBorders(Box $box, ContentStream $stream): void
9859    {
9860        // CSS Backgrounds 3 Â§6 â€” when `border-image-source` is set and
9861        // successfully loaded, it REPLACES the per-side border paint.
9862        // We delegate to the 9-slice painter and skip the legacy
9863        // border-colour/style rendering for this box.
9864        if ($this->paintBorderImage($box, $stream)) {
9865            return;
9866        }
9867        $geo = $box->geometry;
9868        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft;
9869        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop;
9870        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
9871            + $geo->borderLeft + $geo->borderRight;
9872        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
9873            + $geo->borderTop + $geo->borderBottom;
9874
9875        // Rounded-uniform-border fast path: all four sides share width +
9876        // colour + style, and any radius is set â†’ emit one stroked
9877        // rounded path. Mixed-width/colour borders or no radius fall back
9878        // to the per-side rectangle path (still straight corners).
9879        $radii = $this->borderRadii($box);
9880        if (array_sum($radii) > 0.0 && $this->bordersAreUniform($box)) {
9881            $width = $geo->borderTop;
9882            if ($width > 0.0) {
9883                $this->emitRoundedStroke(
9884                    $stream,
9885                    $outerX + $width / 2,
9886                    $outerY + $width / 2,
9887                    $outerWidth - $width,
9888                    $outerHeight - $width,
9889                    $radii,
9890                    $this->borderColor($box, 'top'),
9891                    $width,
9892                );
9893                return;
9894            }
9895        }
9896
9897        if ($geo->borderTop > 0.0 && $this->borderIsVisible($box, 'top')) {
9898            $this->paintBorderSide(
9899                $stream,
9900                $this->borderStyleName($box, 'top'),
9901                $this->borderColor($box, 'top'),
9902                $outerX,
9903                $outerY,
9904                $outerWidth,
9905                $geo->borderTop,
9906                side: 'top',
9907            );
9908        }
9909        if ($geo->borderBottom > 0.0 && $this->borderIsVisible($box, 'bottom')) {
9910            $this->paintBorderSide(
9911                $stream,
9912                $this->borderStyleName($box, 'bottom'),
9913                $this->borderColor($box, 'bottom'),
9914                $outerX,
9915                $outerY + $outerHeight - $geo->borderBottom,
9916                $outerWidth,
9917                $geo->borderBottom,
9918                side: 'bottom',
9919            );
9920        }
9921        if ($geo->borderLeft > 0.0 && $this->borderIsVisible($box, 'left')) {
9922            $this->paintBorderSide(
9923                $stream,
9924                $this->borderStyleName($box, 'left'),
9925                $this->borderColor($box, 'left'),
9926                $outerX,
9927                $outerY,
9928                $geo->borderLeft,
9929                $outerHeight,
9930                side: 'left',
9931            );
9932        }
9933        if ($geo->borderRight > 0.0 && $this->borderIsVisible($box, 'right')) {
9934            $this->paintBorderSide(
9935                $stream,
9936                $this->borderStyleName($box, 'right'),
9937                $this->borderColor($box, 'right'),
9938                $outerX + $outerWidth - $geo->borderRight,
9939                $outerY,
9940                $geo->borderRight,
9941                $outerHeight,
9942                side: 'right',
9943            );
9944        }
9945    }
9946
9947    /**
9948     * Paint one border side honouring `border-style`. `axis` flags
9949     * whether the rect runs horizontally (top / bottom) or vertically
9950     * (left / right) so the `double` decomposition knows which
9951     * dimension to split into thirds.
9952     *
9953     *  - `solid`: one filled rect (the original behaviour).
9954     *  - `double` (CSS Backgrounds 3 Â§5): two parallel bands each
9955     *    `thickness/3` thick with a `thickness/3` gap. When the
9956     *    thickness is too small to split (< 3 units), falls back to
9957     *    solid so the border doesn't disappear into a hairline.
9958     *  - `dashed` / `dotted`: stroke a line at the centerline of the
9959     *    side with a PDF dash pattern. Dashed uses 3w-on / 2w-off;
9960     *    dotted uses 1w-on / 1w-off (PDF rounds dotted patterns to
9961     *    square caps).
9962     *  - Other style keywords (`groove`, `ridge`, `inset`, `outset`):
9963     *    Phase-1 fallback to solid.
9964     */
9965    private function paintBorderSide(
9966        ContentStream $stream,
9967        string $styleName,
9968        Color $color,
9969        float $x,
9970        float $y,
9971        float $width,
9972        float $height,
9973        string $side,
9974    ): void {
9975        $axis = ($side === 'top' || $side === 'bottom') ? 'horizontal' : 'vertical';
9976        // CSS Backgrounds 3 Â§5.2 â€” 3D-effect styles. `inset` darkens
9977        // top + left; `outset` lightens them; `groove` and `ridge`
9978        // split per side as if etched / raised.
9979        if (in_array($styleName, ['inset', 'outset', 'groove', 'ridge'], true)) {
9980            $color = $this->resolve3dBorderColor($styleName, $color, $side);
9981        }
9982        if ($styleName === 'dashed' || $styleName === 'dotted') {
9983            $thickness = $axis === 'horizontal' ? $height : $width;
9984            $this->paintDashedDottedSide(
9985                $stream,
9986                $styleName,
9987                $color,
9988                $x,
9989                $y,
9990                $width,
9991                $height,
9992                $axis,
9993                $thickness,
9994            );
9995            return;
9996        }
9997        if ($styleName === 'double') {
9998            $thickness = $axis === 'horizontal' ? $height : $width;
9999            if ($thickness >= 3.0) {
10000                $third = $thickness / 3.0;
10001                if ($axis === 'horizontal') {
10002                    // Two horizontal bands stacked vertically.
10003                    $this->emitRect($stream, $x, $y, $width, $third, fill: $color);
10004                    $this->emitRect($stream, $x, $y + 2 * $third, $width, $third, fill: $color);
10005                } else {
10006                    // Two vertical bands stacked horizontally.
10007                    $this->emitRect($stream, $x, $y, $third, $height, fill: $color);
10008                    $this->emitRect($stream, $x + 2 * $third, $y, $third, $height, fill: $color);
10009                }
10010                return;
10011            }
10012        }
10013        $this->emitRect($stream, $x, $y, $width, $height, fill: $color);
10014    }
10015
10016    /**
10017     * Stroke one side of a border as a dashed / dotted line at the
10018     * centerline of the side, at line-width = thickness. CSS
10019     * Backgrounds 3 Â§5 says the dash / dot geometry is
10020     * implementation-defined; we follow the Chromium + WebKit
10021     * convention:
10022     *   - dashed: dash-length = 2w, gap = w (period 3w, butt cap).
10023     *   - dotted: round-capped point-dashes spaced 2w apart, so each
10024     *     "dot" renders as a circle of diameter = thickness. The
10025     *     stroke is inset by half the thickness on each end so the
10026     *     leading dot sits at the centre-line crossing of the two
10027     *     meeting borders (matching the corner geometry browsers
10028     *     emit).
10029     * The period for both styles is rounded to fit the edge in whole
10030     * cycles so dashes / dots stay symmetric across the corner; this
10031     * is what closes the residual ~14% pixel-AE vs Chromium on the
10032     * dashed / dotted fixtures (issue #27).
10033     */
10034    private function paintDashedDottedSide(
10035        ContentStream $stream,
10036        string $styleName,
10037        Color $color,
10038        float $x,
10039        float $y,
10040        float $width,
10041        float $height,
10042        string $axis,
10043        float $thickness,
10044    ): void {
10045        if ($thickness <= 0.0) {
10046            return;
10047        }
10048        $edgeLength = $axis === 'horizontal' ? $width : $height;
10049        if ($edgeLength <= 0.0) {
10050            return;
10051        }
10052        $stream->saveGraphicsState();
10053        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
10054        $stream->setLineWidth($thickness);
10055
10056        if ($styleName === 'dotted') {
10057            // Round-capped point-dash â†’ each "on" of length 0 paints a
10058            // full-thickness circle. Inset by thickness/2 so the first
10059            // and last dot land on the corner of the two borders'
10060            // centre-lines.
10061            $insetEdge = max($edgeLength - $thickness, 0.0);
10062            $targetPeriod = $thickness * 2.0;
10063            $count = max(1, (int) round($insetEdge / $targetPeriod));
10064            $period = $count > 0 ? $insetEdge / $count : $targetPeriod;
10065            $stream->setLineCap(1);
10066            $stream->setDashPattern([0.0, $period], 0);
10067            [$startX, $startY, $endX, $endY] = $this->dashedSideEndpoints(
10068                $axis,
10069                $x,
10070                $y,
10071                $width,
10072                $height,
10073                $thickness / 2.0,
10074            );
10075        } else {
10076            // Dashed â€” dash:gap = 2:1, period rounded to whole cycles.
10077            $targetPeriod = $thickness * 3.0;
10078            $count = max(1, (int) round($edgeLength / $targetPeriod));
10079            $period = $edgeLength / $count;
10080            $dash = $period * 2.0 / 3.0;
10081            $gap = $period - $dash;
10082            $stream->setDashPattern([$dash, $gap], 0);
10083            [$startX, $startY, $endX, $endY] = $this->dashedSideEndpoints(
10084                $axis,
10085                $x,
10086                $y,
10087                $width,
10088                $height,
10089                0.0,
10090            );
10091        }
10092        $stream->moveTo($startX, $startY);
10093        $stream->lineTo($endX, $endY);
10094        $stream->stroke();
10095        $stream->restoreGraphicsState();
10096    }
10097
10098    /**
10099     * Compute the PDF-space stroke endpoints for one dashed / dotted
10100     * border side, optionally inset by `$inset` units along the axis
10101     * (so the leading dot of a dotted edge sits at the corner of the
10102     * two borders' centre-lines rather than poking past it).
10103     *
10104     * @return array{float, float, float, float}
10105     */
10106    private function dashedSideEndpoints(
10107        string $axis,
10108        float $x,
10109        float $y,
10110        float $width,
10111        float $height,
10112        float $inset,
10113    ): array {
10114        if ($axis === 'horizontal') {
10115            $midPdfY = $this->pageHeight - ($y + $height / 2.0);
10116            return [$x + $inset, $midPdfY, $x + $width - $inset, $midPdfY];
10117        }
10118        $midX = $x + $width / 2.0;
10119        $topPdfY = $this->pageHeight - $y - $inset;
10120        $bottomPdfY = $this->pageHeight - ($y + $height) + $inset;
10121        return [$midX, $topPdfY, $midX, $bottomPdfY];
10122    }
10123
10124    /**
10125     * Resolve the per-side colour for CSS Backgrounds 3 Â§5.2 3D-style
10126     * borders. The light source is conventionally top-left:
10127     *
10128     *  - `inset`  â†’ top + left use a darker variant (carved-in look).
10129     *  - `outset` â†’ bottom + right use a darker variant (raised look).
10130     *  - `groove` â†’ top + left darker, bottom + right lighter (etched in).
10131     *  - `ridge`  â†’ top + left lighter, bottom + right darker (raised ridge).
10132     *
10133     * "Darker" multiplies each RGB channel by 0.5; "lighter" lightens
10134     * toward white by 30%. These match common browser approximations.
10135     */
10136    private function resolve3dBorderColor(string $styleName, Color $base, string $side): Color
10137    {
10138        $isTopLeft = $side === 'top' || $side === 'left';
10139        $darken = static function (Color $c): Color {
10140            return new Color($c->r * 0.5, $c->g * 0.5, $c->b * 0.5, $c->a, $c->space);
10141        };
10142        $lighten = static function (Color $c): Color {
10143            return new Color(
10144                $c->r + (1.0 - $c->r) * 0.3,
10145                $c->g + (1.0 - $c->g) * 0.3,
10146                $c->b + (1.0 - $c->b) * 0.3,
10147                $c->a,
10148                $c->space,
10149            );
10150        };
10151        return match ($styleName) {
10152            'inset' => $isTopLeft ? $darken($base) : $base,
10153            'outset' => $isTopLeft ? $base : $darken($base),
10154            'groove' => $isTopLeft ? $darken($base) : $lighten($base),
10155            'ridge' => $isTopLeft ? $lighten($base) : $darken($base),
10156            default => $base,
10157        };
10158    }
10159
10160    private function borderStyleName(Box $box, string $side): string
10161    {
10162        $value = $box->style->get("border-$side-style");
10163        if (!$value instanceof Keyword) {
10164            return 'none';
10165        }
10166        return strtolower($value->name);
10167    }
10168
10169    /**
10170     * Uniform borders: same width / colour / visible-style on all 4 sides.
10171     * Enables the rounded-stroke fast path; mixed borders fall back to
10172     * straight per-side rectangles.
10173     */
10174    private function bordersAreUniform(Box $box): bool
10175    {
10176        $g = $box->geometry;
10177        if (abs($g->borderTop - $g->borderRight) > 0.001
10178            || abs($g->borderTop - $g->borderBottom) > 0.001
10179            || abs($g->borderTop - $g->borderLeft) > 0.001
10180        ) {
10181            return false;
10182        }
10183        $colorTop = $this->borderColor($box, 'top');
10184        foreach (['right', 'bottom', 'left'] as $side) {
10185            if (!$this->borderIsVisible($box, $side)) {
10186                return false;
10187            }
10188            $c = $this->borderColor($box, $side);
10189            if ($c->r !== $colorTop->r || $c->g !== $colorTop->g || $c->b !== $colorTop->b) {
10190                return false;
10191            }
10192        }
10193        return $this->borderIsVisible($box, 'top');
10194    }
10195
10196    /**
10197     * Stroke a rounded-rectangle path. `x,topY,width,height` describe the
10198     * path's centreline (so the stroke straddles both inside and outside);
10199     * radii are clamped per spec. Used for uniform-border rendering when
10200     * border-radius is set.
10201     *
10202     * @param array{float, float, float, float} $radii
10203     */
10204    private function emitRoundedStroke(
10205        ContentStream $stream,
10206        float $x,
10207        float $topY,
10208        float $width,
10209        float $height,
10210        array $radii,
10211        Color $color,
10212        float $lineWidth,
10213    ): void {
10214        $maxR = min($width, $height) / 2.0;
10215        [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => max(0.0, min($r, $maxR)), $radii);
10216        $k = 0.5522847498;
10217        $bottomPdfY = $this->pageHeight - $topY - $height;
10218        $topPdfY = $this->pageHeight - $topY;
10219        $stream->saveGraphicsState();
10220        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
10221        $stream->setLineWidth($lineWidth);
10222        $stream->moveTo($x + $rtl, $topPdfY);
10223        $stream->lineTo($x + $width - $rtr, $topPdfY);
10224        if ($rtr > 0.0) {
10225            $stream->curveTo(
10226                $x + $width - $rtr + $rtr * $k,
10227                $topPdfY,
10228                $x + $width,
10229                $topPdfY - $rtr + $rtr * $k,
10230                $x + $width,
10231                $topPdfY - $rtr,
10232            );
10233        }
10234        $stream->lineTo($x + $width, $bottomPdfY + $rbr);
10235        if ($rbr > 0.0) {
10236            $stream->curveTo(
10237                $x + $width,
10238                $bottomPdfY + $rbr - $rbr * $k,
10239                $x + $width - $rbr + $rbr * $k,
10240                $bottomPdfY,
10241                $x + $width - $rbr,
10242                $bottomPdfY,
10243            );
10244        }
10245        $stream->lineTo($x + $rbl, $bottomPdfY);
10246        if ($rbl > 0.0) {
10247            $stream->curveTo(
10248                $x + $rbl - $rbl * $k,
10249                $bottomPdfY,
10250                $x,
10251                $bottomPdfY + $rbl - $rbl * $k,
10252                $x,
10253                $bottomPdfY + $rbl,
10254            );
10255        }
10256        $stream->lineTo($x, $topPdfY - $rtl);
10257        if ($rtl > 0.0) {
10258            $stream->curveTo(
10259                $x,
10260                $topPdfY - $rtl + $rtl * $k,
10261                $x + $rtl - $rtl * $k,
10262                $topPdfY,
10263                $x + $rtl,
10264                $topPdfY,
10265            );
10266        }
10267        $stream->closePath();
10268        $stream->stroke();
10269        $stream->restoreGraphicsState();
10270    }
10271
10272    /**
10273     * Paint CSS UI 3 Â§4 `outline`. Outlines don't take part in layout â€”
10274     * they're drawn just outside the border-box at `outline-offset`. We
10275     * only paint the visible outline-style values (everything except
10276     * `none` / `hidden`); `outline-width` and `outline-color` follow the
10277     * cascade.
10278     */
10279    private function paintOutline(Box $box, ContentStream $stream): void
10280    {
10281        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
10282            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
10283            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
10284        ) {
10285            return;
10286        }
10287        $style = $box->style->get('outline-style');
10288        if (!$style instanceof Keyword) {
10289            return;
10290        }
10291        $styleName = strtolower($style->name);
10292        if ($styleName === 'none' || $styleName === 'hidden') {
10293            return;
10294        }
10295        $widthValue = $box->style->get('outline-width');
10296        $width = match (true) {
10297            $widthValue instanceof \Phpdftk\Css\Value\Length => max(0.0, $widthValue->value),
10298            // CSS Backgrounds 3 Â§4.4 keyword resolution.
10299            $widthValue instanceof \Phpdftk\Css\Value\Keyword => match (strtolower($widthValue->name)) {
10300                'thin' => 1.0,
10301                'medium' => 3.0,
10302                'thick' => 5.0,
10303                default => 0.0,
10304            },
10305            default => 0.0,
10306        };
10307        if ($width <= 0.0) {
10308            return;
10309        }
10310        $offsetValue = $box->style->get('outline-offset');
10311        $offset = $offsetValue instanceof \Phpdftk\Css\Value\Length ? $offsetValue->value : 0.0;
10312        $colorValue = $box->style->get('outline-color');
10313        $color = $colorValue instanceof Color ? $colorValue : ($box->style->get('color') instanceof Color
10314            ? $box->style->get('color')
10315            : new Color(0, 0, 0, 1));
10316
10317        $geo = $box->geometry;
10318        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft - $offset - $width / 2;
10319        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop - $offset - $width / 2;
10320        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
10321            + $geo->borderLeft + $geo->borderRight + 2 * $offset + $width;
10322        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
10323            + $geo->borderTop + $geo->borderBottom + 2 * $offset + $width;
10324        $pdfY = $this->pageHeight - $outerY - $outerHeight;
10325        $stream->saveGraphicsState();
10326        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
10327        $stream->setLineWidth($width);
10328        // CSS Outline 3 Â§5 styles. `dashed` / `dotted` map onto PDF
10329        // line-dash patterns; `double` paints two concentric strokes
10330        // each `width/3` thick separated by a `width/3` gap; the rest
10331        // (`groove` / `ridge` / `inset` / `outset`) fall back to solid.
10332        if ($styleName === 'double' && $width >= 3.0) {
10333            $third = $width / 3.0;
10334            $stream->setLineWidth($third);
10335            // `$outerX`/`$outerWidth` describe the path for a CENTRED stroke
10336            // of the FULL width, so that rect sits half the outline width in
10337            // from the outline's outer edge â€” it is not the outer edge itself.
10338            // Each ring is `$third` thick, so its path is `($width - $third)/2`
10339            // out from (outer ring) or in from (inner ring) that centre rect.
10340            $ring = ($width - $third) / 2.0;
10341            // Outer ring: outer face flush with the outline's outer edge.
10342            $stream->rectangle(
10343                $outerX - $ring,
10344                $pdfY - $ring,
10345                $outerWidth + 2.0 * $ring,
10346                $outerHeight + 2.0 * $ring,
10347            );
10348            $stream->stroke();
10349            // Inner ring: inner face flush with the border box (+ offset).
10350            $stream->rectangle(
10351                $outerX + $ring,
10352                $pdfY + $ring,
10353                $outerWidth - 2.0 * $ring,
10354                $outerHeight - 2.0 * $ring,
10355            );
10356            $stream->stroke();
10357            $stream->restoreGraphicsState();
10358            return;
10359        }
10360        if ($styleName === 'dashed' || $styleName === 'dotted') {
10361            $stream->setDashPattern(
10362                $styleName === 'dashed' ? [$width * 3, $width * 2] : [$width, $width * 1.5],
10363                0,
10364            );
10365            if ($color->a < 0.999 && $this->page !== null) {
10366                $stream->setGraphicsState($this->page->ensureOpacityState($color->a, $color->a));
10367            }
10368            $stream->rectangle($outerX, $pdfY, $outerWidth, $outerHeight);
10369            $stream->stroke();
10370            $stream->restoreGraphicsState();
10371            return;
10372        }
10373        // CSS UI 3 Â§4 â€” a `solid` outline (and the groove/ridge/inset/outset
10374        // fallbacks) is a SOLID FILLED BAND from the box's outer outline edge
10375        // to its inner edge (even-odd fill), NOT a centred stroke: this fills
10376        // correctly for large outline widths and honours negative
10377        // outline-offset (which a centred stroke inverts/leaks through).
10378        $bbX = $geo->x - $geo->paddingLeft - $geo->borderLeft;
10379        $bbY = $geo->y - $geo->paddingTop - $geo->borderTop;
10380        $bbW = $geo->paddingLeft + $geo->width + $geo->paddingRight
10381            + $geo->borderLeft + $geo->borderRight;
10382        $bbH = $geo->paddingTop + $geo->height + $geo->paddingBottom
10383            + $geo->borderTop + $geo->borderBottom;
10384        // A negative outline-offset shrinks the inner edge; clamp it to a
10385        // non-negative size so the outer shape never drops below 2×width
10386        // (CSS UI 3 â€” outline-013). The band is always `width` thick per side,
10387        // so the outer size is the clamped inner size + 2×width; centre both
10388        // on the box so the non-clamped case still lands on the real edges.
10389        $innW = max(0.0, $bbW + 2.0 * $offset);
10390        $innH = max(0.0, $bbH + 2.0 * $offset);
10391        $outW = $innW + 2.0 * $width;
10392        $outH = $innH + 2.0 * $width;
10393        $cx = $bbX + $bbW / 2.0;
10394        $cy = $bbY + $bbH / 2.0;
10395        $outLeft = $cx - $outW / 2.0;
10396        $outTop = $cy - $outH / 2.0;
10397        $innLeft = $cx - $innW / 2.0;
10398        $innTop = $cy - $innH / 2.0;
10399        if ($color->a < 0.999 && $this->page !== null) {
10400            $stream->setGraphicsState($this->page->ensureOpacityState($color->a, $color->a));
10401        }
10402        $stream->setFillColorRGB($color->r, $color->g, $color->b);
10403        $stream->rectangle($outLeft, $this->pageHeight - $outTop - $outH, $outW, $outH);
10404        $stream->rectangle($innLeft, $this->pageHeight - $innTop - $innH, $innW, $innH);
10405        $stream->fillEvenOdd();
10406        $stream->restoreGraphicsState();
10407    }
10408
10409    /**
10410     * Stroke `column-rule` between adjacent columns inside a multi-column
10411     * container (CSS Multi-column 1 Â§3). Each rule is centred in its
10412     * column-gap, spans the container's content-area height, and honours
10413     * `column-rule-style` for `solid` / `dashed` / `dotted`. No-op when
10414     * the box isn't a multi-column container, the rule has zero width, or
10415     * the style is `none` / `hidden`.
10416     */
10417    private function paintColumnRules(Box $box, ContentStream $stream): void
10418    {
10419        $mc = $box->multiColumn;
10420        if ($mc === null || $mc->columnCount < 2) {
10421            return;
10422        }
10423        if ($mc->ruleWidth <= 0.0 || $mc->ruleColor === null) {
10424            return;
10425        }
10426        $styleName = $mc->ruleStyle;
10427        if ($styleName === 'none' || $styleName === 'hidden') {
10428            return;
10429        }
10430        $geo = $box->geometry;
10431        $top = $geo->y;
10432        $height = $geo->height;
10433        if ($height <= 0.0) {
10434            return;
10435        }
10436        $pdfTop = $this->pageHeight - $top;
10437        $pdfBottom = $this->pageHeight - ($top + $height);
10438        $stream->saveGraphicsState();
10439        $stream->setStrokeColorRGB($mc->ruleColor->r, $mc->ruleColor->g, $mc->ruleColor->b);
10440        $stream->setLineWidth($mc->ruleWidth);
10441        switch ($styleName) {
10442            case 'dashed':
10443                $stream->setDashPattern([$mc->ruleWidth * 3, $mc->ruleWidth * 2], 0);
10444                break;
10445            case 'dotted':
10446                $stream->setDashPattern([$mc->ruleWidth, $mc->ruleWidth * 1.5], 0);
10447                break;
10448                // Other styles (double / groove / ridge / inset / outset)
10449                // fall back to solid for Phase 1, mirroring the outline
10450                // painter's approximation.
10451        }
10452        for ($i = 0; $i < $mc->columnCount - 1; $i++) {
10453            // Centre line of the gap between column $i and $i+1.
10454            $gapCentreX = $geo->x
10455                + ($i + 1) * $mc->columnWidth
10456                + $i * $mc->columnGap
10457                + $mc->columnGap / 2.0;
10458            $stream->moveTo($gapCentreX, $pdfBottom);
10459            $stream->lineTo($gapCentreX, $pdfTop);
10460            $stream->stroke();
10461        }
10462        $stream->restoreGraphicsState();
10463    }
10464
10465    /**
10466     * CSS Multi-column 1 Â§3.3 â€” paint a `column-fill: auto` fragmented
10467     * container. Its content was laid out in one tall column at the
10468     * container's left edge (layout y from the container origin plus
10469     * `contentOffset` down); render it
10470     * once per column, clipping to column `i`'s `columnHeight` band and
10471     * translating band `i` up into that column. Bands past the content
10472     * height simply clip to empty.
10473     */
10474    private function paintFragmentedColumns(Box $box, ContentStream $stream, MultiColumnLayout $mc): void
10475    {
10476        if ($mc->runs !== []) {
10477            $this->paintColumnRuns($box, $stream, $mc);
10478            return;
10479        }
10480        $colW = $mc->columnWidth;
10481        $gap = $mc->columnGap;
10482        $height = $mc->columnHeight;
10483        if ($height <= 0.0 || $colW <= 0.0) {
10484            // Degenerate â€” fall back to a single unsliced pass.
10485            foreach ($this->paintOrderChildren($box) as $child) {
10486                $this->paintBox($child, $stream, $box);
10487            }
10488            return;
10489        }
10490        $baseX = $box->geometry->x;
10491        // Relative origin â€” see `paintSlicedColumnRun`: the container can
10492        // still be moved after layout recorded the slice.
10493        $contentTop = $box->geometry->y + $mc->contentOffset;
10494        $children = $this->paintOrderChildren($box);
10495        if ($mc->columnWrap) {
10496            // CSS Multi-column 2 Â§3 â€” `column-wrap: wrap`: slice the tall
10497            // content into bands of `columnHeight` and place band `i` at grid
10498            // cell (col = i mod columnCount, row = i div columnCount), filling
10499            // a row of columns left→right then wrapping to the next row.
10500            $rowGap = $mc->rowGap;
10501            $bands = (int) ceil($mc->contentHeight / $height - 1e-6);
10502            $bands = max(1, $bands);
10503            for ($i = 0; $i < $bands; $i++) {
10504                $col = $i % $mc->columnCount;
10505                $row = intdiv($i, $mc->columnCount);
10506                $colX = $baseX + $col * ($colW + $gap);
10507                $rowTop = $contentTop + $row * ($height + $rowGap);
10508                $stream->saveGraphicsState();
10509                $clipPdfY = $this->pageHeight - ($rowTop + $height);
10510                $stream->rectangle($colX, $clipPdfY, $colW, $height);
10511                $stream->clip();
10512                $stream->endPath();
10513                // Band i (tall layout-y = contentTop + i·height) â†’ grid cell:
10514                // PDF translate dy = i·height âˆ’ row·(height+rowGap).
10515                $dy = $i * $height - $row * ($height + $rowGap);
10516                $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $col * ($colW + $gap), $dy);
10517                foreach ($children as $child) {
10518                    $this->paintBox($child, $stream, $box);
10519                }
10520                $stream->restoreGraphicsState();
10521            }
10522            return;
10523        }
10524        for ($i = 0; $i < $mc->columnCount; $i++) {
10525            $colX = $baseX + $i * ($colW + $gap);
10526            $stream->saveGraphicsState();
10527            // Clip to column i's band: layout rect [colX, contentTop, colW,
10528            // height] â†’ PDF y = pageHeight âˆ’ (contentTop + height).
10529            $clipPdfY = $this->pageHeight - ($contentTop + $height);
10530            $stream->rectangle($colX, $clipPdfY, $colW, $height);
10531            $stream->clip();
10532            $stream->endPath();
10533            // Translate band i into this column: layout (dx = i·(colW+gap),
10534            // dy = âˆ’i·height) â†’ PDF (dx, +i·height).
10535            if ($i > 0) {
10536                $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $i * ($colW + $gap), $i * $height);
10537            }
10538            foreach ($children as $child) {
10539                $this->paintBox($child, $stream, $box);
10540            }
10541            $stream->restoreGraphicsState();
10542        }
10543    }
10544
10545    /**
10546     * CSS Multi-column 1 Â§3.3 + Â§6.2 â€” paint a container whose content
10547     * layout carved into fragmented columnar runs
10548     * ({@see \Phpdftk\HtmlToPdf\Layout\ColumnRun}).
10549     *
10550     * Each run's children were stacked as ONE TALL column; slice that
10551     * column into the run's bands here. Children belonging to no run â€”
10552     * `column-span: all` spanners â€” paint once, unsliced, exactly where
10553     * layout put them. A run paints at the position its first child holds
10554     * in paint order, so z-index and Appendix E sub-layering still order
10555     * the spanners against the runs around them.
10556     */
10557    private function paintColumnRuns(Box $box, ContentStream $stream, MultiColumnLayout $mc): void
10558    {
10559        $order = $this->paintOrderChildren($box);
10560        /** @var array<int, int> $runOf spl_object_id â†’ run index */
10561        $runOf = [];
10562        foreach ($mc->runs as $index => $run) {
10563            foreach ($run->children as $child) {
10564                $runOf[spl_object_id($child)] = $index;
10565            }
10566        }
10567        /** @var array<int, true> $painted */
10568        $painted = [];
10569        foreach ($order as $child) {
10570            $index = $runOf[spl_object_id($child)] ?? null;
10571            if ($index === null) {
10572                $this->paintBox($child, $stream, $box);
10573                continue;
10574            }
10575            if (isset($painted[$index])) {
10576                continue;
10577            }
10578            $painted[$index] = true;
10579            $runChildren = [];
10580            foreach ($order as $candidate) {
10581                if (($runOf[spl_object_id($candidate)] ?? null) === $index) {
10582                    $runChildren[] = $candidate;
10583                }
10584            }
10585            $this->paintSlicedColumnRun($box, $stream, $mc, $mc->runs[$index], $runChildren);
10586        }
10587    }
10588
10589    /**
10590     * Draw one {@see \Phpdftk\HtmlToPdf\Layout\ColumnRun}: render its tall
10591     * column once per band, clipped to that band's destination column and
10592     * translated up into it. Mirrors the single-run `column-fill: auto`
10593     * slicing in {@see paintFragmentedColumns}, but reads the height and
10594     * band count from the run rather than the container.
10595     *
10596     * @param list<Box> $children
10597     */
10598    private function paintSlicedColumnRun(
10599        Box $box,
10600        ContentStream $stream,
10601        MultiColumnLayout $mc,
10602        \Phpdftk\HtmlToPdf\Layout\ColumnRun $run,
10603        array $children,
10604    ): void {
10605        $colW = $mc->columnWidth;
10606        $gap = $mc->columnGap;
10607        $height = $run->columnHeight;
10608        if ($height <= 0.0 || $colW <= 0.0) {
10609            foreach ($children as $child) {
10610                $this->paintBox($child, $stream, $box);
10611            }
10612            return;
10613        }
10614        $baseX = $box->geometry->x;
10615        // The run's origin is stored RELATIVE to the container: block
10616        // layout can still move a multi-column box after its runs are
10617        // recorded (margin collapsing pulls it up by the previous sibling's
10618        // bottom margin), and the children move with it, so an absolute
10619        // origin would leave the bands clipping empty space.
10620        $contentTop = $box->geometry->y + $run->contentOffset;
10621        // Band i lives at layout-y `contentTop + i·height`; the clip is the
10622        // DESTINATION band, so it is emitted before the translate and stays
10623        // at band 0's vertical slot for every column.
10624        $clipPdfY = $this->pageHeight - ($contentTop + $height);
10625        for ($i = 0; $i < $run->bandCount; $i++) {
10626            $colX = $baseX + $i * ($colW + $gap);
10627            $stream->saveGraphicsState();
10628            $stream->rectangle($colX, $clipPdfY, $colW, $height);
10629            $stream->clip();
10630            $stream->endPath();
10631            if ($i > 0) {
10632                $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $i * ($colW + $gap), $i * $height);
10633            }
10634            foreach ($children as $child) {
10635                $this->paintBox($child, $stream, $box);
10636            }
10637            $stream->restoreGraphicsState();
10638        }
10639    }
10640
10641    /**
10642     * CSS Gaps 1 â€” paint the `column-rule` / `row-rule` decorations in a
10643     * grid container's gaps. The segments â€” already carrying their break
10644     * points, per-item visibility and endpoint insets â€” were resolved
10645     * during layout by
10646     * {@see \Phpdftk\HtmlToPdf\Layout\BlockLayout::computeGridGapRuleSegments()};
10647     * here they are Y-flipped into PDF space and handed to the shared
10648     * {@see strokeGapRule()} helper.
10649     */
10650    private function paintGridGapRules(Box $box, ContentStream $stream): void
10651    {
10652        if (!$box instanceof \Phpdftk\HtmlToPdf\Box\GridBox) {
10653            return;
10654        }
10655        if ($box->gapRuleSegments === []) {
10656            return;
10657        }
10658        $stream->saveGraphicsState();
10659        // CSS Gaps 1 + CSS Overflow 3 â€” a scroll container (`overflow`
10660        // other than `visible`) clips its gap decorations to the padding
10661        // box, so an overflowing grid's rules don't extend past the
10662        // visible edge (WPT grid-gap-decorations 035-037 clip a 320px
10663        // track area to a 130px box). `overflow: visible` intentionally
10664        // lets rule ink spill past the container (029/090/091), so only
10665        // clip when overflow is not visible.
10666        if ($this->shouldOverflowClip($box)) {
10667            $g = $box->geometry;
10668            $padLeft = $g->x - $g->paddingLeft;
10669            $padTop = $g->y - $g->paddingTop;
10670            $padWidth = $g->paddingLeft + $g->width + $g->paddingRight;
10671            $padHeight = $g->paddingTop + $g->height + $g->paddingBottom;
10672            $stream->rectangle($padLeft, $this->pageHeight - $padTop - $padHeight, $padWidth, $padHeight);
10673            $stream->clip();
10674            $stream->endPath();
10675        }
10676        // The segments are stored relative to the container's content-box
10677        // origin (layout cannot know the box's final absolute position),
10678        // so re-anchor on the box's geometry before the Y-flip.
10679        $originX = $box->geometry->x;
10680        $originY = $box->geometry->y;
10681        foreach ($box->gapRuleSegments as $seg) {
10682            $this->strokeGapRule(
10683                $box,
10684                $stream,
10685                $seg['prefix'],
10686                $originX + $seg['x1'],
10687                $this->pageHeight - $originY - $seg['y1'],
10688                $originX + $seg['x2'],
10689                $this->pageHeight - $originY - $seg['y2'],
10690            );
10691        }
10692        $stream->restoreGraphicsState();
10693    }
10694
10695    /**
10696     * CSS Gaps 1 â€” paint the `column-rule` / `row-rule` decorations in a
10697     * flex container's gaps. The segments (in top-down layout space) were
10698     * resolved per flex line during layout; here they are Y-flipped into
10699     * PDF space and stroked with the shared {@see strokeGapRule()} helper.
10700     * No-op for non-flex boxes or when no segments were recorded.
10701     */
10702    private function paintFlexGapRules(Box $box, ContentStream $stream): void
10703    {
10704        if (!$box instanceof \Phpdftk\HtmlToPdf\Box\FlexBox) {
10705            return;
10706        }
10707        if ($box->gapRuleSegments === []) {
10708            return;
10709        }
10710        $stream->saveGraphicsState();
10711        // Mirror the grid painter: a scroll container (`overflow` other
10712        // than `visible`) clips its gap decorations to the padding box.
10713        if ($this->shouldOverflowClip($box)) {
10714            $g = $box->geometry;
10715            $padLeft = $g->x - $g->paddingLeft;
10716            $padTop = $g->y - $g->paddingTop;
10717            $padWidth = $g->paddingLeft + $g->width + $g->paddingRight;
10718            $padHeight = $g->paddingTop + $g->height + $g->paddingBottom;
10719            $stream->rectangle($padLeft, $this->pageHeight - $padTop - $padHeight, $padWidth, $padHeight);
10720            $stream->clip();
10721            $stream->endPath();
10722        }
10723        // The segments are stored relative to the container's content-box
10724        // origin (layout cannot know the box's final absolute position),
10725        // so re-anchor on the box's geometry before the Y-flip.
10726        $originX = $box->geometry->x;
10727        $originY = $box->geometry->y;
10728        foreach ($box->gapRuleSegments as $seg) {
10729            $this->strokeGapRule(
10730                $box,
10731                $stream,
10732                $seg['prefix'],
10733                $originX + $seg['x1'],
10734                $this->pageHeight - $originY - $seg['y1'],
10735                $originX + $seg['x2'],
10736                $this->pageHeight - $originY - $seg['y2'],
10737            );
10738        }
10739        $stream->restoreGraphicsState();
10740    }
10741
10742    /**
10743     * Stroke a single gap-decoration rule between two PDF-space points,
10744     * reading `<prefix>-width` / `-style` / `-color` off the box. No-op
10745     * when the rule is invisible (zero width, `none` / `hidden` style, or
10746     * a fully transparent colour).
10747     */
10748    private function strokeGapRule(
10749        Box $box,
10750        ContentStream $stream,
10751        string $prefix,
10752        float $x1,
10753        float $y1,
10754        float $x2,
10755        float $y2,
10756    ): void {
10757        $styleV = $box->style->get("$prefix-style");
10758        $styleName = $styleV instanceof Keyword ? strtolower($styleV->name) : 'none';
10759        if ($styleName === 'none' || $styleName === 'hidden') {
10760            return;
10761        }
10762        $widthV = $box->style->get("$prefix-width");
10763        $width = $widthV instanceof \Phpdftk\Css\Value\Length ? $widthV->value : 0.0;
10764        if ($width <= 0.0) {
10765            return;
10766        }
10767        $color = $this->ruleColor($box, "$prefix-color");
10768        if ($color->a <= 0.0) {
10769            return;
10770        }
10771        $stream->saveGraphicsState();
10772        // A solid rule covers exactly the same shape as a border edge, so
10773        // FILL the rectangle it occupies rather than stroking a centre-line.
10774        // The rasteriser snaps a stroke's half-pixel edges differently from
10775        // a filled box, and every CSS Gaps reference draws the expected
10776        // rule as a bordered / `background`-filled element â€” so stroking
10777        // landed the rule one device pixel off whenever `width / 2` fell
10778        // mid-pixel (a 5px rule centred on x=105 covered 103..107 instead
10779        // of the reference's 102..106).
10780        if ($styleName !== 'dashed' && $styleName !== 'dotted') {
10781            $half = $width / 2.0;
10782            $stream->setFillColorRGB($color->r, $color->g, $color->b);
10783            if (abs($x1 - $x2) < abs($y1 - $y2)) {
10784                // Vertical rule: fixed x, spanning y.
10785                $stream->rectangle(min($x1, $x2) - $half, min($y1, $y2), $width, abs($y2 - $y1));
10786            } else {
10787                // Horizontal rule: fixed y, spanning x.
10788                $stream->rectangle(min($x1, $x2), min($y1, $y2) - $half, abs($x2 - $x1), $width);
10789            }
10790            $stream->fill();
10791            $stream->restoreGraphicsState();
10792
10793            return;
10794        }
10795        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
10796        $stream->setLineWidth($width);
10797        if ($styleName === 'dashed') {
10798            $stream->setDashPattern([$width * 3, $width * 2], 0);
10799        } else {
10800            $stream->setDashPattern([$width, $width * 1.5], 0);
10801        }
10802        $stream->moveTo($x1, $y1);
10803        $stream->lineTo($x2, $y2);
10804        $stream->stroke();
10805        $stream->restoreGraphicsState();
10806    }
10807
10808    /**
10809     * Resolve a gap-rule colour property, mapping `currentcolor` to the
10810     * box's `color`. Mirrors {@see borderColor()}.
10811     */
10812    private function ruleColor(Box $box, string $prop): Color
10813    {
10814        $color = $box->style->get($prop);
10815        if ($color instanceof Color) {
10816            return $color;
10817        }
10818        $current = $box->style->get('color');
10819        if ($current instanceof Color) {
10820            return $current;
10821        }
10822        return new Color(0, 0, 0, 1);
10823    }
10824
10825    private function borderIsVisible(Box $box, string $side): bool
10826    {
10827        $style = $box->style->get("border-$side-style");
10828        if (!$style instanceof Keyword) {
10829            return false;
10830        }
10831        $lower = strtolower($style->name);
10832        if ($lower === 'none' || $lower === 'hidden') {
10833            return false;
10834        }
10835        // CSS Backgrounds 3 Â§4.4: a fully transparent border colour
10836        // contributes nothing visible â€” skipping the paint avoids drawing
10837        // a black bar where the alpha=0 value would otherwise resolve
10838        // through the DeviceRGB `rg` operator (which has no alpha).
10839        return $this->borderColor($box, $side)->a > 0.0;
10840    }
10841
10842    private function borderColor(Box $box, string $side): Color
10843    {
10844        $color = $box->style->get("border-$side-color");
10845        if ($color instanceof Color) {
10846            return $color;
10847        }
10848        // CSS Colors 4: border-color initial is currentColor, which means
10849        // the cascaded `color` property.
10850        $current = $box->style->get('color');
10851        if ($current instanceof Color) {
10852            return $current;
10853        }
10854        return new Color(0, 0, 0, 1);
10855    }
10856
10857    /**
10858     * Emit a rect in PDF coordinates (Y flipped from top-down layout space).
10859     * `topY` is the layout-space top edge; `height` is positive downward.
10860     */
10861    private function emitRect(
10862        ContentStream $stream,
10863        float $x,
10864        float $topY,
10865        float $width,
10866        float $height,
10867        Color $fill,
10868    ): void {
10869        $pdfY = $this->pageHeight - $topY - $height;
10870        $stream->saveGraphicsState();
10871        // CSS Color Â§10 â€” translucent fills (Color::a < 1) need to
10872        // composite over whatever's underneath; that's an ExtGState
10873        // dictionary in PDF with /ca for non-stroke and /CA for
10874        // stroke. Without it the painter would emit a fully-opaque
10875        // rect even when the cascaded color said e.g. `rgba(0,0,0,
10876        // 0.6)`, masking the background entirely. WPT t422-rgba-*
10877        // exercise this with checkerboards behind translucent bands.
10878        if ($fill->a < 0.999 && $this->page !== null) {
10879            $alphaName = $this->page->ensureOpacityState($fill->a, $fill->a);
10880            $stream->setGraphicsState($alphaName);
10881        }
10882        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
10883        $stream->rectangle($x, $pdfY, $width, $height);
10884        $stream->fill();
10885        $stream->restoreGraphicsState();
10886    }
10887
10888    /**
10889     * Read the box's four corner radii in pixel-equivalent units. CSS
10890     * Backgrounds 3 Â§6 requires each radius to be clamped to half the
10891     * shorter side; we do that here.
10892     *
10893     * @return array{float, float, float, float} [tl, tr, br, bl]
10894     */
10895    private function borderRadii(Box $box): array
10896    {
10897        $read = function (string $name) use ($box): float {
10898            $v = $box->style->get($name);
10899            return $v instanceof \Phpdftk\Css\Value\Length ? max(0.0, $v->value) : 0.0;
10900        };
10901        return [
10902            $read('border-top-left-radius'),
10903            $read('border-top-right-radius'),
10904            $read('border-bottom-right-radius'),
10905            $read('border-bottom-left-radius'),
10906        ];
10907    }
10908
10909    /**
10910     * Per-corner border radii as `[horizontal, vertical]` pairs (CSS
10911     * Backgrounds 3 Â§5.1). Resolves `<length>`, `<percentage>` (horizontal %
10912     * of `$width`, vertical % of `$height`) and the two-value elliptical form
10913     * (`border-top-right-radius: 75px 50px`). Order: [TL, TR, BR, BL]. A
10914     * single-value corner yields a circular `[r, r]`, so this is a superset
10915     * of {@see borderRadii}.
10916     *
10917     * @return array{array{float,float}, array{float,float}, array{float,float}, array{float,float}}
10918     */
10919    private function borderRadiiXY(Box $box, float $width, float $height): array
10920    {
10921        $corner = function (string $name) use ($box, $width, $height): array {
10922            $v = $box->style->get($name);
10923            if ($v instanceof \Phpdftk\Css\Value\ValueList && $v->values !== []) {
10924                $h = $v->values[0];
10925                $vv = $v->values[1] ?? $v->values[0];
10926                return [
10927                    $this->resolveRadiusComponent($h, $width),
10928                    $this->resolveRadiusComponent($vv, $height),
10929                ];
10930            }
10931            return [
10932                $this->resolveRadiusComponent($v, $width),
10933                $this->resolveRadiusComponent($v, $height),
10934            ];
10935        };
10936        return [
10937            $corner('border-top-left-radius'),
10938            $corner('border-top-right-radius'),
10939            $corner('border-bottom-right-radius'),
10940            $corner('border-bottom-left-radius'),
10941        ];
10942    }
10943
10944    private function resolveRadiusComponent(?\Phpdftk\Css\Value\Value $v, float $extent): float
10945    {
10946        if ($v instanceof \Phpdftk\Css\Value\Length) {
10947            return max(0.0, $v->value);
10948        }
10949        if ($v instanceof \Phpdftk\Css\Value\Percentage) {
10950            return max(0.0, $v->value / 100.0 * $extent);
10951        }
10952        return 0.0;
10953    }
10954
10955    /**
10956     * @param array<array{float,float}> $radii
10957     */
10958    private function radiiAnyPositive(array $radii): bool
10959    {
10960        foreach ($radii as $r) {
10961            if ($r[0] > 0.0 || $r[1] > 0.0) {
10962                return true;
10963            }
10964        }
10965        return false;
10966    }
10967
10968    /**
10969     * Emit a rounded-rectangle fill path using cubic Béziers at the four
10970     * corners. Topology in layout-Y (top-down) with the painter's flip
10971     * applied at emission time. The 0.5522847498 constant is the standard
10972     * cubic-Bézier circle approximation factor.
10973     *
10974     * Each corner is an `[rx, ry]` pair (elliptical corners, CSS Backgrounds
10975     * 3 Â§5.1); `rx == ry` gives the circular case (byte-identical to the old
10976     * scalar path).
10977     *
10978     * @param array{array{float,float}, array{float,float}, array{float,float}, array{float,float}} $radii [tl, tr, br, bl]
10979     * @param list<string> $shapes [tl, tr, br, bl] corner shapes; empty â†’ all round
10980     */
10981    private function emitRoundedFill(
10982        ContentStream $stream,
10983        float $x,
10984        float $topY,
10985        float $width,
10986        float $height,
10987        array $radii,
10988        Color $fill,
10989        array $shapes = [],
10990    ): void {
10991        $stream->saveGraphicsState();
10992        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
10993        $this->buildRoundedRectPath($stream, $x, $topY, $width, $height, $radii, $shapes);
10994        $stream->fill();
10995        $stream->restoreGraphicsState();
10996    }
10997
10998    /**
10999     * Emit a rounded-rectangle SUBPATH (moveTo â†’ lineTo/curveTo â†’ closePath)
11000     * in PDF coords, without any graphics-state / colour / paint operator, so
11001     * callers can either fill or `clip` it. Each corner is an `[rx, ry]` pair
11002     * (elliptical). Layout-Y (top-down) in, PDF-Y flip applied here.
11003     *
11004     * Each corner may carry a CSS Borders 4 `corner-shape` (`$shapes` as
11005     * `[tl, tr, br, bl]` strings): `round` (arc, the default), `bevel`
11006     * (straight chamfer), `square` (fills to the box corner), `notch`
11007     * (inward square), or `scoop` (concave arc). An empty `$shapes` keeps
11008     * every corner round (byte-identical to the pre-corner-shape path).
11009     *
11010     * @param array{array{float,float}, array{float,float}, array{float,float}, array{float,float}} $radii [tl, tr, br, bl]
11011     * @param list<string> $shapes [tl, tr, br, bl] corner shapes; empty â†’ all round
11012     */
11013    private function buildRoundedRectPath(
11014        ContentStream $stream,
11015        float $x,
11016        float $topY,
11017        float $width,
11018        float $height,
11019        array $radii,
11020        array $shapes = [],
11021    ): void {
11022        $clamp = static fn(array $r): array => [
11023            min(max(0.0, $r[0]), $width / 2.0),
11024            min(max(0.0, $r[1]), $height / 2.0),
11025        ];
11026        [$tl, $tr, $br, $bl] = array_map($clamp, $radii);
11027        $shapeTl = $shapes[0] ?? 'round';
11028        $shapeTr = $shapes[1] ?? 'round';
11029        $shapeBr = $shapes[2] ?? 'round';
11030        $shapeBl = $shapes[3] ?? 'round';
11031        $bottomPdfY = $this->pageHeight - $topY - $height;
11032        $topPdfY = $this->pageHeight - $topY;
11033        // Walk clockwise starting at the top-left straight edge. Each corner
11034        // runs from its incoming edge point (A) to the outgoing edge point (B),
11035        // shaped around either the box corner (convex) or the inner corner
11036        // (concave) per `corner-shape`.
11037        $stream->moveTo($x + $tl[0], $topPdfY);
11038        $stream->lineTo($x + $width - $tr[0], $topPdfY);
11039        $this->emitCornerSegment(
11040            $stream,
11041            $shapeTr,
11042            $x + $width - $tr[0],
11043            $topPdfY,
11044            $x + $width,
11045            $topPdfY - $tr[1],
11046            $x + $width,
11047            $topPdfY,
11048        );
11049        $stream->lineTo($x + $width, $bottomPdfY + $br[1]);
11050        $this->emitCornerSegment(
11051            $stream,
11052            $shapeBr,
11053            $x + $width,
11054            $bottomPdfY + $br[1],
11055            $x + $width - $br[0],
11056            $bottomPdfY,
11057            $x + $width,
11058            $bottomPdfY,
11059        );
11060        $stream->lineTo($x + $bl[0], $bottomPdfY);
11061        $this->emitCornerSegment(
11062            $stream,
11063            $shapeBl,
11064            $x + $bl[0],
11065            $bottomPdfY,
11066            $x,
11067            $bottomPdfY + $bl[1],
11068            $x,
11069            $bottomPdfY,
11070        );
11071        $stream->lineTo($x, $topPdfY - $tl[1]);
11072        $this->emitCornerSegment(
11073            $stream,
11074            $shapeTl,
11075            $x,
11076            $topPdfY - $tl[1],
11077            $x + $tl[0],
11078            $topPdfY,
11079            $x,
11080            $topPdfY,
11081        );
11082        $stream->closePath();
11083    }
11084
11085    /**
11086     * Emit one corner of {@see buildRoundedRectPath()} from the current point
11087     * A=(ax,ay) to B=(bx,by), shaped per `corner-shape`. `(boxX,boxY)` is the
11088     * physical box corner (the convex direction); the inner corner (concave
11089     * direction) is its reflection A+B−box. The 0.5522847498 constant is the
11090     * standard cubic-Bézier quarter-ellipse factor.
11091     */
11092    private function emitCornerSegment(
11093        ContentStream $stream,
11094        string $shape,
11095        float $ax,
11096        float $ay,
11097        float $bx,
11098        float $by,
11099        float $boxX,
11100        float $boxY,
11101    ): void {
11102        // Zero-radius corner: A == B, nothing to add (the edge lineTos meet
11103        // at the box corner already).
11104        if ($ax === $bx && $ay === $by) {
11105            return;
11106        }
11107        $k = 0.5522847498;
11108        $innerX = $ax + $bx - $boxX;
11109        $innerY = $ay + $by - $boxY;
11110        switch ($shape) {
11111            case 'bevel':
11112                $stream->lineTo($bx, $by);
11113                return;
11114            case 'square':
11115                $stream->lineTo($boxX, $boxY);
11116                $stream->lineTo($bx, $by);
11117                return;
11118            case 'notch':
11119                $stream->lineTo($innerX, $innerY);
11120                $stream->lineTo($bx, $by);
11121                return;
11122            case 'scoop':
11123                // Concave quarter: pull the Bézier handles toward the inner
11124                // corner instead of the box corner.
11125                $stream->curveTo(
11126                    $ax + $k * ($innerX - $ax),
11127                    $ay + $k * ($innerY - $ay),
11128                    $bx + $k * ($innerX - $bx),
11129                    $by + $k * ($innerY - $by),
11130                    $bx,
11131                    $by,
11132                );
11133                return;
11134            case 'round':
11135            case 'squircle':
11136            default:
11137                // Convex quarter-ellipse toward the box corner.
11138                $stream->curveTo(
11139                    $ax + $k * ($boxX - $ax),
11140                    $ay + $k * ($boxY - $ay),
11141                    $bx + $k * ($boxX - $bx),
11142                    $by + $k * ($boxY - $by),
11143                    $bx,
11144                    $by,
11145                );
11146                return;
11147        }
11148    }
11149
11150    /**
11151     * CSS Borders 4 Â§5 â€” resolve `corner-shape` into `[tl, tr, br, bl]`
11152     * shape names (the 1-4 value shorthand expanded like `border-radius`).
11153     * `superellipse(<n>)` maps to the nearest implemented keyword by its
11154     * exponent (large â†’ square, ~1 â†’ round, ~0 â†’ bevel, very negative â†’
11155     * notch). Absent / unrecognised â†’ all `round`.
11156     *
11157     * @return list<string> [tl, tr, br, bl]
11158     */
11159    private function cornerShapes(Box $box): array
11160    {
11161        $value = $box->style->get('corner-shape');
11162        if ($value === null) {
11163            return ['round', 'round', 'round', 'round'];
11164        }
11165        $items = $value instanceof \Phpdftk\Css\Value\ValueList ? $value->values : [$value];
11166        $shapes = [];
11167        foreach ($items as $item) {
11168            $shapes[] = $this->cornerShapeName($item);
11169        }
11170        return match (count($shapes)) {
11171            0 => ['round', 'round', 'round', 'round'],
11172            1 => [$shapes[0], $shapes[0], $shapes[0], $shapes[0]],
11173            2 => [$shapes[0], $shapes[1], $shapes[0], $shapes[1]],
11174            3 => [$shapes[0], $shapes[1], $shapes[2], $shapes[1]],
11175            default => [$shapes[0], $shapes[1], $shapes[2], $shapes[3]],
11176        };
11177    }
11178
11179    private function cornerShapeName(\Phpdftk\Css\Value\Value $item): string
11180    {
11181        if ($item instanceof Keyword) {
11182            $name = strtolower($item->name);
11183            if (in_array($name, ['round', 'bevel', 'square', 'notch', 'scoop', 'squircle'], true)) {
11184                return $name;
11185            }
11186            return 'round';
11187        }
11188        if ($item instanceof \Phpdftk\Css\Value\CssFunction
11189            && strtolower($item->name) === 'superellipse'
11190        ) {
11191            $s = $this->superellipseExponent($item->arguments[0] ?? null);
11192            return match (true) {
11193                $s >= 3.0 => 'square',
11194                $s >= 1.5 => 'squircle',
11195                $s >= 0.5 => 'round',
11196                $s >= -0.5 => 'bevel',
11197                $s >= -1.5 => 'scoop',
11198                default => 'notch',
11199            };
11200        }
11201        return 'round';
11202    }
11203
11204    private function superellipseExponent(?\Phpdftk\Css\Value\Value $arg): float
11205    {
11206        if ($arg instanceof Keyword) {
11207            $name = strtolower($arg->name);
11208            if ($name === 'infinity') {
11209                return INF;
11210            }
11211            if ($name === '-infinity') {
11212                return -INF;
11213            }
11214        }
11215        if ($arg instanceof \Phpdftk\Css\Value\Integer || $arg instanceof \Phpdftk\Css\Value\Number) {
11216            return (float) $arg->value;
11217        }
11218        if ($arg instanceof \Phpdftk\Css\Value\Length) {
11219            return $arg->value;
11220        }
11221        return 1.0;
11222    }
11223
11224    /**
11225     * Reduce per-corner border radii from the border box inward to a
11226     * reference box (CSS Backgrounds 3 Â§5.3): each corner's rx shrinks by the
11227     * border (+ padding for content-box) on its horizontal side, ry by the
11228     * vertical side. `border-box` / `margin-box` pass through unchanged.
11229     *
11230     * @param array{array{float,float}, array{float,float}, array{float,float}, array{float,float}} $radii
11231     * @return array{array{float,float}, array{float,float}, array{float,float}, array{float,float}}
11232     */
11233    private function reduceRadiiToBox(array $radii, string $refBox, BoxGeometry $g): array
11234    {
11235        if ($refBox !== 'padding-box' && $refBox !== 'content-box') {
11236            return $radii;
11237        }
11238        $insL = $g->borderLeft;
11239        $insT = $g->borderTop;
11240        $insR = $g->borderRight;
11241        $insB = $g->borderBottom;
11242        if ($refBox === 'content-box') {
11243            $insL += $g->paddingLeft;
11244            $insT += $g->paddingTop;
11245            $insR += $g->paddingRight;
11246            $insB += $g->paddingBottom;
11247        }
11248        $sub = static fn(float $r, float $i): float => max(0.0, $r - $i);
11249        return [
11250            [$sub($radii[0][0], $insL), $sub($radii[0][1], $insT)], // TL
11251            [$sub($radii[1][0], $insR), $sub($radii[1][1], $insT)], // TR
11252            [$sub($radii[2][0], $insR), $sub($radii[2][1], $insB)], // BR
11253            [$sub($radii[3][0], $insL), $sub($radii[3][1], $insB)], // BL
11254        ];
11255    }
11256
11257    /**
11258     * A `border-collapse: collapse` table cell whose border must paint in the
11259     * late (post-content) phase so a descendant cannot overpaint the collapsed
11260     * table border (CSS Tables 3 Â§4.3 painting order).
11261     */
11262    private function isCollapsedBorderCell(Box $box): bool
11263    {
11264        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox)) {
11265            return false;
11266        }
11267        $bc = $box->style->get('border-collapse');
11268        return $bc instanceof \Phpdftk\Css\Value\Keyword
11269            && strtolower($bc->name) === 'collapse';
11270    }
11271}