Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.25% covered (warning)
84.25%
1284 / 1524
35.14% covered (danger)
35.14%
26 / 74
CRAP
0.00% covered (danger)
0.00%
0 / 1
Painter
84.25% covered (warning)
84.25%
1284 / 1524
35.14% covered (danger)
35.14%
26 / 74
1545.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 paint
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 paintBox
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
11
 boxEntirelyOffPage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 applyBoxTransform
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 composeTransformMatrix
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 transformFunctionToPdfMatrix
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
12.47
 multiplyMatrices
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 resolveTransformOrigin
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 resolveOriginComponent
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
18.39
 lengthOrPercentageToFloat
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 shouldClampDecorationsToPage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 isCloneDecorationBreak
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 clampGeometryToPage
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 paintImage
88.46% covered (warning)
88.46%
46 / 52
0.00% covered (danger)
0.00%
0 / 1
15.35
 objectFitKeyword
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveObjectFit
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
6.01
 resolveImageSrc
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 materializeDataUrl
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
7.60
 resolveBackgroundClip
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 shouldOverflowClip
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
7.10
 emitOverflowClipPath
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 isVisibilityHidden
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 paintBoxShadow
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
9
 paintInsetShadow
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 collectShadowLayers
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 parseShadowLayer
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
10.03
 resolveOpacityGsName
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 paintListMarker
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
11
 formatCounterMarker
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 listItemIndex
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
17.47
 paintCounterMarker
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
5.03
 paintMarkerSquare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 paintMarkerCircle
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 dominantFontSize
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 paintLineBoxes
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 paintInlineBackgrounds
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
12.05
 collectTextShadowLayers
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
12.07
 paintLine
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 paintTextDecorations
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
13.36
 emitDecorationStyled
53.33% covered (warning)
53.33%
16 / 30
0.00% covered (danger)
0.00%
0 / 1
14.50
 emitWavyDecoration
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
5
 resolveDecorationThickness
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveUnderlineOffset
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 textDecorationStyle
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 textDecorationLines
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
8.19
 textDecorationColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 paintFragment
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
12
 collectBlockLinkRect
36.00% covered (danger)
36.00%
9 / 25
0.00% covered (danger)
0.00%
0 / 1
36.21
 snapKern
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 paintBackground
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
23
 paintRadialGradient
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
9.11
 paintLinearGradient
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
6.04
 paintBackgroundImage
88.89% covered (warning)
88.89%
64 / 72
0.00% covered (danger)
0.00%
0 / 1
21.60
 repeatAxes
60.00% covered (warning)
60.00%
12 / 20
0.00% covered (danger)
0.00%
0 / 1
21.22
 resolveBackgroundPosition
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
15.37
 axisOffsetFromValue
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
13.34
 resolveBackgroundSize
71.43% covered (warning)
71.43%
35 / 49
0.00% covered (danger)
0.00%
0 / 1
21.97
 intrinsicSize
50.00% covered (danger)
50.00%
8 / 16
0.00% covered (danger)
0.00%
0 / 1
16.00
 paintBorders
80.30% covered (warning)
80.30%
53 / 66
0.00% covered (danger)
0.00%
0 / 1
13.10
 paintBorderSide
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
11
 paintDashedDottedSide
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 resolve3dBorderColor
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 borderStyleName
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 bordersAreUniform
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
13.61
 emitRoundedStroke
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
30
 paintOutline
89.83% covered (warning)
89.83%
53 / 59
0.00% covered (danger)
0.00%
0 / 1
16.27
 paintColumnRules
