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