81.82% covered (warning)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
11.73
 borderIsVisible
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 borderColor
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 emitRect
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 borderRadii
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 emitRoundedFill
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Painter;
6
7use Phpdftk\Css\Value\Color;
8use Phpdftk\Css\Value\Keyword;
9use Phpdftk\HtmlToPdf\Box\Box;
10use Phpdftk\HtmlToPdf\Layout\BoxGeometry;
11use Phpdftk\HtmlToPdf\Layout\InlineFragment;
12use Phpdftk\HtmlToPdf\Layout\LineBox;
13use Phpdftk\Pdf\Core\Content\ContentStream;
14use Phpdftk\Pdf\Core\Font\RegisteredFont;
15use Phpdftk\Pdf\Writer\Font as WriterFont;
16use Phpdftk\Pdf\Writer\Page as WriterPage;
17use Phpdftk\Pdf\Writer\PdfWriter;
18
19/**
20 * Phase 1G — paints a laid-out box tree onto a {@see ContentStream}.
21 *
22 * The painter walks the box tree depth-first and emits PDF operators for
23 * each box's visual contributions: background colour (rect + fill), then
24 * border edges (four straight strokes one per side, honouring per-side
25 * widths and colours), then recurses into children. Text rendering uses
26 * the line-box / shaped-glyph data deposited by {@see InlineLayout};
27 * Phase 1G.1 ships background + border painting and leaves text as a
28 * follow-up that depends on `@font-face` integration (1M) — for now line
29 * boxes are walked but text painting is a no-op, so the painter exercises
30 * end-to-end without requiring a font registration.
31 *
32 * **Coordinate-system flip**: the layout uses PDF user-space units but
33 * with Y growing downward from the top of the page (the convention of CSS
34 * and every other layout engine). PDF's native content-stream coordinates
35 * grow upward from the bottom. The painter flips Y when emitting
36 * rectangles so consumers see PDF-correct output; the underlying box
37 * geometry stays in top-down space for layout sanity.
38 */
39final class Painter
40{
41    public function __construct(
42        private readonly float $pageHeight,
43        private readonly ?RegisteredFont $defaultFont = null,
44        private readonly ?WriterPage $page = null,
45        /**
46         * Layout-Y range this page covers. When set, the painter skips
47         * any box whose geometry sits entirely above or entirely below
48         * this range — a multi-page document no longer re-paints every
49         * box on every page, just the ones intersecting the current
50         * page slot.
51         */
52        private readonly ?float $pageRangeStart = null,
53        private readonly ?float $pageRangeEnd = null,
54        /**
55         * When set, the painter can register Image XObjects via the
56         * writer's `addImage` and emit `Do` for `<img>` elements whose
57         * `src` is a `data:image/{png,jpeg}` URL. When null, image
58         * painting is a no-op (the alt-text fallback still flows).
59         */
60        private readonly ?PdfWriter $writer = null,
61        /**
62         * Base directory for resolving relative `<img src>` paths against
63         * the filesystem. When null, only `data:` URLs paint.
64         */
65        private readonly ?string $baseDir = null,
66        /**
67         * Map of `postScriptName → RegisteredFont` keyed by the font's
68         * raw PS name. Used to switch `Tf` per fragment when an inline
69         * subtree shaped against an alternate font from the `FontResolver`.
70         * Defaults to `[$defaultFont->postScriptName => $defaultFont]`
71         * when only the default is registered.
72         *
73         * @var array<string, RegisteredFont>
74         */
75        private readonly array $registeredFonts = [],
76    ) {}
77
78    /**
79     * Track tempfile paths created for `data:` URL images so we can
80     * delete them when the Painter is destroyed.
81     *
82     * @var list<string>
83     */
84    private array $tempImagePaths = [];
85
86    /**
87     * Cache `data:` URL → registered XObject resource name for the
88     * current page, so the same image used multiple times only spills
89     * + registers once.
90     *
91     * @var array<string, string>
92     */
93    private array $imageNameCache = [];
94
95    public function __destruct()
96    {
97        foreach ($this->tempImagePaths as $path) {
98            if (is_file($path)) {
99                @unlink($path);
100            }
101        }
102    }
103
104    /**
105     * Link rects in PDF coordinates collected during the most recent
106     * {@see paint()} call. Each entry is `{href, llx, lly, urx, ury,
107     * title}` with the Y-flip already applied. The Renderer reads this
108     * list to register `/Link` annotations on the current page.
109     *
110     * @var list<array{href: string, llx: float, lly: float, urx: float, ury: float, title: ?string}>
111     */
112    public array $collectedLinks = [];
113
114    public function paint(Box $root, ContentStream $stream): void
115    {
116        $this->collectedLinks = [];
117        $this->imageNameCache = [];
118        $this->paintBox($root, $stream);
119    }
120
121    private function paintBox(Box $box, ContentStream $stream): void
122    {
123        // Off-page skip: the box's layout-Y range doesn't overlap this
124        // page's range. We still must descend into children for the
125        // `<a href>` link-rect collection (which uses the page constant
126        // to compute PDF-Y), but skip the heavy paint operations.
127        if ($this->boxEntirelyOffPage($box)) {
128            return;
129        }
130        $opacityGsName = $this->resolveOpacityGsName($box);
131        if ($opacityGsName !== null) {
132            $stream->saveGraphicsState();
133            $stream->setGraphicsState($opacityGsName);
134        }
135        // CSS Transforms 2 §6: apply the box's transform (if any)
136        // before any drawing. The graphics state save/restore wraps
137        // the entire paint (background + content + children) so the
138        // transform affects every nested operation.
139        $hasTransform = $this->applyBoxTransform($box, $stream);
140        // CSS Visual Formatting Model 9.5: `visibility: hidden` boxes
141        // occupy layout space but paint nothing themselves; descendants
142        // with their own `visibility` declaration can still be visible.
143        $hidden = $this->isVisibilityHidden($box);
144        if (!$hidden) {
145            // CSS Fragmentation 4 §5.5: `box-decoration-break: clone`
146            // makes each fragment paint full decorations as if it were
147            // a standalone box. For a straddling box we temporarily
148            // swap in a geometry clamped to this page's visible
149            // extent, so background/border/shadow draw at the page
150            // seam as a synthetic edge.
151            $originalGeo = null;
152            if ($this->shouldClampDecorationsToPage($box)) {
153                $originalGeo = $box->geometry;
154                $box->geometry = $this->clampGeometryToPage($originalGeo);
155            }
156            // CSS Backgrounds 3 §6.1.1 — paint stack from bottom up:
157            // outset shadows → background → inset shadows → border.
158            $this->paintBoxShadow($box, $stream, insetOnly: false);
159            $this->paintBackground($box, $stream);
160            $this->paintBoxShadow($box, $stream, insetOnly: true);
161            $this->paintBorders($box, $stream);
162            if ($originalGeo !== null) {
163                $box->geometry = $originalGeo;
164            }
165            $this->paintOutline($box, $stream);
166            $this->paintColumnRules($box, $stream);
167            $this->paintImage($box, $stream);
168            $this->paintListMarker($box, $stream);
169            $this->paintLineBoxes($box, $stream);
170            $this->collectBlockLinkRect($box);
171        }
172        // CSS Overflow 3 §3 — `overflow: hidden | clip | scroll | auto`
173        // clips descendants to the box's padding-edge. `visible` (the
174        // initial value) lets descendants render outside the box.
175        // Print medium can't scroll, so `scroll` / `auto` behave like
176        // `hidden` here. The clip is push/popped around the children
177        // loop so siblings of this box stay unaffected.
178        $overflowClip = $this->shouldOverflowClip($box);
179        if ($overflowClip) {
180            $stream->saveGraphicsState();
181            $this->emitOverflowClipPath($stream, $box);
182        }
183        foreach ($box->children as $child) {
184            $this->paintBox($child, $stream);
185        }
186        if ($overflowClip) {
187            $stream->restoreGraphicsState();
188        }
189        if ($hasTransform) {
190            $stream->restoreGraphicsState();
191        }
192        if ($opacityGsName !== null) {
193            $stream->restoreGraphicsState();
194        }
195    }
196
197    /**
198     * Return `true` when the box's layout-Y range sits entirely above or
199     * entirely below the painter's configured page range. Skipping these
200     * subtrees lets a 100-page document not re-paint every box on every
201     * page. Falls back to `false` (always paint) when the page range
202     * isn't set — preserves the old behaviour for single-page renders.
203     */
204    private function boxEntirelyOffPage(Box $box): bool
205    {
206        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
207            return false;
208        }
209        $g = $box->geometry;
210        $top = $g->y;
211        $bottom = $g->y + $g->outerHeight();
212        // Outline boxes / anonymous boxes can carry zero geometry; never
213        // skip them — descendants may still be in range.
214        if ($bottom === $top) {
215            return false;
216        }
217        return $bottom <= $this->pageRangeStart || $top >= $this->pageRangeEnd;
218    }
219
220    /**
221     * Apply the box's CSS `transform` (if any) via the PDF `cm`
222     * operator. Returns `true` if a graphics-state save was emitted
223     * (the caller must restoreGraphicsState after painting); `false`
224     * if no transform applied. The transform is composed as
225     *   T(origin) × M_css→pdf × T(-origin)
226     * where M is the composition of all transform functions and the
227     * origin sits at the box's `transform-origin` in PDF coordinates.
228     */
229    private function applyBoxTransform(Box $box, ContentStream $stream): bool
230    {
231        $value = $box->style->get('transform');
232        if (!$value instanceof \Phpdftk\Css\Value\Transform || $value->functions === []) {
233            return false;
234        }
235        $matrix = $this->composeTransformMatrix($value, $box);
236        if ($matrix === null) {
237            return false;
238        }
239        [$ox, $oy] = $this->resolveTransformOrigin($box);
240        // T(ox, oy) × M × T(-ox, -oy). PDF cm composes
241        // CTM_new = CTM_old × M_provided, so submit in
242        // outer-to-inner order: translate(+), matrix, translate(-).
243        $stream->saveGraphicsState();
244        if ($ox !== 0.0 || $oy !== 0.0) {
245            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $ox, $oy);
246        }
247        $stream->concatMatrix($matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5]);
248        if ($ox !== 0.0 || $oy !== 0.0) {
249            $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$ox, -$oy);
250        }
251        return true;
252    }
253
254    /**
255     * Compose the box's transform-function list into a single 2D
256     * matrix [a, b, c, d, e, f] in PDF coordinate space (Y-up).
257     * Returns `null` if no function produced output (3D-only
258     * transforms flatten to identity at Phase 2).
259     *
260     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
261     */
262    private function composeTransformMatrix(\Phpdftk\Css\Value\Transform $transform, Box $box): ?array
263    {
264        $result = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // identity
265        $any = false;
266        foreach ($transform->functions as $fn) {
267            $m = $this->transformFunctionToPdfMatrix($fn, $box);
268            if ($m === null) {
269                continue;
270            }
271            $result = $this->multiplyMatrices($result, $m);
272            $any = true;
273        }
274        return $any ? $result : null;
275    }
276
277    /**
278     * Convert a single CSS transform-function to a 2D PDF matrix
279     * (Y-up). The conversion negates the (b, c, f) entries — that's
280     * the matrix-form of conjugating by a Y-axis flip, which maps
281     * CSS's Y-down coords to PDF's Y-up.
282     *
283     * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
284     */
285    private function transformFunctionToPdfMatrix(
286        \Phpdftk\Css\Value\TransformFunction $fn,
287        Box $box,
288    ): ?array {
289        if ($fn instanceof \Phpdftk\Css\Value\TranslateTransform) {
290            $tx = $this->lengthOrPercentageToFloat($fn->x, $box->geometry->width);
291            $ty = $this->lengthOrPercentageToFloat($fn->y, $box->geometry->height);
292            return [1.0, 0.0, 0.0, 1.0, $tx, -$ty];
293        }
294        if ($fn instanceof \Phpdftk\Css\Value\RotateTransform) {
295            // 3D rotations around the X or Y axis flatten to identity
296            // at Phase 2 (we don't render in perspective); only the Z
297            // axis rotation produces a 2D effect.
298            if (($fn->ax !== 0.0 || $fn->ay !== 0.0) && $fn->az === 0.0) {
299                return null;
300            }
301            $rad = deg2rad($fn->angleDeg);
302            $cos = cos($rad);
303            $sin = sin($rad);
304            return [$cos, -$sin, $sin, $cos, 0.0, 0.0];
305        }
306        if ($fn instanceof \Phpdftk\Css\Value\ScaleTransform) {
307            return [$fn->sx, 0.0, 0.0, $fn->sy, 0.0, 0.0];
308        }
309        if ($fn instanceof \Phpdftk\Css\Value\SkewTransform) {
310            $tanX = tan(deg2rad($fn->xDeg));
311            $tanY = tan(deg2rad($fn->yDeg));
312            // CSS skewX: [1, 0, tan(x), 1]; PDF flips b+c → [1, 0, -tan(x), 1]
313            // CSS skewY: [1, tan(y), 0, 1]; PDF flips → [1, -tan(y), 0, 1]
314            return [1.0, -$tanY, -$tanX, 1.0, 0.0, 0.0];
315        }
316        if ($fn instanceof \Phpdftk\Css\Value\MatrixTransform) {
317            return [$fn->a, -$fn->b, -$fn->c, $fn->d, $fn->e, -$fn->f];
318        }
319        return null;
320    }
321
322    /**
323     * Multiply two 2D affine matrices in [a, b, c, d, e, f] form:
324     *   M = [a c e]    M' = [a' c' e']    M × M' = [...]
325     *       [b d f]         [b' d' f']
326     *       [0 0 1]         [0  0  1]
327     *
328     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m1
329     * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m2
330     * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
331     */
332    private function multiplyMatrices(array $m1, array $m2): array
333    {
334        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
335        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
336        return [
337            $a1 * $a2 + $c1 * $b2,
338            $b1 * $a2 + $d1 * $b2,
339            $a1 * $c2 + $c1 * $d2,
340            $b1 * $c2 + $d1 * $d2,
341            $a1 * $e2 + $c1 * $f2 + $e1,
342            $b1 * $e2 + $d1 * $f2 + $f1,
343        ];
344    }
345
346    /**
347     * Resolve `transform-origin` to a PDF coordinate point. Default
348     * `50% 50%` puts the pivot at the box's centre. Lengths and
349     * percentages compose; percentages resolve against the box's
350     * border-box dimension on each axis.
351     *
352     * @return array{0: float, 1: float} (px, py) in PDF coords.
353     */
354    private function resolveTransformOrigin(Box $box): array
355    {
356        $g = $box->geometry;
357        $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight;
358        $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom;
359        $boxX = $g->x - $g->paddingLeft - $g->borderLeft;
360        $boxY = $g->y - $g->paddingTop - $g->borderTop;
361
362        $value = $box->style->get('transform-origin');
363        $offX = $width / 2.0;
364        $offY = $height / 2.0;
365        if ($value instanceof \Phpdftk\Css\Value\ValueList && count($value->values) >= 2) {
366            $offX = $this->resolveOriginComponent($value->values[0], $width, $offX);
367            $offY = $this->resolveOriginComponent($value->values[1], $height, $offY);
368        }
369        $cssY = $boxY + $offY;
370        return [$boxX + $offX, $this->pageHeight - $cssY];
371    }
372
373    private function resolveOriginComponent(
374        \Phpdftk\Css\Value\Value $value,
375        float $extent,
376        float $fallback,
377    ): float {
378        if ($value instanceof \Phpdftk\Css\Value\Length) {
379            return $value->value;
380        }
381        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
382            return $value->value / 100.0 * $extent;
383        }
384        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
385            return match (strtolower($value->name)) {
386                'left', 'top' => 0.0,
387                'right', 'bottom' => $extent,
388                'center' => $extent / 2.0,
389                default => $fallback,
390            };
391        }
392        return $fallback;
393    }
394
395    private function lengthOrPercentageToFloat(
396        \Phpdftk\Css\Value\Length|\Phpdftk\Css\Value\Percentage $value,
397        float $basis,
398    ): float {
399        if ($value instanceof \Phpdftk\Css\Value\Length) {
400            return $value->value;
401        }
402        return $value->value / 100.0 * $basis;
403    }
404
405    /**
406     * `true` when this box (a) declares `box-decoration-break: clone`
407     * AND (b) actually straddles the current page boundary. Boxes that
408     * fit entirely on one page don't need the clamp — slice and clone
409     * paint identically in that case.
410     */
411    private function shouldClampDecorationsToPage(Box $box): bool
412    {
413        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
414            return false;
415        }
416        if (!$this->isCloneDecorationBreak($box)) {
417            return false;
418        }
419        $g = $box->geometry;
420        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
421        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
422        return $outerTop < $this->pageRangeStart || $outerBottom > $this->pageRangeEnd;
423    }
424
425    private function isCloneDecorationBreak(Box $box): bool
426    {
427        $value = $box->style->get('box-decoration-break');
428        if (!($value instanceof Keyword)) {
429            return false;
430        }
431        return strtolower($value->name) === 'clone';
432    }
433
434    /**
435     * Return a clone of `$g` with content y / height clamped so the
436     * box's outer margin-box sits entirely inside the painter's
437     * current page range. Used by `box-decoration-break: clone` to
438     * make each fragment paint full borders at its visible extent.
439     */
440    private function clampGeometryToPage(BoxGeometry $g): BoxGeometry
441    {
442        $clone = clone $g;
443        if ($this->pageRangeStart === null || $this->pageRangeEnd === null) {
444            return $clone;
445        }
446        $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop;
447        $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
448        if ($outerTop < $this->pageRangeStart) {
449            $delta = $this->pageRangeStart - $outerTop;
450            $clone->y += $delta;
451            $clone->height = max(0.0, $clone->height - $delta);
452        }
453        if ($outerBottom > $this->pageRangeEnd) {
454            $delta = $outerBottom - $this->pageRangeEnd;
455            $clone->height = max(0.0, $clone->height - $delta);
456        }
457        return $clone;
458    }
459
460    /**
461     * Phase-1 `<img src="data:image/...">` painter: decodes the data URL,
462     * spills the bytes to a tempfile, registers an Image XObject on the
463     * current page via the writer, and emits `q cm /Name Do Q` at the
464     * box's geometry. No-op when the writer or page is not wired in, or
465     * when the src isn't a `data:image/png|jpeg` URL we can paint.
466     */
467    private function paintImage(Box $box, ContentStream $stream): void
468    {
469        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)) {
470            return;
471        }
472        if ($this->writer === null || $this->page === null) {
473            return;
474        }
475        $element = $box->element;
476        if ($element === null || strtolower($element->localName) !== 'img') {
477            return;
478        }
479        $src = $element->getAttribute('src');
480        if ($src === null) {
481            return;
482        }
483        // Per-page cache: the same src only spills + registers once on
484        // this page. Multi-page reuse still re-registers — XObject
485        // resource names are page-local in PdfWriter.
486        if (isset($this->imageNameCache[$src])) {
487            $name = $this->imageNameCache[$src];
488        } else {
489            $resolvedPath = $this->resolveImageSrc($src);
490            if ($resolvedPath === null) {
491                return;
492            }
493            // ImageParser throws on malformed bytes; swallow + fall back to
494            // the alt-text path (or empty box) rather than crashing the whole
495            // render because of one bad asset.
496            try {
497                $name = $this->writer->addImage($resolvedPath, $this->page);
498            } catch (\Throwable) {
499                return;
500            }
501            $this->imageNameCache[$src] = $name;
502        }
503        $geo = $box->geometry;
504        if ($geo->width <= 0.0) {
505            // No declared size — skip; the alt-text fallback path covers
506            // unsized images via the BoxGenerator's InlineBox lowering.
507            return;
508        }
509        $height = $geo->height > 0.0 ? $geo->height : $geo->width;
510        // CSS Images 3 §5: `object-fit` controls the image's scale
511        // within its declared content rect. `fill` (default) stretches;
512        // `contain` / `cover` / `none` / `scale-down` preserve aspect.
513        // `object-position` selects which part of the box the image
514        // anchors to when there's slack — defaults to centre.
515        $fit = $this->objectFitKeyword($box);
516        $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height);
517        $positionValue = $box->style->get('object-position');
518        if ($positionValue !== null
519            && ($rect['w'] !== $geo->width || $rect['h'] !== $height)
520        ) {
521            $pos = $this->resolveBackgroundPosition(
522                $positionValue,
523                $rect['w'],
524                $rect['h'],
525                $geo->width,
526                $height,
527            );
528            $rect['offsetX'] = $pos['offsetX'];
529            $rect['offsetY'] = $pos['offsetY'];
530        }
531        // PDF y-axis is inverted; the `cm` matrix maps the unit square
532        // [0,1]^2 to the box's PDF-space rect.
533        $pdfY = $this->pageHeight - $geo->y - $height;
534        $stream->saveGraphicsState();
535        // Clip to the box rect so `cover` overflow doesn't bleed into
536        // sibling boxes.
537        $stream->rectangle($geo->x, $pdfY, $geo->width, $height);
538        $stream->clip();
539        $stream->endPath();
540        $stream->concatMatrix(
541            $rect['w'],
542            0.0,
543            0.0,
544            $rect['h'],
545            $geo->x + $rect['offsetX'],
546            $pdfY + ($height - $rect['h'] - $rect['offsetY']),
547        );
548        $stream->doXObject($name);
549        $stream->restoreGraphicsState();
550    }
551
552    /**
553     * Read the box's cascaded `object-fit` value, normalised to one of
554     * `fill` / `contain` / `cover` / `none` / `scale-down`. Unknown
555     * keywords fall back to `fill`.
556     */
557    private function objectFitKeyword(Box $box): string
558    {
559        $value = $box->style->get('object-fit');
560        if ($value instanceof Keyword) {
561            $kw = strtolower($value->name);
562            if (in_array($kw, ['fill', 'contain', 'cover', 'none', 'scale-down'], true)) {
563                return $kw;
564            }
565        }
566        return 'fill';
567    }
568
569    /**
570     * Compute the painted rect for a replaced element under `object-fit`.
571     * Mirrors CSS Images 3 §5 semantics:
572     *   - `fill` → stretch to the box.
573     *   - `contain` → preserve aspect, fit inside; centred slack.
574     *   - `cover` → preserve aspect, fill; clipped overflow.
575     *   - `none` → natural size; centred slack.
576     *   - `scale-down` → min(`none`, `contain`) — uses natural size when
577     *     the image already fits, otherwise behaves like `contain`.
578     *
579     * @return array{w: float, h: float, offsetX: float, offsetY: float}
580     */
581    private function resolveObjectFit(
582        string $fit,
583        string $src,
584        float $boxWidth,
585        float $boxHeight,
586    ): array {
587        if ($fit === 'fill') {
588            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
589        }
590        $intrinsic = $this->intrinsicSize($src);
591        if ($intrinsic === null) {
592            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
593        }
594        [$natW, $natH] = $intrinsic;
595        if ($fit === 'none') {
596            return [
597                'w' => (float) $natW,
598                'h' => (float) $natH,
599                'offsetX' => ($boxWidth - $natW) / 2,
600                'offsetY' => ($boxHeight - $natH) / 2,
601            ];
602        }
603        $scaleW = $boxWidth / $natW;
604        $scaleH = $boxHeight / $natH;
605        if ($fit === 'scale-down') {
606            // Use 1.0 (natural) when it already fits; else contain.
607            $scale = min(1.0, $scaleW, $scaleH);
608        } elseif ($fit === 'cover') {
609            $scale = max($scaleW, $scaleH);
610        } else {
611            // contain
612            $scale = min($scaleW, $scaleH);
613        }
614        $finalW = $natW * $scale;
615        $finalH = $natH * $scale;
616        return [
617            'w' => $finalW,
618            'h' => $finalH,
619            'offsetX' => ($boxWidth - $finalW) / 2,
620            'offsetY' => ($boxHeight - $finalH) / 2,
621        ];
622    }
623
624    /**
625     * Resolve an `<img src>` value to a real path that
626     * {@see PdfWriter::addImage} can read. Handles:
627     *   - `data:image/{png,jpeg}[;base64],...` → spilled tempfile
628     *   - relative paths → joined with `baseDir`, must resolve under it
629     *
630     * Returns null when the source isn't a Phase-1 supported variant or
631     * when path resolution escapes `baseDir`.
632     */
633    private function resolveImageSrc(string $src): ?string
634    {
635        if (str_starts_with($src, 'data:')) {
636            return $this->materializeDataUrl($src);
637        }
638        return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir))
639            ->resolveLocalPath($src);
640    }
641
642    /**
643     * Decode `data:image/{png,jpeg};base64,...` (or the rfc2397 non-base64
644     * form) into a tempfile so {@see PdfWriter::addImage} can parse it.
645     * Returns null when the URL isn't a Phase-1 supported variant.
646     */
647    private function materializeDataUrl(string $dataUrl): ?string
648    {
649        // `data:image/png;base64,iVBORw0K...` — match the MIME + optional
650        // `;base64` flag + the payload.
651        if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $dataUrl, $m) !== 1) {
652            return null;
653        }
654        $mime = $m[1] === 'jpg' ? 'jpeg' : $m[1];
655        $payload = $m[2] === 'base64,'
656            ? base64_decode($m[3], strict: true)
657            : urldecode($m[3]);
658        if ($payload === false || $payload === '') {
659            return null;
660        }
661        $ext = $mime === 'jpeg' ? 'jpg' : 'png';
662        $tempPath = tempnam(sys_get_temp_dir(), 'phpdftk-img-') . '.' . $ext;
663        \Phpdftk\Filesystem\LocalFilesystem::writeFile($tempPath, $payload);
664        $this->tempImagePaths[] = $tempPath;
665        return $tempPath;
666    }
667
668    /**
669     * Resolve CSS Backgrounds 3 §3.5 `background-clip` to one of
670     * `border-box` / `padding-box` / `content-box`. Unknown
671     * keywords fall back to the initial `border-box`.
672     */
673    private function resolveBackgroundClip(Box $box): string
674    {
675        $value = $box->style->get('background-clip');
676        if (!($value instanceof Keyword)) {
677            return 'border-box';
678        }
679        $name = strtolower($value->name);
680        if (in_array($name, ['border-box', 'padding-box', 'content-box'], true)) {
681            return $name;
682        }
683        return 'border-box';
684    }
685
686    /**
687     * CSS Overflow 3 §3 — return true when this box should clip its
688     * descendants to its padding edge. `visible` (initial) → no clip;
689     * any of `hidden` / `clip` / `scroll` / `auto` → clip. PDF
690     * clipping is rectangular (both axes), so when EITHER `overflow-x`
691     * or `overflow-y` is non-visible we clip both axes — over-clipping
692     * relative to spec but visually correct for print where there's
693     * no scroll viewport.
694     */
695    private function shouldOverflowClip(Box $box): bool
696    {
697        foreach (['overflow', 'overflow-x', 'overflow-y'] as $prop) {
698            $value = $box->style->get($prop);
699            if (!($value instanceof Keyword)) {
700                continue;
701            }
702            $name = strtolower($value->name);
703            if ($name === 'hidden' || $name === 'clip' || $name === 'scroll' || $name === 'auto') {
704                return true;
705            }
706        }
707        return false;
708    }
709
710    /**
711     * Emit a clip rect at the box's padding-edge rectangle (CSS
712     * Overflow 3 §4) so descendants painted after this call are
713     * clipped to it. Caller is responsible for the `saveGraphicsState`
714     * / `restoreGraphicsState` envelope.
715     */
716    private function emitOverflowClipPath(ContentStream $stream, Box $box): void
717    {
718        $g = $box->geometry;
719        $padX = $g->x - $g->paddingLeft;
720        $padTop = $g->y - $g->paddingTop;
721        $padWidth = $g->paddingLeft + $g->width + $g->paddingRight;
722        $padHeight = $g->paddingTop + $g->height + $g->paddingBottom;
723        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
724            return;
725        }
726        $pdfY = $this->pageHeight - $padTop - $padHeight;
727        $stream->rectangle($padX, $pdfY, $padWidth, $padHeight);
728        $stream->clip();
729        $stream->endPath();
730    }
731
732    private function isVisibilityHidden(Box $box): bool
733    {
734        $value = $box->style->get('visibility');
735        return $value instanceof Keyword
736            && in_array(strtolower($value->name), ['hidden', 'collapse'], true);
737    }
738
739    /**
740     * Paint CSS Backgrounds 3 §6 `box-shadow`. Phase-1 implementation:
741     * draws a hard-edged shadow rect (no blur — blur needs Filter Effects 1
742     * which is Phase 2). Honours `<offset-x>`, `<offset-y>`, optional
743     * `<spread-radius>`, and `<color>` (defaults to cascaded `color`).
744     * Inset shadows are not yet emitted (would clip inward).
745     *
746     * Multi-shadow comma lists are read; each shadow paints in reverse
747     * order so the first listed sits on top — matching CSS stacking.
748     */
749    private function paintBoxShadow(Box $box, ContentStream $stream, bool $insetOnly): void
750    {
751        $value = $box->style->get('box-shadow');
752        if ($value === null
753            || ($value instanceof Keyword && strtolower($value->name) === 'none')
754        ) {
755            return;
756        }
757        $shadows = $this->collectShadowLayers($value);
758        if ($shadows === []) {
759            return;
760        }
761        $defaultColor = $box->style->get('color');
762        $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1);
763        $geo = $box->geometry;
764
765        // Paint last shadow first so earlier-listed shadows sit on top.
766        foreach (array_reverse($shadows) as $shadow) {
767            if ($shadow['inset'] !== $insetOnly) {
768                continue;
769            }
770            $color = $shadow['color'] ?? $textColor;
771            $spread = $shadow['spread'];
772            if ($shadow['inset']) {
773                $this->paintInsetShadow($geo, $shadow, $color, $stream);
774                continue;
775            }
776            $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'] - $spread;
777            $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'] - $spread;
778            $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
779                + $geo->borderLeft + $geo->borderRight + 2 * $spread;
780            $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
781                + $geo->borderTop + $geo->borderBottom + 2 * $spread;
782            $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
783        }
784    }
785
786    /**
787     * Paint an inset box-shadow per CSS Backgrounds 3 §6. The shadow
788     * paints INSIDE the padding-box edge (not outside the border-box
789     * like the default outset case). Offsets are inverted in effect:
790     * a positive `offsetX` makes the shadow visible at the *left*
791     * edge of the inside (the shadow "comes from" the +X direction).
792     * Positive `spread` makes the visible inner shadow band thicker
793     * by shrinking the unshaded inner rect.
794     *
795     * Implementation: paint the padding-box outer rect plus the
796     * computed inner rect as two subpaths, then fill with the
797     * even-odd rule (`f*`) so PDF leaves the inner rect transparent
798     * and fills only the frame between them with the shadow colour.
799     *
800     * @param array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool} $shadow
801     */
802    private function paintInsetShadow(\Phpdftk\HtmlToPdf\Layout\BoxGeometry $geo, array $shadow, Color $color, ContentStream $stream): void
803    {
804        // Padding-box edge (one step inside the border edge).
805        $padX = $geo->x - $geo->paddingLeft;
806        $padTop = $geo->y - $geo->paddingTop;
807        $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight;
808        $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom;
809        if ($padWidth <= 0.0 || $padHeight <= 0.0) {
810            return;
811        }
812        $spread = $shadow['spread'];
813        // Inner unshaded rect — inset from the padding-box by the
814        // offset on the corresponding side, then further by spread on
815        // every side. CSS 2 §6: a positive +X offset moves the shadow
816        // toward +X (visible at the OPPOSITE edge — the left), so the
817        // inner rect's left edge advances by offsetX.
818        $innerX = $padX + max(0.0, $shadow['offsetX']) + $spread;
819        $innerTop = $padTop + max(0.0, $shadow['offsetY']) + $spread;
820        $innerRight = $padX + $padWidth + min(0.0, $shadow['offsetX']) - $spread;
821        $innerBottom = $padTop + $padHeight + min(0.0, $shadow['offsetY']) - $spread;
822        $innerWidth = $innerRight - $innerX;
823        $innerHeight = $innerBottom - $innerTop;
824        if ($innerWidth <= 0.0 || $innerHeight <= 0.0) {
825            // Spread+offset consumes the whole padding box — fill it
826            // solid with the shadow colour.
827            $this->emitRect($stream, $padX, $padTop, $padWidth, $padHeight, fill: $color);
828            return;
829        }
830        // PDF Y axis is inverted vs layout. Flip both rects.
831        $padPdfY = $this->pageHeight - $padTop - $padHeight;
832        $innerPdfY = $this->pageHeight - $innerTop - $innerHeight;
833        $stream->saveGraphicsState();
834        $stream->setFillColorRGB($color->r, $color->g, $color->b);
835        $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight);
836        $stream->rectangle($innerX, $innerPdfY, $innerWidth, $innerHeight);
837        $stream->fillEvenOdd();
838        $stream->restoreGraphicsState();
839    }
840
841    /**
842     * Parse the value list(s) into per-shadow layer arrays.
843     *
844     * @return list<array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}>
845     */
846    private function collectShadowLayers(\Phpdftk\Css\Value\Value $value): array
847    {
848        if ($value instanceof \Phpdftk\Css\Value\ValueList
849            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
850        ) {
851            $layers = [];
852            foreach ($value->values as $item) {
853                $parsed = $this->parseShadowLayer($item);
854                if ($parsed !== null) {
855                    $layers[] = $parsed;
856                }
857            }
858            return $layers;
859        }
860        $single = $this->parseShadowLayer($value);
861        return $single === null ? [] : [$single];
862    }
863
864    /**
865     * @return array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}|null
866     */
867    private function parseShadowLayer(\Phpdftk\Css\Value\Value $value): ?array
868    {
869        $components = $value instanceof \Phpdftk\Css\Value\ValueList
870            ? $value->values
871            : [$value];
872
873        $inset = false;
874        $color = null;
875        $lengths = [];
876        foreach ($components as $c) {
877            if ($c instanceof Keyword && strtolower($c->name) === 'inset') {
878                $inset = true;
879                continue;
880            }
881            if ($c instanceof Color) {
882                $color = $c;
883                continue;
884            }
885            if ($c instanceof \Phpdftk\Css\Value\Length) {
886                $lengths[] = $c->value;
887                continue;
888            }
889            // CSS Values 4 §6.2: a unitless `0` is a valid zero-length
890            // wherever a length is expected. Accept Integer/Number
891            // values as zero (and treat non-zero numerics as 0 — the
892            // grammar requires a unit otherwise).
893            if ($c instanceof \Phpdftk\Css\Value\Integer
894                || $c instanceof \Phpdftk\Css\Value\Number
895            ) {
896                $lengths[] = (float) $c->value;
897            }
898        }
899        if (count($lengths) < 2) {
900            return null;
901        }
902        return [
903            'offsetX' => $lengths[0],
904            'offsetY' => $lengths[1],
905            'blur' => $lengths[2] ?? 0.0,
906            'spread' => $lengths[3] ?? 0.0,
907            'color' => $color,
908            'inset' => $inset,
909        ];
910    }
911
912    /**
913     * Resolve the box's cascaded `opacity`. Returns the page-level
914     * `ExtGState` resource name to invoke for partial opacity, or null
915     * when opacity is full (1.0) or the painter wasn't given a Page
916     * reference. Opacity affects this box plus every descendant since
917     * the `gs` operator persists until the matching `Q`.
918     */
919    private function resolveOpacityGsName(Box $box): ?string
920    {
921        if ($this->page === null) {
922            return null;
923        }
924        $value = $box->style->get('opacity');
925        $alpha = match (true) {
926            $value instanceof \Phpdftk\Css\Value\Number => $value->value,
927            $value instanceof \Phpdftk\Css\Value\Integer => (float) $value->value,
928            default => 1.0,
929        };
930        $alpha = max(0.0, min(1.0, $alpha));
931        if ($alpha >= 0.999) {
932            return null;
933        }
934        return $this->page->ensureOpacityState($alpha, $alpha);
935    }
936
937    /**
938     * Paint a CSS Lists 3 list marker (the `::marker` pseudo) for boxes
939     * with `display: list-item`, honouring `list-style-type` for the
940     * three geometric markers (`disc` / `circle` / `square`). Counter-
941     * style markers (`decimal`, `lower-alpha`, etc.) require font
942     * rendering of the running counter and live in Phase 2; they fall
943     * through to a `disc` (filled circle) here. Marker colour follows
944     * the cascaded `color`.
945     */
946    private function paintListMarker(Box $box, ContentStream $stream): void
947    {
948        $display = $box->style->get('display');
949        if (!$display instanceof Keyword || strtolower($display->name) !== 'list-item') {
950            return;
951        }
952        $typeValue = $box->style->get('list-style-type');
953        $type = $typeValue instanceof Keyword ? strtolower($typeValue->name) : 'disc';
954        if ($type === 'none') {
955            return;
956        }
957
958        $color = $box->style->get('color');
959        $markerColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
960        $fontSize = $this->dominantFontSize($box);
961
962        // Counter-style markers — formatted text, requires a registered font.
963        $counterText = $this->formatCounterMarker($box, $type);
964        if ($counterText !== null && $this->defaultFont !== null) {
965            $this->paintCounterMarker($box, $stream, $markerColor, $fontSize, $counterText);
966            return;
967        }
968
969        $size = max(2.0, $fontSize / 3.0);
970        $x = $box->geometry->x - max(6.0, $fontSize * 0.5);
971        $layoutY = $box->geometry->y + $fontSize * 0.35;
972        $pdfY = $this->pageHeight - $layoutY - $size;
973
974        $stream->saveGraphicsState();
975        $stream->setFillColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
976        $stream->setStrokeColorRGB($markerColor->r, $markerColor->g, $markerColor->b);
977        match ($type) {
978            'circle' => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: false),
979            'square' => $this->paintMarkerSquare($stream, $x, $pdfY, $size),
980            default => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: true),
981        };
982        $stream->restoreGraphicsState();
983    }
984
985    /**
986     * Format the marker text for counter-style list-style-types. Returns
987     * null for geometric / unknown / `none` types (caller paints the
988     * geometric stand-in or skips).
989     */
990    private function formatCounterMarker(Box $box, string $type): ?string
991    {
992        $index = $this->listItemIndex($box);
993        if ($index < 1) {
994            return null;
995        }
996        $supported = [
997            'decimal', 'decimal-leading-zero',
998            'lower-alpha', 'lower-latin', 'upper-alpha', 'upper-latin',
999            'lower-roman', 'upper-roman',
1000        ];
1001        if (!in_array(strtolower($type), $supported, true)) {
1002            return null;
1003        }
1004        return \Phpdftk\HtmlToPdf\Layout\CounterFormat::format($index, $type) . '.';
1005    }
1006
1007    /**
1008     * Compute the 1-based index of `$box` among its `<li>` siblings by
1009     * walking the originating Element's previousSibling chain. Returns
1010     * 0 when `$box` isn't bound to a DOM element (e.g. anonymous).
1011     */
1012    private function listItemIndex(Box $box): int
1013    {
1014        if ($box->element === null) {
1015            return 0;
1016        }
1017        $thisLi = $box->element;
1018        // HTML 5 §4.4.5.2: `<li value="N">` sets the explicit ordinal — and
1019        // also resets the count for following siblings. Walk left-to-right
1020        // from the parent's first child until we hit `$thisLi`; bumping on
1021        // each `<li>` and snapping to `value` whenever a sibling provides
1022        // it.
1023        $parent = $thisLi->parentNode;
1024        if (!$parent instanceof \Phpdftk\Html\Dom\Element) {
1025            return 1;
1026        }
1027        // HTML 5 §4.4.5.3: `<ol start="N">` sets the starting count.
1028        // `<ol reversed>` counts down instead.
1029        $start = 1;
1030        $reversed = false;
1031        if (strtolower($parent->localName) === 'ol') {
1032            $rawStart = $parent->getAttribute('start');
1033            if ($rawStart !== null && preg_match('/^-?\d+$/', trim($rawStart)) === 1) {
1034                $start = (int) trim($rawStart);
1035            }
1036            $reversed = $parent->getAttribute('reversed') !== null;
1037        }
1038        if ($reversed) {
1039            // Count `<li>` siblings to derive the reversed initial value.
1040            $liCount = 0;
1041            for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) {
1042                if ($n instanceof \Phpdftk\Html\Dom\Element
1043                    && strtolower($n->localName) === 'li'
1044                ) {
1045                    $liCount++;
1046                }
1047            }
1048            $count = $start === 1 ? $liCount + 1 : $start + 1;
1049            $step = -1;
1050        } else {
1051            $count = $start - 1;
1052            $step = 1;
1053        }
1054        for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) {
1055            if (!($n instanceof \Phpdftk\Html\Dom\Element)
1056                || strtolower($n->localName) !== 'li'
1057            ) {
1058                continue;
1059            }
1060            $raw = $n->getAttribute('value');
1061            if ($raw !== null && preg_match('/^-?\d+$/', trim($raw)) === 1) {
1062                $count = (int) trim($raw);
1063            } else {
1064                $count += $step;
1065            }
1066            if ($n === $thisLi) {
1067                return $count;
1068            }
1069        }
1070        return $start;
1071    }
1072
1073    /**
1074     * Paint a counter-style marker — shapes the text against the
1075     * registered font and emits a Tj at the marker position.
1076     */
1077    private function paintCounterMarker(
1078        Box $box,
1079        ContentStream $stream,
1080        Color $color,
1081        float $fontSize,
1082        string $text,
1083    ): void {
1084        $font = $this->defaultFont;
1085        if (!$font instanceof WriterFont) {
1086            return;
1087        }
1088        $otd = $font->getParsedData();
1089        if (!$otd instanceof \Phpdftk\FontParser\OpenTypeData) {
1090            return;
1091        }
1092        $shaper = new \Phpdftk\Text\Shaper();
1093        $shapedRun = $shaper->shapeRun(
1094            $text,
1095            new \Phpdftk\Text\ShapingContext($otd, $fontSize),
1096        );
1097        if ($shapedRun->glyphs === []) {
1098            return;
1099        }
1100        $ascent = ($otd->ascent / max(1, $otd->unitsPerEm)) * $fontSize;
1101        // Right-align the marker so it sits just to the left of the box content.
1102        $width = $shapedRun->totalAdvance;
1103        $x = $box->geometry->x - $width - max(2.0, $fontSize * 0.2);
1104        $baselineY = $box->geometry->y + $ascent;
1105        $pdfY = $this->pageHeight - $baselineY;
1106
1107        $hex = '';
1108        $gidMap = $font->getOldToNewGidMap();
1109        foreach ($shapedRun->glyphs as $g) {
1110            $hex .= sprintf('%04X', $gidMap[$g->glyphId] ?? $g->glyphId);
1111        }
1112
1113        $stream->saveGraphicsState();
1114        $stream->setFillColorRGB($color->r, $color->g, $color->b);
1115        $stream->beginText();
1116        $stream->setFont($font, $fontSize);
1117        $stream->setTextMatrix(1, 0, 0, 1, $x, $pdfY);
1118        $stream->showTextHex($hex);
1119        $stream->endText();
1120        $stream->restoreGraphicsState();
1121    }
1122
1123    private function paintMarkerSquare(ContentStream $stream, float $x, float $y, float $size): void
1124    {
1125        $stream->rectangle($x, $y, $size, $size);
1126        $stream->fill();
1127    }
1128
1129    /**
1130     * Approximate a circle inside the bounding box (x, y, size, size) with
1131     * four cubic Bézier curves. The classic offset constant for unit-radius
1132     * approximation is `0.5522847498` — keeps the curve within ≈ 0.027% of
1133     * the true circle, more than enough at marker scale.
1134     */
1135    private function paintMarkerCircle(
1136        ContentStream $stream,
1137        float $x,
1138        float $y,
1139        float $size,
1140        bool $fill,
1141    ): void {
1142        $r = $size / 2.0;
1143        $cx = $x + $r;
1144        $cy = $y + $r;
1145        $k = $r * 0.5522847498307933;
1146        $stream->moveTo($cx + $r, $cy);
1147        $stream->curveTo($cx + $r, $cy + $k, $cx + $k, $cy + $r, $cx, $cy + $r);
1148        $stream->curveTo($cx - $k, $cy + $r, $cx - $r, $cy + $k, $cx - $r, $cy);
1149        $stream->curveTo($cx - $r, $cy - $k, $cx - $k, $cy - $r, $cx, $cy - $r);
1150        $stream->curveTo($cx + $k, $cy - $r, $cx + $r, $cy - $k, $cx + $r, $cy);
1151        $stream->closePath();
1152        if ($fill) {
1153            $stream->fill();
1154        } else {
1155            $stream->setLineWidth(max(0.4, $r / 6.0));
1156            $stream->stroke();
1157        }
1158    }
1159
1160    private function dominantFontSize(Box $box): float
1161    {
1162        $value = $box->style->get('font-size');
1163        if ($value instanceof \Phpdftk\Css\Value\Length) {
1164            return $value->value;
1165        }
1166        return 12.0;
1167    }
1168
1169    /**
1170     * Emit glyphs for every {@see InlineFragment} in this box's line boxes.
1171     * Requires a {@see RegisteredFont} on the painter — without one, text
1172     * painting is a no-op so block + border content still renders.
1173     *
1174     * Coordinates: layout space is top-down; PDF text positioning is
1175     * baseline-relative in bottom-up space. The baseline sits at
1176     * `lineBox.y + ascent` where ascent = (font.ascent / unitsPerEm) ×
1177     * fontSize. The painter converts to PDF Y by subtracting from
1178     * `$this->pageHeight`.
1179     */
1180    private function paintLineBoxes(Box $box, ContentStream $stream): void
1181    {
1182        if ($box->lineBoxes === [] || $this->defaultFont === null) {
1183            return;
1184        }
1185        $color = $box->style->get('color');
1186        $textColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1);
1187
1188        // Inline backgrounds (`<mark>` and friends) paint as a strip behind
1189        // each fragment that carries a `backgroundColor`. Goes before the
1190        // text + shadow passes so the glyphs sit on top.
1191        foreach ($box->lineBoxes as $line) {
1192            $this->paintInlineBackgrounds($box, $line, $stream);
1193        }
1194
1195        $shadows = $this->collectTextShadowLayers($box, $textColor);
1196        foreach ($box->lineBoxes as $line) {
1197            // Paint shadow layers behind the real text. CSS Text Decoration 4
1198            // §6 says the first listed shadow is painted on top, so we
1199            // reverse the list for the back-to-front emission order.
1200            foreach (array_reverse($shadows) as $shadow) {
1201                $this->paintLine(
1202                    $box,
1203                    $line,
1204                    $stream,
1205                    $shadow['color'],
1206                    $shadow['offsetX'],
1207                    $shadow['offsetY'],
1208                );
1209            }
1210            $this->paintLine($box, $line, $stream, $textColor);
1211            $this->paintTextDecorations($box, $line, $stream, $textColor);
1212        }
1213    }
1214
1215    /**
1216     * Paint per-fragment inline background rectangles. Each fragment that
1217     * carries a `backgroundColor` (propagated from an inline element like
1218     * `<mark>` whose cascade sets `background-color`) gets a filled rect
1219     * spanning the fragment's width and the line's height. Adjacent
1220     * fragments with the same colour are merged so we emit one wider rect
1221     * per run of same-colour fragments — cheaper output without sub-pixel
1222     * gaps from neighbouring fills.
1223     */
1224    private function paintInlineBackgrounds(Box $box, LineBox $line, ContentStream $stream): void
1225    {
1226        if ($line->fragments === []) {
1227            return;
1228        }
1229        // Coalesce contiguous fragments that share a background colour into
1230        // single rects so we emit cheaper output without sub-pixel gaps.
1231        /** @var list<array{x: float, width: float, color: Color}> $runs */
1232        $runs = [];
1233        foreach ($line->fragments as $fragment) {
1234            $bg = $fragment->backgroundColor;
1235            if ($bg === null) {
1236                continue;
1237            }
1238            $last = $runs === [] ? null : $runs[array_key_last($runs)];
1239            $sameAsLast = $last !== null
1240                && abs($last['x'] + $last['width'] - $fragment->x) < 0.001
1241                && $last['color']->r === $bg->r
1242                && $last['color']->g === $bg->g
1243                && $last['color']->b === $bg->b;
1244            if ($sameAsLast) {
1245                $runs[array_key_last($runs)]['width'] = ($fragment->x + $fragment->width) - $last['x'];
1246            } else {
1247                $runs[] = [
1248                    'x' => $fragment->x,
1249                    'width' => $fragment->width,
1250                    'color' => $bg,
1251                ];
1252            }
1253        }
1254        if ($runs === []) {
1255            return;
1256        }
1257        $pdfY = $this->pageHeight - ($box->geometry->y + $line->y + $line->height);
1258        foreach ($runs as $run) {
1259            $stream->saveGraphicsState();
1260            $stream->setFillColorRGB($run['color']->r, $run['color']->g, $run['color']->b);
1261            $stream->rectangle($box->geometry->x + $run['x'], $pdfY, $run['width'], $line->height);
1262            $stream->fill();
1263            $stream->restoreGraphicsState();
1264        }
1265    }
1266
1267    /**
1268     * Parse the cascaded `text-shadow` value into layer entries. Returns
1269     * an empty array when text-shadow is `none` or absent.
1270     *
1271     * @return list<array{offsetX: float, offsetY: float, color: Color}>
1272     */
1273    private function collectTextShadowLayers(Box $box, Color $fallback): array
1274    {
1275        $value = $box->style->get('text-shadow');
1276        if ($value === null
1277            || ($value instanceof Keyword && strtolower($value->name) === 'none')
1278        ) {
1279            return [];
1280        }
1281        $layers = [];
1282        $items = $value instanceof \Phpdftk\Css\Value\ValueList
1283            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
1284            ? $value->values
1285            : [$value];
1286        foreach ($items as $item) {
1287            $components = $item instanceof \Phpdftk\Css\Value\ValueList ? $item->values : [$item];
1288            $lengths = [];
1289            $color = null;
1290            foreach ($components as $c) {
1291                if ($c instanceof \Phpdftk\Css\Value\Length) {
1292                    $lengths[] = $c->value;
1293                } elseif ($c instanceof Color) {
1294                    $color = $c;
1295                }
1296            }
1297            if (count($lengths) < 2) {
1298                continue;
1299            }
1300            $layers[] = [
1301                'offsetX' => $lengths[0],
1302                'offsetY' => $lengths[1],
1303                'color' => $color ?? $fallback,
1304            ];
1305        }
1306        return $layers;
1307    }
1308
1309    private function paintLine(
1310        Box $box,
1311        LineBox $line,
1312        ContentStream $stream,
1313        Color $color,
1314        float $offsetX = 0.0,
1315        float $offsetY = 0.0,
1316    ): void {
1317        if ($line->fragments === []) {
1318            return;
1319        }
1320        $stream->saveGraphicsState();
1321        $stream->setFillColorRGB($color->r, $color->g, $color->b);
1322        $stream->beginText();
1323        $activeColor = $color;
1324        foreach ($line->fragments as $fragment) {
1325            // Inline-level `color` override: when the fragment carries its
1326            // own colour (typically `<a>` inheriting blue from the UA
1327            // stylesheet inside a black `<p>`), reseat the fill colour.
1328            $fragColor = $fragment->textColor ?? $color;
1329            if ($fragColor !== $activeColor) {
1330                $stream->setFillColorRGB($fragColor->r, $fragColor->g, $fragColor->b);
1331                $activeColor = $fragColor;
1332            }
1333            $this->paintFragment($box, $line, $fragment, $stream, $fragColor, $offsetX, $offsetY);
1334        }
1335        // Reset rendering mode in case the last fragment left it set.
1336        $stream->setTextRenderingMode(0);
1337        $stream->endText();
1338        $stream->restoreGraphicsState();
1339    }
1340
1341    /**
1342     * Paint text-decoration lines (underline / overline / line-through) for
1343     * every fragment whose parent style sets `text-decoration-line` to a
1344     * non-`none` value.
1345     *
1346     * Position approximation per CSS Text Decoration 3 §3:
1347     *  - underline:   baseline + 0.15 × fontSize
1348     *  - overline:    baseline − ascent (top of em box)
1349     *  - line-through: baseline − 0.3 × fontSize (~x-height middle)
1350     * Thickness: `fontSize / 14`.
1351     *
1352     * The fallback approximations stand in until `phpdftk/font-parser`
1353     * exposes the OS/2 sTypoUnderlinePosition / underlineThickness fields.
1354     */
1355    private function paintTextDecorations(Box $box, LineBox $line, ContentStream $stream, Color $color): void
1356    {
1357        $blockLines = $this->textDecorationLines($box);
1358        $decoColor = $this->textDecorationColor($box, $color);
1359        foreach ($line->fragments as $fragment) {
1360            // CSS Text Decoration 4 §2: a fragment's effective decoration
1361            // is the union of inherited (from inline ancestors) + block-
1362            // level lines. Block-level wins for color since the value
1363            // doesn't inherit through inlines.
1364            $lines = array_values(array_unique(array_merge($blockLines, $fragment->decorationLines)));
1365            if ($lines === []) {
1366                continue;
1367            }
1368            $shapedRun = $fragment->shapedRun;
1369            if ($shapedRun->glyphs === [] && $fragment->width <= 0.0) {
1370                continue;
1371            }
1372            $fontSize = $shapedRun->fontSizePt;
1373            $font = $shapedRun->font;
1374            $unitsPerEm = max(1, $font->unitsPerEm);
1375            $ascent = ($font->ascent / $unitsPerEm) * $fontSize;
1376            // Real OS/2 underline metrics when available; fall back to the
1377            // 1G.3 approximation otherwise.
1378            $underlineOffset = $font->underlinePosition !== null
1379                ? -($font->underlinePosition / $unitsPerEm) * $fontSize
1380                : 0.15 * $fontSize;
1381            $thickness = $font->underlineThickness !== null
1382                ? max(0.5, ($font->underlineThickness / $unitsPerEm) * $fontSize)
1383                : max(0.5, $fontSize / 14.0);
1384            // CSS Text Decoration 4 §4 — `text-decoration-thickness`
1385            // explicit Length / Percentage overrides the font metric.
1386            // `auto` defers to the metric above.
1387            $explicitThickness = $this->resolveDecorationThickness($box, $fontSize);
1388            if ($explicitThickness !== null) {
1389                $thickness = max(0.5, $explicitThickness);
1390            }
1391            // `text-underline-offset` shifts the underline ONLY (not
1392            // overline or line-through). Positive values push the line
1393            // further below the baseline.
1394            $explicitUnderlineOffset = $this->resolveUnderlineOffset($box, $fontSize);
1395            $x = $box->geometry->x + $fragment->x;
1396            $width = $fragment->width;
1397            $baselineY = $box->geometry->y + $line->y + $ascent;
1398            $style = $this->textDecorationStyle($box);
1399            // Per CSS Text Decoration 4 §3, the decoration colour follows
1400            // the *originating* element's `text-decoration-color` (when
1401            // explicitly set) — fall back to the fragment's `color` (so an
1402            // inline `<a>` with cascaded `color: blue` paints a blue
1403            // underline) and finally to the block-level value resolved at
1404            // the outer paint context.
1405            $effectiveColor = $fragment->decorationColor
1406                ?? $fragment->textColor
1407                ?? $decoColor;
1408            foreach ($lines as $lineKind) {
1409                $offsetY = match ($lineKind) {
1410                    'underline' => $underlineOffset + ($explicitUnderlineOffset ?? 0.0),
1411                    'overline' => -$ascent,
1412                    'line-through' => -0.3 * $fontSize,
1413                    default => 0.0,
1414                };
1415                $layoutY = $baselineY + $offsetY;
1416                $pdfY = $this->pageHeight - $layoutY - $thickness;
1417                $this->emitDecorationStyled($stream, $x, $pdfY, $width, $thickness, $effectiveColor, $style);
1418            }
1419        }
1420    }
1421
1422    /**
1423     * Emit one text-decoration line in the given style. `solid` is one
1424     * rect; `double` is two parallel rects with a small gap; `dashed`
1425     * and `dotted` emit a series of segment rects; `wavy` strokes a
1426     * cubic-Bezier-approximated sine wave at the decoration position.
1427     */
1428    private function emitDecorationStyled(
1429        ContentStream $stream,
1430        float $x,
1431        float $pdfY,
1432        float $width,
1433        float $thickness,
1434        Color $color,
1435        string $style,
1436    ): void {
1437        if ($style === 'wavy') {
1438            $this->emitWavyDecoration($stream, $x, $pdfY, $width, $thickness, $color);
1439            return;
1440        }
1441        $stream->saveGraphicsState();
1442        $stream->setFillColorRGB($color->r, $color->g, $color->b);
1443        switch ($style) {
1444            case 'double':
1445                $gap = max(0.5, $thickness);
1446                $stream->rectangle($x, $pdfY, $width, $thickness);
1447                $stream->rectangle($x, $pdfY - $gap - $thickness, $width, $thickness);
1448                $stream->fill();
1449                break;
1450            case 'dashed':
1451                $segment = max(2.0, $thickness * 3);
1452                $gap = max(1.5, $thickness * 2);
1453                for ($cx = $x; $cx < $x + $width; $cx += $segment + $gap) {
1454                    $w = min($segment, $x + $width - $cx);
1455                    $stream->rectangle($cx, $pdfY, $w, $thickness);
1456                }
1457                $stream->fill();
1458                break;
1459            case 'dotted':
1460                $dotSize = max(1.0, $thickness);
1461                $gap = $dotSize * 1.2;
1462                for ($cx = $x; $cx < $x + $width; $cx += $dotSize + $gap) {
1463                    $w = min($dotSize, $x + $width - $cx);
1464                    $stream->rectangle($cx, $pdfY, $w, $thickness);
1465                }
1466                $stream->fill();
1467                break;
1468            default: // solid
1469                $stream->rectangle($x, $pdfY, $width, $thickness);
1470                $stream->fill();
1471        }
1472        $stream->restoreGraphicsState();
1473    }
1474
1475    /**
1476     * Stroke a sine-wave-shaped text decoration line, approximated by
1477     * cubic Bezier curves. Two Beziers per period (one half-cycle up,
1478     * one half-cycle down). The wave's period is `thickness × 6` and
1479     * amplitude is `thickness × 0.7` — these tune the look to match
1480     * the wavy spell-check underlines that browsers render.
1481     */
1482    private function emitWavyDecoration(
1483        ContentStream $stream,
1484        float $x,
1485        float $pdfY,
1486        float $width,
1487        float $thickness,
1488        Color $color,
1489    ): void {
1490        if ($width <= 0.0 || $thickness <= 0.0) {
1491            return;
1492        }
1493        $period = max(4.0, $thickness * 6.0);
1494        $amp = max(1.0, $thickness * 0.7);
1495        $strokeWidth = max(0.5, $thickness * 0.7);
1496        $stream->saveGraphicsState();
1497        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
1498        $stream->setLineWidth($strokeWidth);
1499        // Centerline of the wave sits at $pdfY + thickness/2 so the
1500        // visible band stays within the decoration's allocated band.
1501        $centerY = $pdfY + $thickness / 2.0;
1502        $stream->moveTo($x, $centerY);
1503        $halfPeriod = $period / 2.0;
1504        // Bezier control offset for a sine half-cycle (well-known
1505        // approximation: control points at 1/3 and 2/3 of the half).
1506        $cx1Offset = $halfPeriod / 3.0;
1507        $cx2Offset = ($halfPeriod * 2.0) / 3.0;
1508        $end = $x + $width;
1509        $curX = $x;
1510        $up = true;
1511        while ($curX < $end) {
1512            $segmentEnd = min($curX + $halfPeriod, $end);
1513            $controlY = $up ? $centerY + $amp : $centerY - $amp;
1514            $stream->curveTo(
1515                $curX + $cx1Offset,
1516                $controlY,
1517                $curX + $cx2Offset,
1518                $controlY,
1519                $segmentEnd,
1520                $centerY,
1521            );
1522            $curX = $segmentEnd;
1523            $up = !$up;
1524        }
1525        $stream->stroke();
1526        $stream->restoreGraphicsState();
1527    }
1528
1529    /**
1530     * Resolve CSS Text Decoration 4 §4 `text-decoration-thickness`.
1531     * Returns the resolved pixel value when an explicit Length or
1532     * Percentage is set (percentage is relative to the font size per
1533     * CSS UI 4 §6); returns null when the value is `auto` so the font
1534     * metric stays in effect.
1535     */
1536    private function resolveDecorationThickness(Box $box, float $fontSize): ?float
1537    {
1538        $value = $box->style->get('text-decoration-thickness');
1539        if ($value instanceof \Phpdftk\Css\Value\Length) {
1540            return $value->value;
1541        }
1542        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1543            return $value->value / 100.0 * $fontSize;
1544        }
1545        return null;
1546    }
1547
1548    /**
1549     * Resolve CSS Text Decoration 4 §4.2 `text-underline-offset`.
1550     * Positive values push the underline further below the baseline;
1551     * `auto` (null) defers to the font-metric default.
1552     */
1553    private function resolveUnderlineOffset(Box $box, float $fontSize): ?float
1554    {
1555        $value = $box->style->get('text-underline-offset');
1556        if ($value instanceof \Phpdftk\Css\Value\Length) {
1557            return $value->value;
1558        }
1559        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1560            return $value->value / 100.0 * $fontSize;
1561        }
1562        return null;
1563    }
1564
1565    private function textDecorationStyle(Box $box): string
1566    {
1567        $value = $box->style->get('text-decoration-style');
1568        if ($value instanceof Keyword) {
1569            $name = strtolower($value->name);
1570            if (in_array($name, ['solid', 'double', 'dashed', 'dotted', 'wavy'], true)) {
1571                return $name;
1572            }
1573        }
1574        return 'solid';
1575    }
1576
1577    /** @return list<string> */
1578    private function textDecorationLines(Box $box): array
1579    {
1580        $value = $box->style->get('text-decoration-line');
1581        if ($value === null) {
1582            return [];
1583        }
1584        $items = $value instanceof \Phpdftk\Css\Value\ValueList ? $value->values : [$value];
1585        $out = [];
1586        foreach ($items as $item) {
1587            if (!$item instanceof Keyword) {
1588                continue;
1589            }
1590            $lower = strtolower($item->name);
1591            if ($lower === 'none' || $lower === 'blink') {
1592                continue;
1593            }
1594            if (in_array($lower, ['underline', 'overline', 'line-through'], true)) {
1595                $out[] = $lower;
1596            }
1597        }
1598        return $out;
1599    }
1600
1601    private function textDecorationColor(Box $box, Color $fallback): Color
1602    {
1603        $value = $box->style->get('text-decoration-color');
1604        return $value instanceof Color ? $value : $fallback;
1605    }
1606
1607    private function paintFragment(
1608        Box $box,
1609        LineBox $line,
1610        InlineFragment $fragment,
1611        ContentStream $stream,
1612        Color $color,
1613        float $offsetX = 0.0,
1614        float $offsetY = 0.0,
1615    ): void {
1616        $shapedRun = $fragment->shapedRun;
1617        if ($shapedRun->glyphs === []) {
1618            return;
1619        }
1620        $font = $shapedRun->font;
1621        $ascent = ($font->ascent / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt;
1622        $x = $box->geometry->x + $fragment->x + $offsetX;
1623        // CSS Inline 3 §4.5 vertical-align — `baselineShift` is negative
1624        // for `super`, positive for `sub`. Add it in layout-Y space, then
1625        // flip to PDF-Y.
1626        $baselineY = $box->geometry->y + $line->y + $ascent + $offsetY + $fragment->baselineShift;
1627        $pdfY = $this->pageHeight - $baselineY;
1628
1629        // Pick the RegisteredFont matching this fragment's shaped font.
1630        // The map is keyed by OpenType postScriptName; the shaping context
1631        // chose the font, so this lookup just hands the painter the right
1632        // PDF Tf resource. Falls back to defaultFont when the fragment's
1633        // font wasn't registered (e.g., the resolver returned defaultFont
1634        // and there's no alt map).
1635        $registered = $this->registeredFonts[$font->postScriptName] ?? $this->defaultFont;
1636        $stream->setFont($registered ?? $font->postScriptName, $shapedRun->fontSizePt);
1637        // Fake-italic via a 12° skew in the Tm `c` slot (≈ tan(12°) = 0.213).
1638        // CSS Fonts 4 §6.4.1 lets browsers synthesise oblique from regular
1639        // when no real italic face is registered; this is the same trick.
1640        $skew = $fragment->isItalic ? 0.213 : 0.0;
1641        // Tm reseats the text matrix at each fragment's left baseline, which
1642        // is simpler than tracking incremental Td offsets between fragments.
1643        $stream->setTextMatrix(1, 0, $skew, 1, $x, $pdfY);
1644        // Fake-bold via text rendering mode 2 (fill + stroke). Stroke
1645        // contributes ≈ fontSize × 0.04 of extra thickness — visually close
1646        // to the design-weight increment for bold. Match the stroke color
1647        // to the cascaded fill color so the bold outline doesn't bleed in a
1648        // different hue. Always re-emit the Tr so a non-bold fragment that
1649        // follows a bold one resets to fill-only.
1650        if ($fragment->isBold) {
1651            $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
1652            $stream->setLineWidth($shapedRun->fontSizePt * 0.04);
1653            $stream->setTextRenderingMode(2);
1654        } else {
1655            $stream->setTextRenderingMode(0);
1656        }
1657
1658        // Per-font GID translation: CFF subsetting renumbers glyphs in the
1659        // embedded font, so the shaper's full-font GIDs must be mapped to
1660        // the subset GIDs before emission. Each registered font has its
1661        // own gid map.
1662        $gidMap = $registered instanceof WriterFont
1663            ? $registered->getOldToNewGidMap()
1664            : [];
1665        $unitsPerEm = max(1, $font->unitsPerEm);
1666        $fontSize = $shapedRun->fontSizePt;
1667
1668        // Build a TJ array if the shaper's advances diverge from the font's
1669        // natural hmtx widths — that gap is the kern adjustment to encode.
1670        // Otherwise emit a plain Tj for the whole run.
1671        $items = [];
1672        $hex = '';
1673        $hasKern = false;
1674        foreach ($shapedRun->glyphs as $g) {
1675            $emitted = $gidMap[$g->glyphId] ?? $g->glyphId;
1676            $hex .= sprintf('%04X', $emitted);
1677
1678            $natural = ($font->glyphWidths[$g->glyphId] ?? 0) / $unitsPerEm * $fontSize;
1679            $delta = $natural - $g->advanceX; // positive = shaper pulled the glyph in (kern)
1680            $kern = $delta * 1000.0 / $fontSize;
1681            if (abs($kern) >= 0.5) {
1682                $items[] = $hex;
1683                $items[] = $this->snapKern($kern);
1684                $hex = '';
1685                $hasKern = true;
1686            }
1687        }
1688        if ($hex !== '') {
1689            $items[] = $hex;
1690        }
1691
1692        if ($hasKern) {
1693            $stream->showTextArrayHex($items);
1694        } else {
1695            $stream->showTextHex(implode('', array_filter($items, 'is_string')));
1696        }
1697
1698        // Inline `<a href>` — record the fragment's rect for /Link emission.
1699        // We only collect on the "real text" pass (offsetX / offsetY == 0)
1700        // so multi-layer text-shadow doesn't multiply the link count.
1701        if ($fragment->href !== null && $offsetX === 0.0 && $offsetY === 0.0) {
1702            $descent = abs($font->descent) / max(1, $unitsPerEm) * $fontSize;
1703            $this->collectedLinks[] = [
1704                'href' => $fragment->href,
1705                'llx' => $x,
1706                'lly' => $pdfY - $descent,
1707                'urx' => $x + $fragment->width,
1708                'ury' => $pdfY + $ascent,
1709                'title' => $fragment->linkTitle,
1710            ];
1711        }
1712    }
1713
1714    /**
1715     * Block-level `<a href>` — emit a single link rect covering the box's
1716     * border box. Inline `<a>` is handled inside {@see paintFragment()}.
1717     */
1718    private function collectBlockLinkRect(Box $box): void
1719    {
1720        if ($box->element === null
1721            || strtolower($box->element->localName) !== 'a'
1722        ) {
1723            return;
1724        }
1725        $href = $box->element->getAttribute('href');
1726        if ($href === null || $href === '') {
1727            return;
1728        }
1729        // Inline `<a>` already produces per-fragment rects via paintFragment;
1730        // skip when there's nothing to do at the block level.
1731        if (!($box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox)
1732            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AnonymousBlockBox)
1733            && !($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)
1734        ) {
1735            return;
1736        }
1737        $g = $box->geometry;
1738        if ($g->width <= 0.0 || $g->outerHeight() <= 0.0) {
1739            return;
1740        }
1741        $llx = $g->x;
1742        $urx = $g->x + $g->width;
1743        $ury = $this->pageHeight - $g->y;
1744        $lly = $this->pageHeight - ($g->y + $g->outerHeight());
1745        $this->collectedLinks[] = [
1746            'href' => $href,
1747            'llx' => $llx,
1748            'lly' => $lly,
1749            'urx' => $urx,
1750            'ury' => $ury,
1751            'title' => $box->element->getAttribute('title'),
1752        ];
1753    }
1754
1755    /**
1756     * Round to the nearest 0.1 unit so the emitted PDF stays compact.
1757     * PDF readers don't visually distinguish sub-tenth-unit kerns.
1758     */
1759    private function snapKern(float $kern): float|int
1760    {
1761        $rounded = round($kern, 1);
1762        return $rounded == (int) $rounded ? (int) $rounded : $rounded;
1763    }
1764
1765    private function paintBackground(Box $box, ContentStream $stream): void
1766    {
1767        // Inline-level backgrounds (InlineBox, TextBox, LineBreakBox) are
1768        // painted per-fragment by {@see paintInlineBackgrounds()}; their
1769        // own geometry is meaningless for block-style background painting
1770        // (layout doesn't size them as a single rect). AtomicInlineBox /
1771        // BlockBox / AnonymousBlockBox keep the block-style fill.
1772        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
1773            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
1774            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
1775        ) {
1776            return;
1777        }
1778        $color = $box->style->get('background-color');
1779        $bgImage = $box->style->get('background-image');
1780        $hasColor = $color instanceof Color && $color->a > 0.0;
1781        $hasImage = $bgImage instanceof \Phpdftk\Css\Value\Url;
1782        $hasGradient = $bgImage instanceof \Phpdftk\Css\Value\LinearGradient;
1783        $hasRadial = $bgImage instanceof \Phpdftk\Css\Value\RadialGradient;
1784        if (!$hasColor && !$hasImage && !$hasGradient && !$hasRadial) {
1785            return;
1786        }
1787        $geo = $box->geometry;
1788        // CSS Backgrounds 3 §3.5 — `background-clip` controls which
1789        // box edge the background paint extends to. `border-box`
1790        // (initial) reaches the outer border edge; `padding-box`
1791        // stops at the inner border edge; `content-box` stays inside
1792        // padding. We honour all three keywords.
1793        $clip = $this->resolveBackgroundClip($box);
1794        switch ($clip) {
1795            case 'content-box':
1796                $x = $geo->x;
1797                $top = $geo->y;
1798                $width = $geo->width;
1799                $height = $geo->height;
1800                break;
1801            case 'padding-box':
1802                $x = $geo->x - $geo->paddingLeft;
1803                $top = $geo->y - $geo->paddingTop;
1804                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight;
1805                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom;
1806                break;
1807            default: // 'border-box'
1808                $x = $geo->x - $geo->paddingLeft - $geo->borderLeft;
1809                $top = $geo->y - $geo->paddingTop - $geo->borderTop;
1810                $width = $geo->paddingLeft + $geo->width + $geo->paddingRight
1811                    + $geo->borderLeft + $geo->borderRight;
1812                $height = $geo->paddingTop + $geo->height + $geo->paddingBottom
1813                    + $geo->borderTop + $geo->borderBottom;
1814        }
1815        if ($hasColor) {
1816            $radii = $this->borderRadii($box);
1817            if (array_sum($radii) > 0.0) {
1818                $this->emitRoundedFill($stream, $x, $top, $width, $height, $radii, $color);
1819            } else {
1820                $this->emitRect($stream, $x, $top, $width, $height, fill: $color);
1821            }
1822        }
1823        if ($hasImage && $width > 0.0 && $height > 0.0) {
1824            $sizeValue = $box->style->get('background-size');
1825            $positionValue = $box->style->get('background-position');
1826            $repeatValue = $box->style->get('background-repeat');
1827            $this->paintBackgroundImage(
1828                $bgImage,
1829                $stream,
1830                $x,
1831                $top,
1832                $width,
1833                $height,
1834                $sizeValue,
1835                $positionValue,
1836                $repeatValue,
1837            );
1838        }
1839        if ($hasGradient && $width > 0.0 && $height > 0.0) {
1840            $this->paintLinearGradient($bgImage, $stream, $x, $top, $width, $height);
1841        }
1842        if ($hasRadial && $width > 0.0 && $height > 0.0) {
1843            $this->paintRadialGradient($bgImage, $stream, $x, $top, $width, $height);
1844        }
1845    }
1846
1847    /**
1848     * Paint a CSS `radial-gradient([<shape> <size>] [at <position>], <stops>)`
1849     * as the box's background. Phase-1 simplification: only the first
1850     * and last stops are honoured (PDF's basic ShadingType3 is two-stop),
1851     * the box centre is used when no `at <position>` is supplied, and
1852     * `circle` shapes default to half the box's smaller side while
1853     * `ellipse` shapes get half the box's dimensions per axis.
1854     *
1855     * Because PDF's radial shading is a *circular* primitive, ellipse
1856     * gradients are approximated by scaling the user-space matrix so a
1857     * unit circle becomes an ellipse — the gradient still expands
1858     * outward proportionally.
1859     */
1860    private function paintRadialGradient(
1861        \Phpdftk\Css\Value\RadialGradient $gradient,
1862        ContentStream $stream,
1863        float $x,
1864        float $top,
1865        float $width,
1866        float $height,
1867    ): void {
1868        if ($this->writer === null || $gradient->stops === []) {
1869            return;
1870        }
1871        $first = $gradient->stops[0];
1872        $last = $gradient->stops[array_key_last($gradient->stops)];
1873        $pdfY = $this->pageHeight - $top - $height;
1874        // Centre: default to the box centre when no `at <position>` is
1875        // supplied. Author-supplied length values resolve relative to
1876        // the box's content rect.
1877        $cx = $x + ($gradient->centerX !== null ? $gradient->centerX->value : $width / 2);
1878        $cy = $pdfY + ($height - ($gradient->centerY !== null ? $gradient->centerY->value : $height / 2));
1879        // Radii: prefer author lengths, otherwise default to the
1880        // farthest-corner distance for circles (PDF's two-stop primitive
1881        // assumes a single outer radius; we use the larger axis).
1882        $rx = $gradient->sizeX !== null ? $gradient->sizeX->value : $width / 2;
1883        $ry = $gradient->sizeY !== null ? $gradient->sizeY->value : $height / 2;
1884        // ShadingType3 takes inner+outer concentric circles. Phase-1 has
1885        // a single outer radius (inner = 0); scale the user-space matrix
1886        // for elliptical aspect when sizeX != sizeY.
1887        try {
1888            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
1889            $pattern = $doc->addRadialGradient(
1890                new \Phpdftk\Geometry\Point(0, 0),
1891                0.0,
1892                new \Phpdftk\Geometry\Point(0, 0),
1893                max($rx, $ry),
1894                [$first->color->r, $first->color->g, $first->color->b],
1895                [$last->color->r, $last->color->g, $last->color->b],
1896            );
1897        } catch (\Throwable) {
1898            return;
1899        }
1900        $patternName = $this->page?->useGradient($pattern);
1901        if ($patternName === null) {
1902            return;
1903        }
1904        $stream->saveGraphicsState();
1905        // Clip to the box rect, then translate to the centre and scale
1906        // for elliptical shapes so the unit-radius gradient covers the
1907        // right footprint.
1908        $stream->rectangle($x, $pdfY, $width, $height);
1909        $stream->clip();
1910        $stream->endPath();
1911        $scaleX = $rx / max($rx, $ry);
1912        $scaleY = $ry / max($rx, $ry);
1913        $stream->concatMatrix($scaleX, 0.0, 0.0, $scaleY, $cx, $cy);
1914        $stream->setFillColorSpace('Pattern');
1915        $stream->setFillColor($patternName);
1916        // Paint over a rect big enough to cover the largest possible
1917        // gradient extent (in the un-scaled space the gradient sits at
1918        // origin with radius `max(rx, ry)`, so a square of side 2*max
1919        // covers it).
1920        $extent = max($rx, $ry);
1921        $stream->rectangle(-$extent, -$extent, 2 * $extent, 2 * $extent);
1922        $stream->fill();
1923        $stream->restoreGraphicsState();
1924    }
1925
1926    /**
1927     * Paint a CSS `linear-gradient(<angle>|to <side>, <stops>)` as the
1928     * box's background. Phase-1 simplification: only the first and last
1929     * stop colours are honoured (PDF's basic shading dictionary is
1930     * two-stop). The gradient line orientation comes from the CSS angle
1931     * (CSS direction: 0deg = upward, 90deg = rightward, 180deg = down,
1932     * 270deg = leftward; angles increase clockwise).
1933     */
1934    private function paintLinearGradient(
1935        \Phpdftk\Css\Value\LinearGradient $gradient,
1936        ContentStream $stream,
1937        float $x,
1938        float $top,
1939        float $width,
1940        float $height,
1941    ): void {
1942        if ($this->writer === null || $gradient->stops === []) {
1943            return;
1944        }
1945        $first = $gradient->stops[0];
1946        $last = $gradient->stops[array_key_last($gradient->stops)];
1947        $pdfY = $this->pageHeight - $top - $height;
1948        // CSS angle convention: 0deg points up, increases clockwise. The
1949        // gradient line passes through the centre. Compute its start
1950        // and end points on the box's edge per CSS Images 3 §3.1.
1951        $angle = fmod($gradient->angleDeg, 360.0);
1952        if ($angle < 0.0) {
1953            $angle += 360.0;
1954        }
1955        $rad = deg2rad($angle);
1956        $cx = $x + $width / 2;
1957        $cy = $pdfY + $height / 2;
1958        // Gradient line half-length so the endpoints sit on the box
1959        // boundary corners (CSS spec): l/2 = |W sin θ| + |H cos θ| / 2
1960        $sin = sin($rad);
1961        $cos = cos($rad);
1962        $halfLen = (abs($width * $sin) + abs($height * $cos)) / 2;
1963        // The CSS convention rotates the gradient line such that 0deg
1964        // points UP (towards the box top). In PDF space the y-axis
1965        // grows upward already (after our flip), so "up" is +y.
1966        $dx = $sin;
1967        $dy = $cos;
1968        $startPdfX = $cx - $dx * $halfLen;
1969        $startPdfY = $cy - $dy * $halfLen;
1970        $endPdfX = $cx + $dx * $halfLen;
1971        $endPdfY = $cy + $dy * $halfLen;
1972        try {
1973            $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer);
1974            $pattern = $doc->addLinearGradient(
1975                new \Phpdftk\Geometry\Point($startPdfX, $startPdfY),
1976                new \Phpdftk\Geometry\Point($endPdfX, $endPdfY),
1977                [$first->color->r, $first->color->g, $first->color->b],
1978                [$last->color->r, $last->color->g, $last->color->b],
1979            );
1980        } catch (\Throwable) {
1981            return;
1982        }
1983        $stream->saveGraphicsState();
1984        $stream->rectangle($x, $pdfY, $width, $height);
1985        $stream->clip();
1986        $stream->endPath();
1987        $patternName = $this->page?->useGradient($pattern);
1988        if ($patternName !== null) {
1989            $stream->setFillColorSpace('Pattern');
1990            $stream->setFillColor($patternName);
1991            $stream->rectangle($x, $pdfY, $width, $height);
1992            $stream->fill();
1993        }
1994        $stream->restoreGraphicsState();
1995    }
1996
1997    /**
1998     * Paint a CSS `background-image: url(...)` over the box's
1999     * background-positioning area. CSS Backgrounds 3 §3.9 `background-size`
2000     * support:
2001     *   - `auto` / unset → stretch to fill (Phase-1 default; the legacy
2002     *     `100% 100%`-equivalent we shipped before).
2003     *   - `cover` → preserve aspect, scale to fully cover the box (image
2004     *     may overflow; clipped to box rect).
2005     *   - `contain` → preserve aspect, scale to fit inside the box;
2006     *     image is centred and may show background-color through the
2007     *     letterbox area.
2008     *   - `<length> <length>` → explicit width × height; centred.
2009     */
2010    private function paintBackgroundImage(
2011        \Phpdftk\Css\Value\Url $url,
2012        ContentStream $stream,
2013        float $x,
2014        float $top,
2015        float $width,
2016        float $height,
2017        ?\Phpdftk\Css\Value\Value $sizeValue = null,
2018        ?\Phpdftk\Css\Value\Value $positionValue = null,
2019        ?\Phpdftk\Css\Value\Value $repeatValue = null,
2020    ): void {
2021        if ($this->writer === null || $this->page === null) {
2022            return;
2023        }
2024        $src = $url->url;
2025        if (isset($this->imageNameCache[$src])) {
2026            $name = $this->imageNameCache[$src];
2027        } else {
2028            $resolved = $this->resolveImageSrc($src);
2029            if ($resolved === null) {
2030                return;
2031            }
2032            try {
2033                $name = $this->writer->addImage($resolved, $this->page);
2034            } catch (\Throwable) {
2035                return;
2036            }
2037            $this->imageNameCache[$src] = $name;
2038        }
2039        // Resolve final paint rect (final size + offset within the box).
2040        $paint = $this->resolveBackgroundSize($sizeValue, $src, $width, $height);
2041        // Author `background-position` may override the size resolver's
2042        // default centred offset. `auto` size keeps the stretch
2043        // behaviour so positioning has no effect (the rect equals the
2044        // box). For non-auto sizes, apply the position formula:
2045        // offset = (box - image) × percent.
2046        $isAuto = $sizeValue === null
2047            || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword
2048                && strtolower($sizeValue->name) === 'auto');
2049        if (!$isAuto && $positionValue !== null) {
2050            $pos = $this->resolveBackgroundPosition(
2051                $positionValue,
2052                $paint['w'],
2053                $paint['h'],
2054                $width,
2055                $height,
2056            );
2057            $paint['offsetX'] = $pos['offsetX'];
2058            $paint['offsetY'] = $pos['offsetY'];
2059        }
2060        $pdfY = $this->pageHeight - $top - $height;
2061        $stream->saveGraphicsState();
2062        // `cover` may overflow the box; clip to box rect so the overflow
2063        // doesn't bleed into adjacent boxes.
2064        $stream->rectangle($x, $pdfY, $width, $height);
2065        $stream->clip();
2066        $stream->endPath();
2067        // CSS Backgrounds 3 §3.8: `background-repeat` decides whether
2068        // to tile the image when its painted rect doesn't fill the box.
2069        // `no-repeat` paints one instance; `repeat` / `repeat-x` /
2070        // `repeat-y` tile across the relevant axes. The painter's box
2071        // clip handles edge tiles that extend past the box rect.
2072        $repeat = $this->repeatAxes($repeatValue);
2073        $tileW = $paint['w'];
2074        $tileH = $paint['h'];
2075        if ($tileW <= 0.0 || $tileH <= 0.0) {
2076            $stream->restoreGraphicsState();
2077            return;
2078        }
2079        // Start positions: shift the anchor backwards by whole tile
2080        // widths until the leftmost / topmost tile sits at or before
2081        // the box origin. With `no-repeat`, no shift happens — single
2082        // tile at the resolved position.
2083        $startX = $paint['offsetX'];
2084        if ($repeat['x']) {
2085            while ($startX > 0.0) {
2086                $startX -= $tileW;
2087            }
2088        }
2089        $startY = $paint['offsetY'];
2090        if ($repeat['y']) {
2091            while ($startY > 0.0) {
2092                $startY -= $tileH;
2093            }
2094        }
2095        // Iterate forward emitting cm + Do per tile. Cap the tile count
2096        // defensively so a pathological 1×1 image in a 10000×10000 box
2097        // doesn't blow up the content stream.
2098        $maxTiles = 4096;
2099        $tileCount = 0;
2100        $offsetY = $startY;
2101        while ($offsetY < $height) {
2102            $offsetX = $startX;
2103            while ($offsetX < $width) {
2104                if ($tileCount >= $maxTiles) {
2105                    break 2;
2106                }
2107                $stream->saveGraphicsState();
2108                $stream->concatMatrix(
2109                    $tileW,
2110                    0.0,
2111                    0.0,
2112                    $tileH,
2113                    $x + $offsetX,
2114                    $pdfY + ($height - $tileH - $offsetY),
2115                );
2116                $stream->doXObject($name);
2117                $stream->restoreGraphicsState();
2118                $tileCount++;
2119                if (!$repeat['x']) {
2120                    break;
2121                }
2122                $offsetX += $tileW;
2123            }
2124            if (!$repeat['y']) {
2125                break;
2126            }
2127            $offsetY += $tileH;
2128        }
2129        $stream->restoreGraphicsState();
2130    }
2131
2132    /**
2133     * Resolve a CSS `background-repeat` value to a `{x: bool, y: bool}`
2134     * pair indicating whether each axis should tile.
2135     *
2136     * Phase-1 handles the simple keyword set:
2137     *   - `repeat` (default) → both axes
2138     *   - `repeat-x` → x only
2139     *   - `repeat-y` → y only
2140     *   - `no-repeat` → neither
2141     *   - two-value form (e.g. `repeat no-repeat`): per-axis keywords
2142     * The `space` / `round` per-axis variants land later — they need
2143     * extra-spacing / scaling math beyond the simple loop.
2144     *
2145     * @return array{x: bool, y: bool}
2146     */
2147    private function repeatAxes(?\Phpdftk\Css\Value\Value $value): array
2148    {
2149        if ($value === null) {
2150            return ['x' => true, 'y' => true];
2151        }
2152        $items = $value instanceof \Phpdftk\Css\Value\ValueList
2153            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
2154                ? $value->values
2155                : [$value];
2156        if (count($items) === 2
2157            && $items[0] instanceof \Phpdftk\Css\Value\Keyword
2158            && $items[1] instanceof \Phpdftk\Css\Value\Keyword
2159        ) {
2160            return [
2161                'x' => strtolower($items[0]->name) !== 'no-repeat',
2162                'y' => strtolower($items[1]->name) !== 'no-repeat',
2163            ];
2164        }
2165        if ($items[0] instanceof \Phpdftk\Css\Value\Keyword) {
2166            return match (strtolower($items[0]->name)) {
2167                'repeat-x' => ['x' => true, 'y' => false],
2168                'repeat-y' => ['x' => false, 'y' => true],
2169                'no-repeat' => ['x' => false, 'y' => false],
2170                default => ['x' => true, 'y' => true],
2171            };
2172        }
2173        return ['x' => true, 'y' => true];
2174    }
2175
2176    /**
2177     * Resolve a CSS `background-position` value to a top-left offset of
2178     * the image rect within the background-positioning area (the box's
2179     * rect). Per CSS Backgrounds 3 §3.7, position offsets are
2180     * interpolated such that `0% 0%` puts the image's top-left at the
2181     * box's top-left and `100% 100%` puts the image's bottom-right at
2182     * the box's bottom-right (i.e. `offset = (box - image) × percent`).
2183     *
2184     * Phase-1 surface:
2185     *   - 1 keyword (`center` / `top` / `bottom` / `left` / `right`) → centred on the missing axis
2186     *   - 2 values (keyword | length | percentage), one per axis
2187     *   - Lengths offset directly; percentages use the spec formula.
2188     * Edge syntax (`right 10px bottom 20px`, 4-value form) lands later.
2189     *
2190     * @return array{offsetX: float, offsetY: float}
2191     */
2192    private function resolveBackgroundPosition(
2193        \Phpdftk\Css\Value\Value $value,
2194        float $imageWidth,
2195        float $imageHeight,
2196        float $boxWidth,
2197        float $boxHeight,
2198    ): array {
2199        $items = $value instanceof \Phpdftk\Css\Value\ValueList
2200            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
2201                ? $value->values
2202                : [$value];
2203        // Default both axes to centre (`50%`).
2204        $xPercent = 0.5;
2205        $yPercent = 0.5;
2206        $xLength = null;
2207        $yLength = null;
2208        // Single keyword: maps to one axis and centres the other.
2209        if (count($items) === 1 && $items[0] instanceof \Phpdftk\Css\Value\Keyword) {
2210            $kw = strtolower($items[0]->name);
2211            // Vertical-only keywords pin y; horizontal-only pin x.
2212            switch ($kw) {
2213                case 'top': $yPercent = 0.0;
2214                    break;
2215                case 'bottom': $yPercent = 1.0;
2216                    break;
2217                case 'left': $xPercent = 0.0;
2218                    break;
2219                case 'right': $xPercent = 1.0;
2220                    break;
2221                case 'center': default: break;
2222            }
2223        } else {
2224            // Two-value form: first is x, second is y.
2225            $xItem = $items[0] ?? null;
2226            $yItem = $items[1] ?? null;
2227            $xAxis = $this->axisOffsetFromValue($xItem, isHorizontal: true);
2228            $yAxis = $this->axisOffsetFromValue($yItem, isHorizontal: false);
2229            if ($xAxis['percent'] !== null) {
2230                $xPercent = $xAxis['percent'];
2231            }
2232            if ($xAxis['length'] !== null) {
2233                $xLength = $xAxis['length'];
2234            }
2235            if ($yAxis['percent'] !== null) {
2236                $yPercent = $yAxis['percent'];
2237            }
2238            if ($yAxis['length'] !== null) {
2239                $yLength = $yAxis['length'];
2240            }
2241        }
2242        $offsetX = $xLength ?? ($boxWidth - $imageWidth) * $xPercent;
2243        $offsetY = $yLength ?? ($boxHeight - $imageHeight) * $yPercent;
2244        return ['offsetX' => $offsetX, 'offsetY' => $offsetY];
2245    }
2246
2247    /**
2248     * Classify a single `background-position` axis value into a
2249     * `{percent?, length?}` pair. Keywords (`top`/`bottom`/`left`/
2250     * `right`/`center`) become percentages; explicit `<length>` /
2251     * `<percentage>` values come through as-is.
2252     *
2253     * @return array{percent: ?float, length: ?float}
2254     */
2255    private function axisOffsetFromValue(
2256        ?\Phpdftk\Css\Value\Value $value,
2257        bool $isHorizontal,
2258    ): array {
2259        if ($value === null) {
2260            return ['percent' => 0.5, 'length' => null];
2261        }
2262        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2263            $kw = strtolower($value->name);
2264            $percent = match ($kw) {
2265                'left', 'top' => 0.0,
2266                'right', 'bottom' => 1.0,
2267                'center' => 0.5,
2268                default => 0.5,
2269            };
2270            return ['percent' => $percent, 'length' => null];
2271        }
2272        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2273            return ['percent' => $value->value / 100.0, 'length' => null];
2274        }
2275        if ($value instanceof \Phpdftk\Css\Value\Length) {
2276            return ['percent' => null, 'length' => $value->value];
2277        }
2278        // CSS Values 4 §4.2: bare `0` is treated as `0` in any
2279        // dimensional context, so `background-position: 0 0` resolves
2280        // to top-left anchor (not the default centre).
2281        if (($value instanceof \Phpdftk\Css\Value\Number
2282            || $value instanceof \Phpdftk\Css\Value\Integer)
2283            && (float) $value->value === 0.0
2284        ) {
2285            return ['percent' => null, 'length' => 0.0];
2286        }
2287        return ['percent' => 0.5, 'length' => null];
2288    }
2289
2290    /**
2291     * Compute the final paint rect for a CSS `background-size` value.
2292     * Returns the width / height (in points) and the offset (top-left
2293     * corner) within the containing background-positioning area. Phase 1
2294     * always centres `contain`-sized images; `background-position`
2295     * support lands later with the full Backgrounds 3 §3.7 grammar.
2296     *
2297     * @return array{w: float, h: float, offsetX: float, offsetY: float}
2298     */
2299    private function resolveBackgroundSize(
2300        ?\Phpdftk\Css\Value\Value $sizeValue,
2301        string $src,
2302        float $boxWidth,
2303        float $boxHeight,
2304    ): array {
2305        // Default / unset / `auto`: legacy stretch behaviour.
2306        $isAuto = $sizeValue === null
2307            || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword
2308                && strtolower($sizeValue->name) === 'auto');
2309        if ($isAuto) {
2310            return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
2311        }
2312        $keyword = $sizeValue instanceof \Phpdftk\Css\Value\Keyword
2313            ? strtolower($sizeValue->name)
2314            : null;
2315        if ($keyword === 'cover' || $keyword === 'contain') {
2316            $intrinsic = $this->intrinsicSize($src);
2317            if ($intrinsic === null) {
2318                // Fallback to stretch when we can't read natural size.
2319                return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
2320            }
2321            [$natW, $natH] = $intrinsic;
2322            $scaleW = $boxWidth / $natW;
2323            $scaleH = $boxHeight / $natH;
2324            $scale = $keyword === 'cover' ? max($scaleW, $scaleH) : min($scaleW, $scaleH);
2325            $finalW = $natW * $scale;
2326            $finalH = $natH * $scale;
2327            return [
2328                'w' => $finalW,
2329                'h' => $finalH,
2330                'offsetX' => ($boxWidth - $finalW) / 2,
2331                'offsetY' => ($boxHeight - $finalH) / 2,
2332            ];
2333        }
2334        // `<length> <length>` — explicit dimensions in a 2-element
2335        // space-separated ValueList. Single value sets width, height auto
2336        // (which we resolve to the natural aspect).
2337        if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList
2338            && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space
2339        ) {
2340            $w = $sizeValue->values[0] ?? null;
2341            $h = $sizeValue->values[1] ?? null;
2342            $finalW = $w instanceof \Phpdftk\Css\Value\Length ? $w->value : $boxWidth;
2343            $finalH = $h instanceof \Phpdftk\Css\Value\Length ? $h->value : $boxHeight;
2344            return [
2345                'w' => $finalW,
2346                'h' => $finalH,
2347                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
2348                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
2349            ];
2350        }
2351        if ($sizeValue instanceof \Phpdftk\Css\Value\Length) {
2352            // Single length sets width; height = natural aspect.
2353            $intrinsic = $this->intrinsicSize($src);
2354            $finalW = $sizeValue->value;
2355            $finalH = $intrinsic !== null && $intrinsic[0] > 0
2356                ? $finalW * ($intrinsic[1] / $intrinsic[0])
2357                : $boxHeight;
2358            return [
2359                'w' => $finalW,
2360                'h' => $finalH,
2361                'offsetX' => max(0.0, ($boxWidth - $finalW) / 2),
2362                'offsetY' => max(0.0, ($boxHeight - $finalH) / 2),
2363            ];
2364        }
2365        return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0];
2366    }
2367
2368    /**
2369     * Read the intrinsic pixel dimensions of an image referenced by an
2370     * `<img src>` or `background-image: url(...)` value. Tolerates both
2371     * `data:image/...` URIs and resolved local-file paths via the
2372     * painter's existing `resolveImageSrc`. Returns null when the bytes
2373     * can't be read or parsed.
2374     *
2375     * @return array{int, int}|null
2376     */
2377    private function intrinsicSize(string $src): ?array
2378    {
2379        try {
2380            if (str_starts_with($src, 'data:image/')) {
2381                if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $src, $m) !== 1) {
2382                    return null;
2383                }
2384                $payload = $m[2] === 'base64,'
2385                    ? base64_decode($m[3], strict: true)
2386                    : urldecode($m[3]);
2387                if ($payload === false || $payload === '') {
2388                    return null;
2389                }
2390                $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload);
2391            } else {
2392                $resolved = $this->resolveImageSrc($src);
2393                if ($resolved === null) {
2394                    return null;
2395                }
2396                $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved);
2397            }
2398        } catch (\Throwable) {
2399            return null;
2400        }
2401        return [$info->width, $info->height];
2402    }
2403
2404    private function paintBorders(Box $box, ContentStream $stream): void
2405    {
2406        $geo = $box->geometry;
2407        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft;
2408        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop;
2409        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
2410            + $geo->borderLeft + $geo->borderRight;
2411        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
2412            + $geo->borderTop + $geo->borderBottom;
2413
2414        // Rounded-uniform-border fast path: all four sides share width +
2415        // colour + style, and any radius is set → emit one stroked
2416        // rounded path. Mixed-width/colour borders or no radius fall back
2417        // to the per-side rectangle path (still straight corners).
2418        $radii = $this->borderRadii($box);
2419        if (array_sum($radii) > 0.0 && $this->bordersAreUniform($box)) {
2420            $width = $geo->borderTop;
2421            if ($width > 0.0) {
2422                $this->emitRoundedStroke(
2423                    $stream,
2424                    $outerX + $width / 2,
2425                    $outerY + $width / 2,
2426                    $outerWidth - $width,
2427                    $outerHeight - $width,
2428                    $radii,
2429                    $this->borderColor($box, 'top'),
2430                    $width,
2431                );
2432                return;
2433            }
2434        }
2435
2436        if ($geo->borderTop > 0.0 && $this->borderIsVisible($box, 'top')) {
2437            $this->paintBorderSide(
2438                $stream,
2439                $this->borderStyleName($box, 'top'),
2440                $this->borderColor($box, 'top'),
2441                $outerX,
2442                $outerY,
2443                $outerWidth,
2444                $geo->borderTop,
2445                side: 'top',
2446            );
2447        }
2448        if ($geo->borderBottom > 0.0 && $this->borderIsVisible($box, 'bottom')) {
2449            $this->paintBorderSide(
2450                $stream,
2451                $this->borderStyleName($box, 'bottom'),
2452                $this->borderColor($box, 'bottom'),
2453                $outerX,
2454                $outerY + $outerHeight - $geo->borderBottom,
2455                $outerWidth,
2456                $geo->borderBottom,
2457                side: 'bottom',
2458            );
2459        }
2460        if ($geo->borderLeft > 0.0 && $this->borderIsVisible($box, 'left')) {
2461            $this->paintBorderSide(
2462                $stream,
2463                $this->borderStyleName($box, 'left'),
2464                $this->borderColor($box, 'left'),
2465                $outerX,
2466                $outerY,
2467                $geo->borderLeft,
2468                $outerHeight,
2469                side: 'left',
2470            );
2471        }
2472        if ($geo->borderRight > 0.0 && $this->borderIsVisible($box, 'right')) {
2473            $this->paintBorderSide(
2474                $stream,
2475                $this->borderStyleName($box, 'right'),
2476                $this->borderColor($box, 'right'),
2477                $outerX + $outerWidth - $geo->borderRight,
2478                $outerY,
2479                $geo->borderRight,
2480                $outerHeight,
2481                side: 'right',
2482            );
2483        }
2484    }
2485
2486    /**
2487     * Paint one border side honouring `border-style`. `axis` flags
2488     * whether the rect runs horizontally (top / bottom) or vertically
2489     * (left / right) so the `double` decomposition knows which
2490     * dimension to split into thirds.
2491     *
2492     *  - `solid`: one filled rect (the original behaviour).
2493     *  - `double` (CSS Backgrounds 3 §5): two parallel bands each
2494     *    `thickness/3` thick with a `thickness/3` gap. When the
2495     *    thickness is too small to split (< 3 units), falls back to
2496     *    solid so the border doesn't disappear into a hairline.
2497     *  - `dashed` / `dotted`: stroke a line at the centerline of the
2498     *    side with a PDF dash pattern. Dashed uses 3w-on / 2w-off;
2499     *    dotted uses 1w-on / 1w-off (PDF rounds dotted patterns to
2500     *    square caps).
2501     *  - Other style keywords (`groove`, `ridge`, `inset`, `outset`):
2502     *    Phase-1 fallback to solid.
2503     */
2504    private function paintBorderSide(
2505        ContentStream $stream,
2506        string $styleName,
2507        Color $color,
2508        float $x,
2509        float $y,
2510        float $width,
2511        float $height,
2512        string $side,
2513    ): void {
2514        $axis = ($side === 'top' || $side === 'bottom') ? 'horizontal' : 'vertical';
2515        // CSS Backgrounds 3 §5.2 — 3D-effect styles. `inset` darkens
2516        // top + left; `outset` lightens them; `groove` and `ridge`
2517        // split per side as if etched / raised.
2518        if (in_array($styleName, ['inset', 'outset', 'groove', 'ridge'], true)) {
2519            $color = $this->resolve3dBorderColor($styleName, $color, $side);
2520        }
2521        if ($styleName === 'dashed' || $styleName === 'dotted') {
2522            $thickness = $axis === 'horizontal' ? $height : $width;
2523            $this->paintDashedDottedSide(
2524                $stream,
2525                $styleName,
2526                $color,
2527                $x,
2528                $y,
2529                $width,
2530                $height,
2531                $axis,
2532                $thickness,
2533            );
2534            return;
2535        }
2536        if ($styleName === 'double') {
2537            $thickness = $axis === 'horizontal' ? $height : $width;
2538            if ($thickness >= 3.0) {
2539                $third = $thickness / 3.0;
2540                if ($axis === 'horizontal') {
2541                    // Two horizontal bands stacked vertically.
2542                    $this->emitRect($stream, $x, $y, $width, $third, fill: $color);
2543                    $this->emitRect($stream, $x, $y + 2 * $third, $width, $third, fill: $color);
2544                } else {
2545                    // Two vertical bands stacked horizontally.
2546                    $this->emitRect($stream, $x, $y, $third, $height, fill: $color);
2547                    $this->emitRect($stream, $x + 2 * $third, $y, $third, $height, fill: $color);
2548                }
2549                return;
2550            }
2551        }
2552        $this->emitRect($stream, $x, $y, $width, $height, fill: $color);
2553    }
2554
2555    /**
2556     * Stroke one side of a border as a dashed / dotted line at the
2557     * centerline of the side, at line-width = thickness. PDF's `d`
2558     * operator takes a `[on, off]` array + phase; we use:
2559     *   - dashed: `[thickness*3, thickness*2]`
2560     *   - dotted: `[thickness, thickness]` (PDF strokes with square
2561     *     caps so dotted shows as squares the size of thickness;
2562     *     close enough to CSS dotted in print rendering).
2563     */
2564    private function paintDashedDottedSide(
2565        ContentStream $stream,
2566        string $styleName,
2567        Color $color,
2568        float $x,
2569        float $y,
2570        float $width,
2571        float $height,
2572        string $axis,
2573        float $thickness,
2574    ): void {
2575        if ($thickness <= 0.0) {
2576            return;
2577        }
2578        $stream->saveGraphicsState();
2579        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
2580        $stream->setLineWidth($thickness);
2581        $pattern = $styleName === 'dotted'
2582            ? [$thickness, $thickness]
2583            : [$thickness * 3, $thickness * 2];
2584        $stream->setDashPattern($pattern, 0);
2585        if ($axis === 'horizontal') {
2586            // Stroke from (x, midY) to (x+width, midY). PDF Y is
2587            // inverted; midY in PDF coords = pageHeight - (y + height/2).
2588            $midPdfY = $this->pageHeight - ($y + $height / 2.0);
2589            $stream->moveTo($x, $midPdfY);
2590            $stream->lineTo($x + $width, $midPdfY);
2591        } else {
2592            $midX = $x + $width / 2.0;
2593            $topPdfY = $this->pageHeight - $y;
2594            $bottomPdfY = $this->pageHeight - ($y + $height);
2595            $stream->moveTo($midX, $topPdfY);
2596            $stream->lineTo($midX, $bottomPdfY);
2597        }
2598        $stream->stroke();
2599        $stream->restoreGraphicsState();
2600    }
2601
2602    /**
2603     * Resolve the per-side colour for CSS Backgrounds 3 §5.2 3D-style
2604     * borders. The light source is conventionally top-left:
2605     *
2606     *  - `inset`  → top + left use a darker variant (carved-in look).
2607     *  - `outset` → bottom + right use a darker variant (raised look).
2608     *  - `groove` → top + left darker, bottom + right lighter (etched in).
2609     *  - `ridge`  → top + left lighter, bottom + right darker (raised ridge).
2610     *
2611     * "Darker" multiplies each RGB channel by 0.5; "lighter" lightens
2612     * toward white by 30%. These match common browser approximations.
2613     */
2614    private function resolve3dBorderColor(string $styleName, Color $base, string $side): Color
2615    {
2616        $isTopLeft = $side === 'top' || $side === 'left';
2617        $darken = static function (Color $c): Color {
2618            return new Color($c->r * 0.5, $c->g * 0.5, $c->b * 0.5, $c->a, $c->space);
2619        };
2620        $lighten = static function (Color $c): Color {
2621            return new Color(
2622                $c->r + (1.0 - $c->r) * 0.3,
2623                $c->g + (1.0 - $c->g) * 0.3,
2624                $c->b + (1.0 - $c->b) * 0.3,
2625                $c->a,
2626                $c->space,
2627            );
2628        };
2629        return match ($styleName) {
2630            'inset' => $isTopLeft ? $darken($base) : $base,
2631            'outset' => $isTopLeft ? $base : $darken($base),
2632            'groove' => $isTopLeft ? $darken($base) : $lighten($base),
2633            'ridge' => $isTopLeft ? $lighten($base) : $darken($base),
2634            default => $base,
2635        };
2636    }
2637
2638    private function borderStyleName(Box $box, string $side): string
2639    {
2640        $value = $box->style->get("border-$side-style");
2641        if (!$value instanceof Keyword) {
2642            return 'none';
2643        }
2644        return strtolower($value->name);
2645    }
2646
2647    /**
2648     * Uniform borders: same width / colour / visible-style on all 4 sides.
2649     * Enables the rounded-stroke fast path; mixed borders fall back to
2650     * straight per-side rectangles.
2651     */
2652    private function bordersAreUniform(Box $box): bool
2653    {
2654        $g = $box->geometry;
2655        if (abs($g->borderTop - $g->borderRight) > 0.001
2656            || abs($g->borderTop - $g->borderBottom) > 0.001
2657            || abs($g->borderTop - $g->borderLeft) > 0.001
2658        ) {
2659            return false;
2660        }
2661        $colorTop = $this->borderColor($box, 'top');
2662        foreach (['right', 'bottom', 'left'] as $side) {
2663            if (!$this->borderIsVisible($box, $side)) {
2664                return false;
2665            }
2666            $c = $this->borderColor($box, $side);
2667            if ($c->r !== $colorTop->r || $c->g !== $colorTop->g || $c->b !== $colorTop->b) {
2668                return false;
2669            }
2670        }
2671        return $this->borderIsVisible($box, 'top');
2672    }
2673
2674    /**
2675     * Stroke a rounded-rectangle path. `x,topY,width,height` describe the
2676     * path's centreline (so the stroke straddles both inside and outside);
2677     * radii are clamped per spec. Used for uniform-border rendering when
2678     * border-radius is set.
2679     *
2680     * @param array{float, float, float, float} $radii
2681     */
2682    private function emitRoundedStroke(
2683        ContentStream $stream,
2684        float $x,
2685        float $topY,
2686        float $width,
2687        float $height,
2688        array $radii,
2689        Color $color,
2690        float $lineWidth,
2691    ): void {
2692        $maxR = min($width, $height) / 2.0;
2693        [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => max(0.0, min($r, $maxR)), $radii);
2694        $k = 0.5522847498;
2695        $bottomPdfY = $this->pageHeight - $topY - $height;
2696        $topPdfY = $this->pageHeight - $topY;
2697        $stream->saveGraphicsState();
2698        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
2699        $stream->setLineWidth($lineWidth);
2700        $stream->moveTo($x + $rtl, $topPdfY);
2701        $stream->lineTo($x + $width - $rtr, $topPdfY);
2702        if ($rtr > 0.0) {
2703            $stream->curveTo(
2704                $x + $width - $rtr + $rtr * $k,
2705                $topPdfY,
2706                $x + $width,
2707                $topPdfY - $rtr + $rtr * $k,
2708                $x + $width,
2709                $topPdfY - $rtr,
2710            );
2711        }
2712        $stream->lineTo($x + $width, $bottomPdfY + $rbr);
2713        if ($rbr > 0.0) {
2714            $stream->curveTo(
2715                $x + $width,
2716                $bottomPdfY + $rbr - $rbr * $k,
2717                $x + $width - $rbr + $rbr * $k,
2718                $bottomPdfY,
2719                $x + $width - $rbr,
2720                $bottomPdfY,
2721            );
2722        }
2723        $stream->lineTo($x + $rbl, $bottomPdfY);
2724        if ($rbl > 0.0) {
2725            $stream->curveTo(
2726                $x + $rbl - $rbl * $k,
2727                $bottomPdfY,
2728                $x,
2729                $bottomPdfY + $rbl - $rbl * $k,
2730                $x,
2731                $bottomPdfY + $rbl,
2732            );
2733        }
2734        $stream->lineTo($x, $topPdfY - $rtl);
2735        if ($rtl > 0.0) {
2736            $stream->curveTo(
2737                $x,
2738                $topPdfY - $rtl + $rtl * $k,
2739                $x + $rtl - $rtl * $k,
2740                $topPdfY,
2741                $x + $rtl,
2742                $topPdfY,
2743            );
2744        }
2745        $stream->closePath();
2746        $stream->stroke();
2747        $stream->restoreGraphicsState();
2748    }
2749
2750    /**
2751     * Paint CSS UI 3 §4 `outline`. Outlines don't take part in layout —
2752     * they're drawn just outside the border-box at `outline-offset`. We
2753     * only paint the visible outline-style values (everything except
2754     * `none` / `hidden`); `outline-width` and `outline-color` follow the
2755     * cascade.
2756     */
2757    private function paintOutline(Box $box, ContentStream $stream): void
2758    {
2759        if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox
2760            || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox
2761            || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
2762        ) {
2763            return;
2764        }
2765        $style = $box->style->get('outline-style');
2766        if (!$style instanceof Keyword) {
2767            return;
2768        }
2769        $styleName = strtolower($style->name);
2770        if ($styleName === 'none' || $styleName === 'hidden') {
2771            return;
2772        }
2773        $widthValue = $box->style->get('outline-width');
2774        $width = $widthValue instanceof \Phpdftk\Css\Value\Length ? max(0.0, $widthValue->value) : 0.0;
2775        if ($width <= 0.0) {
2776            return;
2777        }
2778        $offsetValue = $box->style->get('outline-offset');
2779        $offset = $offsetValue instanceof \Phpdftk\Css\Value\Length ? $offsetValue->value : 0.0;
2780        $colorValue = $box->style->get('outline-color');
2781        $color = $colorValue instanceof Color ? $colorValue : ($box->style->get('color') instanceof Color
2782            ? $box->style->get('color')
2783            : new Color(0, 0, 0, 1));
2784
2785        $geo = $box->geometry;
2786        $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft - $offset - $width / 2;
2787        $outerY = $geo->y - $geo->paddingTop - $geo->borderTop - $offset - $width / 2;
2788        $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight
2789            + $geo->borderLeft + $geo->borderRight + 2 * $offset + $width;
2790        $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom
2791            + $geo->borderTop + $geo->borderBottom + 2 * $offset + $width;
2792        $pdfY = $this->pageHeight - $outerY - $outerHeight;
2793        $stream->saveGraphicsState();
2794        $stream->setStrokeColorRGB($color->r, $color->g, $color->b);
2795        $stream->setLineWidth($width);
2796        // CSS Outline 3 §5 styles. `dashed` / `dotted` map onto PDF
2797        // line-dash patterns; `double` paints two concentric strokes
2798        // each `width/3` thick separated by a `width/3` gap; the rest
2799        // (`groove` / `ridge` / `inset` / `outset`) fall back to solid.
2800        if ($styleName === 'double' && $width >= 3.0) {
2801            $third = $width / 3.0;
2802            $stream->setLineWidth($third);
2803            // Outer ring: path centred between the outline's outer
2804            // edge and (outer edge + third). The stroke straddles the
2805            // path by ±third/2, so the outer face sits on the outline
2806            // outer edge.
2807            $stream->rectangle(
2808                $outerX + $third / 2,
2809                $pdfY + $third / 2,
2810                $outerWidth - $third,
2811                $outerHeight - $third,
2812            );
2813            $stream->stroke();
2814            // Inner ring: path centred two-thirds in from the outer.
2815            $stream->rectangle(
2816                $outerX + 2.5 * $third,
2817                $pdfY + 2.5 * $third,
2818                $outerWidth - 5 * $third,
2819                $outerHeight - 5 * $third,
2820            );
2821            $stream->stroke();
2822            $stream->restoreGraphicsState();
2823            return;
2824        }
2825        switch ($styleName) {
2826            case 'dashed':
2827                $stream->setDashPattern([$width * 3, $width * 2], 0);
2828                break;
2829            case 'dotted':
2830                $stream->setDashPattern([$width, $width * 1.5], 0);
2831                break;
2832                // 'groove' / 'ridge' / 'inset' / 'outset' fall back
2833                // to solid for Phase 1.
2834        }
2835        $stream->rectangle($outerX, $pdfY, $outerWidth, $outerHeight);
2836        $stream->stroke();
2837        $stream->restoreGraphicsState();
2838    }
2839
2840    /**
2841     * Stroke `column-rule` between adjacent columns inside a multi-column
2842     * container (CSS Multi-column 1 §3). Each rule is centred in its
2843     * column-gap, spans the container's content-area height, and honours
2844     * `column-rule-style` for `solid` / `dashed` / `dotted`. No-op when
2845     * the box isn't a multi-column container, the rule has zero width, or
2846     * the style is `none` / `hidden`.
2847     */
2848    private function paintColumnRules(Box $box, ContentStream $stream): void
2849    {
2850        $mc = $box->multiColumn;
2851        if ($mc === null || $mc->columnCount < 2) {
2852            return;
2853        }
2854        if ($mc->ruleWidth <= 0.0 || $mc->ruleColor === null) {
2855            return;
2856        }
2857        $styleName = $mc->ruleStyle;
2858        if ($styleName === 'none' || $styleName === 'hidden') {
2859            return;
2860        }
2861        $geo = $box->geometry;
2862        $top = $geo->y;
2863        $height = $geo->height;
2864        if ($height <= 0.0) {
2865            return;
2866        }
2867        $pdfTop = $this->pageHeight - $top;
2868        $pdfBottom = $this->pageHeight - ($top + $height);
2869        $stream->saveGraphicsState();
2870        $stream->setStrokeColorRGB($mc->ruleColor->r, $mc->ruleColor->g, $mc->ruleColor->b);
2871        $stream->setLineWidth($mc->ruleWidth);
2872        switch ($styleName) {
2873            case 'dashed':
2874                $stream->setDashPattern([$mc->ruleWidth * 3, $mc->ruleWidth * 2], 0);
2875                break;
2876            case 'dotted':
2877                $stream->setDashPattern([$mc->ruleWidth, $mc->ruleWidth * 1.5], 0);
2878                break;
2879                // Other styles (double / groove / ridge / inset / outset)
2880                // fall back to solid for Phase 1, mirroring the outline
2881                // painter's approximation.
2882        }
2883        for ($i = 0; $i < $mc->columnCount - 1; $i++) {
2884            // Centre line of the gap between column $i and $i+1.
2885            $gapCentreX = $geo->x
2886                + ($i + 1) * $mc->columnWidth
2887                + $i * $mc->columnGap
2888                + $mc->columnGap / 2.0;
2889            $stream->moveTo($gapCentreX, $pdfBottom);
2890            $stream->lineTo($gapCentreX, $pdfTop);
2891            $stream->stroke();
2892        }
2893        $stream->restoreGraphicsState();
2894    }
2895
2896    private function borderIsVisible(Box $box, string $side): bool
2897    {
2898        $style = $box->style->get("border-$side-style");
2899        if (!$style instanceof Keyword) {
2900            return false;
2901        }
2902        $lower = strtolower($style->name);
2903        return $lower !== 'none' && $lower !== 'hidden';
2904    }
2905
2906    private function borderColor(Box $box, string $side): Color
2907    {
2908        $color = $box->style->get("border-$side-color");
2909        if ($color instanceof Color) {
2910            return $color;
2911        }
2912        // CSS Colors 4: border-color initial is currentColor, which means
2913        // the cascaded `color` property.
2914        $current = $box->style->get('color');
2915        if ($current instanceof Color) {
2916            return $current;
2917        }
2918        return new Color(0, 0, 0, 1);
2919    }
2920
2921    /**
2922     * Emit a rect in PDF coordinates (Y flipped from top-down layout space).
2923     * `topY` is the layout-space top edge; `height` is positive downward.
2924     */
2925    private function emitRect(
2926        ContentStream $stream,
2927        float $x,
2928        float $topY,
2929        float $width,
2930        float $height,
2931        Color $fill,
2932    ): void {
2933        $pdfY = $this->pageHeight - $topY - $height;
2934        $stream->saveGraphicsState();
2935        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
2936        $stream->rectangle($x, $pdfY, $width, $height);
2937        $stream->fill();
2938        $stream->restoreGraphicsState();
2939    }
2940
2941    /**
2942     * Read the box's four corner radii in pixel-equivalent units. CSS
2943     * Backgrounds 3 §6 requires each radius to be clamped to half the
2944     * shorter side; we do that here.
2945     *
2946     * @return array{float, float, float, float} [tl, tr, br, bl]
2947     */
2948    private function borderRadii(Box $box): array
2949    {
2950        $read = function (string $name) use ($box): float {
2951            $v = $box->style->get($name);
2952            return $v instanceof \Phpdftk\Css\Value\Length ? max(0.0, $v->value) : 0.0;
2953        };
2954        return [
2955            $read('border-top-left-radius'),
2956            $read('border-top-right-radius'),
2957            $read('border-bottom-right-radius'),
2958            $read('border-bottom-left-radius'),
2959        ];
2960    }
2961
2962    /**
2963     * Emit a rounded-rectangle fill path using cubic Béziers at the four
2964     * corners. Topology in layout-Y (top-down) with the painter's flip
2965     * applied at emission time. The 0.5522847498 constant is the standard
2966     * cubic-Bézier circle approximation factor.
2967     *
2968     * @param array{float, float, float, float} $radii [tl, tr, br, bl]
2969     */
2970    private function emitRoundedFill(
2971        ContentStream $stream,
2972        float $x,
2973        float $topY,
2974        float $width,
2975        float $height,
2976        array $radii,
2977        Color $fill,
2978    ): void {
2979        $maxR = min($width, $height) / 2.0;
2980        [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => min($r, $maxR), $radii);
2981        $k = 0.5522847498;
2982        // Flip to PDF coords for emission.
2983        $bottomPdfY = $this->pageHeight - $topY - $height;
2984        $topPdfY = $this->pageHeight - $topY;
2985        // Walk clockwise starting at the top-left straight edge.
2986        $stream->saveGraphicsState();
2987        $stream->setFillColorRGB($fill->r, $fill->g, $fill->b);
2988        $stream->moveTo($x + $rtl, $topPdfY);
2989        $stream->lineTo($x + $width - $rtr, $topPdfY);
2990        if ($rtr > 0.0) {
2991            $stream->curveTo(
2992                $x + $width - $rtr + $rtr * $k,
2993                $topPdfY,
2994                $x + $width,
2995                $topPdfY - $rtr + $rtr * $k,
2996                $x + $width,
2997                $topPdfY - $rtr,
2998            );
2999        }
3000        $stream->lineTo($x + $width, $bottomPdfY + $rbr);
3001        if ($rbr > 0.0) {
3002            $stream->curveTo(
3003                $x + $width,
3004                $bottomPdfY + $rbr - $rbr * $k,
3005                $x + $width - $rbr + $rbr * $k,
3006                $bottomPdfY,
3007                $x + $width - $rbr,
3008                $bottomPdfY,
3009            );
3010        }
3011        $stream->lineTo($x + $rbl, $bottomPdfY);
3012        if ($rbl > 0.0) {
3013            $stream->curveTo(
3014                $x + $rbl - $rbl * $k,
3015                $bottomPdfY,
3016                $x,
3017                $bottomPdfY + $rbl - $rbl * $k,
3018                $x,
3019                $bottomPdfY + $rbl,
3020            );
3021        }
3022        $stream->lineTo($x, $topPdfY - $rtl);
3023        if ($rtl > 0.0) {
3024            $stream->curveTo(
3025                $x,
3026                $topPdfY - $rtl + $rtl * $k,
3027                $x + $rtl - $rtl * $k,
3028                $topPdfY,
3029                $x + $rtl,
3030                $topPdfY,
3031            );
3032        }
3033        $stream->closePath();
3034        $stream->fill();
3035        $stream->restoreGraphicsState();
3036    }
3037}