Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.43% covered (warning)
85.43%
956 / 1119
37.50% covered (danger)
37.50%
21 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
InlineLayout
85.43% covered (warning)
85.43%
956 / 1119
37.50% covered (danger)
37.50%
21 / 56
843.14
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
 layout
94.58% covered (success)
94.58%
157 / 166
0.00% covered (danger)
0.00%
0 / 1
31.15
 applyVerticalLineShift
73.81% covered (warning)
73.81%
31 / 42
0.00% covered (danger)
0.00%
0 / 1
9.15
 layoutAtomicOnly
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
1 / 1
19
 resolveTabSize
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 lineBounds
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 applyTextOverflow
78.79% covered (warning)
78.79%
26 / 33
0.00% covered (danger)
0.00%
0 / 1
12.15
 trimFragmentToFit
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
5.01
 applyTextAlign
89.47% covered (warning)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
18.38
 lineUsedWidth
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 isRtlDirection
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveLogicalTextAlign
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 isTextJustifyNone
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 textAlignLastKeyword
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 textAlignKeyword
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 applyTextTransform
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
11.30
 toFullWidth
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 capitalizeWords
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 resolveLineHeight
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 lineHeightMultiplier
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 resolveTextIndent
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 whiteSpaceKeyword
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 shiftFragments
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 justifyFragments
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 collectTokens
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
6
 walkInline
94.89% covered (success)
94.89%
130 / 137
0.00% covered (danger)
0.00%
0 / 1
24.08
 lineHeightFor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 boxFontSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveWeight
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
13.12
 resolveStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveOpenTypeFeatures
72.86% covered (warning)
72.86%
51 / 70
0.00% covered (danger)
0.00%
0 / 1
26.22
 iterateKeywords
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 resolveStretch
26.67% covered (danger)
26.67%
4 / 15
0.00% covered (danger)
0.00%
0 / 1
68.79
 resolveBoxFont
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 decorationLines
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
12.00
 mergeDecorationLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveBackground
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveDecorationColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isBreakAll
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
10.66
 resolveVerticalAlign
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 tokeniseText
93.94% covered (success)
93.94%
62 / 66
0.00% covered (danger)
0.00%
0 / 1
23.12
 splitBidiRuns
88.00% covered (warning)
88.00%
44 / 50
0.00% covered (danger)
0.00%
0 / 1
17.50
 applyLetterSpacing
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 resolveLetterSpacing
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveWordSpacing
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 applyWordSpacing
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 isWordSeparatorAt
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 dominantFontSize
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 atomicLength
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 atomicBorderWidth
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
19.12
 atomicIsBorderBoxSizing
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 atomicAspectRatio
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 atomicNumeric
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveAtomicMinMax
38.46% covered (danger)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
27.88
 clampAtomicReplaced
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
10.27
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7use Phpdftk\Css\Cascade\WritingMode;
8use Phpdftk\Css\Value\Length;
9use Phpdftk\HtmlToPdf\Box\AtomicInlineBox;
10use Phpdftk\HtmlToPdf\Box\Box;
11use Phpdftk\HtmlToPdf\Box\InlineBox;
12use Phpdftk\HtmlToPdf\Box\LineBreakBox;
13use Phpdftk\HtmlToPdf\Box\TextBox;
14use Phpdftk\Text\LineBreaker;
15use Phpdftk\Text\LineBreakKind;
16use Phpdftk\Text\Shaper;
17use Phpdftk\Text\ShapedGlyph;
18use Phpdftk\Text\ShapedRun;
19use Phpdftk\Text\ShapingContext;
20
21/**
22 * Inline formatting context layout — Phase 1F.2 (text shaping + greedy
23 * line wrapping).
24 *
25 * Walks a parent block's inline children (InlineBox / AtomicInlineBox /
26 * TextBox subtrees), shapes their text via `phpdftk/text`'s Shaper, finds
27 * line-break opportunities via UAX #14, and greedily fits the resulting
28 * fragments into line boxes that respect the parent's content width.
29 *
30 * Phase-1 simplifications:
31 *  - Single font per inline run (the layout context's `defaultFont`).
32 *    Font runs / fallback live alongside paragraph shaping in Phase 2.
33 *  - Bidi reorder is the bidi engine's job; this layout reads logical
34 *    order and lays runs left-to-right.
35 *  - Atomic inline boxes (replaced elements, inline-block) are treated as
36 *    fixed-size boxes; sizing comes from the box style (width / height).
37 *  - Line height defaults to `1.2 × font-size` per CSS Inline §3.
38 *
39 * When no font is available in the layout context, this layout falls back
40 * to producing zero-height (no-op) lines so block layout can still
41 * complete end-to-end on the test surface.
42 */
43final class InlineLayout
44{
45    /**
46     * Captured from the LayoutContext at the start of each `layout()`
47     * call so `walkInline` can re-resolve fonts for nested `<code>` etc.
48     * without threading the resolver through every recursive parameter.
49     */
50    private ?FontResolver $currentFontResolver = null;
51
52    /**
53     * Captured at the top of each `layout()` call so atomic-inline
54     * width-percentage resolution (CSS Sizing 3 §6.2: % on an inline
55     * box resolves against its containing block) can see the basis
56     * without threading the value through `collectTokens` /
57     * `walkInline`.
58     */
59    private float $currentAvailableWidth = 0.0;
60
61    public function __construct(
62        private readonly Shaper $shaper = new Shaper(),
63        private readonly LineBreaker $lineBreaker = new LineBreaker(),
64    ) {}
65
66    /**
67     * Lay out the inline children of `$parent` within `$availableWidth`,
68     * starting at Y = 0 in the parent's coordinate space. Returns the
69     * generated line boxes (positions relative to the parent's content
70     * area) and the total height consumed.
71     *
72     * @return array{list<LineBox>, float} (lines, totalHeight)
73     */
74    public function layout(Box $parent, float $availableWidth, LayoutContext $context): array
75    {
76        $this->currentFontResolver = $context->fontResolver;
77        $this->currentAvailableWidth = $availableWidth;
78        if ($availableWidth <= 0.0) {
79            return [[], 0.0];
80        }
81        // Resolve the shaping font + post-match synthetic-effect flags via
82        // CSS Fonts 4 §6 weight/style matching. When a real face matches
83        // the cascaded weight/style, `isBold`/`isItalic` come back false so
84        // the painter doesn't double up with fake-bold / fake-italic.
85        $parentMatch = $this->resolveBoxFont($parent, $context->defaultFont);
86        $font = $parentMatch['font'];
87        $fontSize = $this->dominantFontSize($parent, $context);
88        if ($font === null) {
89            // No font means text shaping is impossible — but inline
90            // replaced content (img, svg, math, inline-block divs)
91            // doesn't need a font for layout. Fall back to a minimal
92            // atomic-only pass that pulls width / height off the
93            // cascade so paintImage and the foreign-content painters
94            // (paintInlineSvg, paintInlineMath) see real geometry
95            // instead of the (0, 0, 0, 0) box the full early return
96            // used to produce. Closes #39 for the atomic-content
97            // case; text-bearing documents still need an explicit
98            // default font.
99            return $this->layoutAtomicOnly($parent);
100        }
101        $shapingCtx = new ShapingContext($font, $fontSize, features: $this->resolveOpenTypeFeatures($parent));
102        $lineHeight = $this->resolveLineHeight($parent, $fontSize);
103        $lineHeightMultiplier = $this->lineHeightMultiplier($parent);
104        $whiteSpace = $this->whiteSpaceKeyword($parent);
105        // CSS Text 3 §4 — wrap permission table:
106        //   normal / pre-wrap / pre-line / break-spaces → allow soft wrap
107        //   nowrap / pre → no soft wrap
108        $allowSoftWrap = $whiteSpace !== 'nowrap' && $whiteSpace !== 'pre';
109        // CSS Text 3 §4 — leading-whitespace collapse table:
110        //   normal / nowrap / pre-line → collapse leading whitespace
111        //   pre / pre-wrap / break-spaces → preserve leading whitespace
112        // (`break-spaces` is `pre-wrap` plus the additional rule that
113        // every preserved space is a wrap opportunity. Leading-edge
114        // semantics match `pre-wrap` — both keep the leading run.)
115        $collapseLeadingWhitespace = $whiteSpace !== 'pre'
116            && $whiteSpace !== 'pre-wrap'
117            && $whiteSpace !== 'break-spaces';
118        $collapseInternalWhitespace = $whiteSpace === 'normal' || $whiteSpace === 'nowrap';
119
120        $letterSpacing = $this->resolveLetterSpacing($parent);
121        $wordSpacing = $this->resolveWordSpacing($parent);
122        // CSS Text 3 §5.5 — when `pre-wrap` / `break-spaces` is in
123        // effect, trailing whitespace at the end of a line hangs. For
124        // that to work, the tokeniser must emit a separate token for
125        // each whitespace run; otherwise UAX-14's `XX<ws>` bundle
126        // hides the trailing ws from the line fitter.
127        $hangsTrailingWhitespacePreCompute = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces';
128        $tokens = $this->collectTokens(
129            $parent,
130            $shapingCtx,
131            $collapseInternalWhitespace,
132            $letterSpacing,
133            $wordSpacing,
134            baselineShift: 0.0,
135            href: null,
136            isBold: $parentMatch['isBold'],
137            isItalic: $parentMatch['isItalic'],
138            decorationLines: $this->decorationLines($parent),
139            textColor: $this->resolveColor($parent),
140            backgroundColor: null,
141            linkTitle: null,
142            decorationColor: $this->resolveDecorationColor($parent),
143            splitWsBoundaries: $hangsTrailingWhitespacePreCompute,
144        );
145        if ($tokens === []) {
146            return [[], 0.0];
147        }
148
149        // CSS Text 3 §3.1: `text-indent` shifts the first inline box of the
150        // first formatted line. Length resolves directly; Percentage resolves
151        // against the block's content width (our `$availableWidth`).
152        $textIndent = $this->resolveTextIndent($parent, $availableWidth);
153
154        // CSS 2.1 §9.5.3 — line boxes shorten on the side(s) where a
155        // float is currently active. Compute the per-line (left, right)
156        // bounds against the float context each time we start a new line.
157        $bounds = $this->lineBounds($parent, $availableWidth, $context, 0.0);
158        $lines = [];
159        $currentFragments = [];
160        // CSS Text 3 §5.5 — when in `pre-wrap` / `break-spaces`, trailing
161        // whitespace at the end of a line hangs (renders past the line edge
162        // with zero contribution to line measurement). We mirror that by
163        // tracking which fragments in `$currentFragments` were whitespace
164        // so we can drop them from the line when wrapping.
165        $hangsTrailingWhitespace = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces';
166        /** @var list<bool> $currentFragmentIsWs */
167        $currentFragmentIsWs = [];
168        $currentX = $bounds['left'] + $textIndent;
169        $lineMaxRight = $bounds['right'];
170        $atLineStart = true;
171        $y = 0.0;
172        foreach ($tokens as $token) {
173            $width = $token['shapedRun']->totalAdvance;
174            $isMandatory = $token['kind'] === LineBreakKind::Mandatory;
175
176            if ($collapseLeadingWhitespace && $token['isWhitespace'] && $atLineStart) {
177                // Leading whitespace at a line start is collapsed.
178                continue;
179            }
180            if ($allowSoftWrap
181                && $currentX + $width > $lineMaxRight
182                && $currentFragments !== []
183            ) {
184                // Wrap before placing this token.
185                // For `pre-wrap` / `break-spaces`: trailing whitespace at the
186                // end of the current line "hangs" — drop those fragments so
187                // they don't push the line width and don't get re-emitted on
188                // the next line. The overflowing whitespace token that
189                // triggered this wrap also hangs (we drop it below).
190                if ($hangsTrailingWhitespace) {
191                    while ($currentFragmentIsWs !== [] && end($currentFragmentIsWs) === true) {
192                        array_pop($currentFragments);
193                        array_pop($currentFragmentIsWs);
194                    }
195                    // Re-derive currentX from the surviving fragments so
196                    // line-width-based math (e.g. alignment) sees the
197                    // post-hang width.
198                    $currentX = $currentFragments === []
199                        ? $bounds['left'] + $textIndent
200                        : end($currentFragments)->x + end($currentFragments)->width;
201                }
202                $effective = $this->lineHeightFor($currentFragments, $lineHeight, $lineHeightMultiplier);
203                $lines[] = new LineBox($y, $effective, $currentFragments);
204                $y += $effective;
205                $currentFragments = [];
206                $currentFragmentIsWs = [];
207                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y);
208                $currentX = $bounds['left'];
209                $lineMaxRight = $bounds['right'];
210                $atLineStart = true;
211                if ($collapseLeadingWhitespace && $token['isWhitespace']) {
212                    // Drop whitespace at start of next line.
213                    continue;
214                }
215                if ($hangsTrailingWhitespace && $token['isWhitespace']) {
216                    // The overflowing whitespace hangs on the prior line —
217                    // don't carry it to the new line.
218                    continue;
219                }
220            }
221            $currentFragments[] = new InlineFragment(
222                $currentX,
223                $width,
224                $token['shapedRun'],
225                $token['baselineShift'] ?? 0.0,
226                $token['href'] ?? null,
227                $token['isBold'] ?? false,
228                $token['isItalic'] ?? false,
229                $token['decorationLines'] ?? [],
230                $token['textColor'] ?? null,
231                $token['backgroundColor'] ?? null,
232                $token['linkTitle'] ?? null,
233                $token['decorationColor'] ?? null,
234                (bool) $token['isWhitespace'],
235            );
236            $currentFragmentIsWs[] = (bool) $token['isWhitespace'];
237            // Side-channel: AtomicInlineBox positions get committed back to
238            // the box's geometry so the painter can draw images / replaced
239            // content at the right spot. CSS Inline 3 §4.5: for the default
240            // `vertical-align: baseline`, the inline-block's baseline aligns
241            // with the parent line's baseline; for replaced elements like
242            // `<img>` the baseline is the bottom of the box. So position
243            // the box so its *bottom* sits at the line's baseline (line.y +
244            // ascent of the shaping font) — same convention the painter
245            // uses for text baselines.
246            $atomic = $token['atomicBox'] ?? null;
247            if ($atomic !== null) {
248                // The token captured the box-sizing-resolved content +
249                // outer widths; resolve the heights with the same
250                // semantics here. Falling back to width when height is
251                // unset keeps the square-replaced-element default
252                // (img with intrinsic ratio) the existing tests rely on.
253                $heightValue = $atomic->style->get('height');
254                $declaredHeight = $heightValue instanceof Length
255                    ? $heightValue->value
256                    : 0.0;
257                $atomicPadTop = self::atomicLength($atomic->style->get('padding-top'));
258                $atomicPadBottom = self::atomicLength($atomic->style->get('padding-bottom'));
259                $atomicBorderTop = self::atomicBorderWidth($atomic->style, 'top');
260                $atomicBorderBottom = self::atomicBorderWidth($atomic->style, 'bottom');
261                $verticalInset = $atomicPadTop + $atomicPadBottom + $atomicBorderTop + $atomicBorderBottom;
262                $atomicBorderBox = $token['atomicBorderBox'] ?? false;
263                if ($declaredHeight > 0.0) {
264                    if ($atomicBorderBox) {
265                        $atomicContentHeight = max(0.0, $declaredHeight - $verticalInset);
266                        $atomicOuterHeight = $declaredHeight;
267                    } else {
268                        $atomicContentHeight = $declaredHeight;
269                        $atomicOuterHeight = $declaredHeight + $verticalInset;
270                    }
271                } else {
272                    // Height auto with no intrinsic-from-cascade fallback;
273                    // square the outer to the (already-resolved) outer
274                    // width so the historical "no height = square box"
275                    // contract holds for tests that rely on it.
276                    $atomicOuterHeight = $width;
277                    $atomicContentHeight = max(0.0, $atomicOuterHeight - $verticalInset);
278                }
279                $shapedRun = $token['shapedRun'];
280                $atomicFont = $shapedRun->font;
281                $atomicUpem = max(1, $atomicFont->unitsPerEm);
282                $atomicAscent = ($atomicFont->ascent / $atomicUpem) * $shapedRun->fontSizePt;
283                // Outer box top-left is `(currentX, y + ascent - outerHeight)`;
284                // the content box sits inside the padding + border edges.
285                $atomicContentWidth = $token['atomicContentWidth'] ?? $width;
286                $atomicPadLeft = $token['atomicPadLeft'] ?? 0.0;
287                $atomicBorderLeft = $token['atomicBorderLeft'] ?? 0.0;
288                $atomicPadRight = $token['atomicPadRight'] ?? 0.0;
289                $atomicBorderRight = $token['atomicBorderRight'] ?? 0.0;
290                $atomicMarginLeft = $token['atomicMarginLeft'] ?? 0.0;
291                $atomicMarginRight = $token['atomicMarginRight'] ?? 0.0;
292                $atomicMarginTop = $token['atomicMarginTop'] ?? 0.0;
293                $atomicMarginBottom = $token['atomicMarginBottom'] ?? 0.0;
294                // `$currentX` sits at the item's margin-box start; step past
295                // the left margin (plus border + padding) to the content box.
296                $atomic->geometry->x = $parent->geometry->x + $currentX
297                    + $atomicMarginLeft + $atomicBorderLeft + $atomicPadLeft;
298                // CSS 2.2 §10.8 — the inline-block's baseline (no in-flow line
299                // boxes here) is its bottom margin edge, so the margin box
300                // bottom aligns with the line baseline; the border box bottom
301                // sits a bottom-margin above it.
302                $atomic->geometry->y = $parent->geometry->y + $y
303                    + $atomicAscent - $atomicOuterHeight - $atomicMarginBottom
304                    + $atomicBorderTop + $atomicPadTop;
305                $atomic->geometry->width = $atomicContentWidth;
306                $atomic->geometry->height = $atomicContentHeight;
307                $atomic->geometry->paddingLeft = $atomicPadLeft;
308                $atomic->geometry->paddingRight = $atomicPadRight;
309                $atomic->geometry->paddingTop = $atomicPadTop;
310                $atomic->geometry->paddingBottom = $atomicPadBottom;
311                $atomic->geometry->borderLeft = $atomicBorderLeft;
312                $atomic->geometry->borderRight = $atomicBorderRight;
313                $atomic->geometry->borderTop = $atomicBorderTop;
314                $atomic->geometry->borderBottom = $atomicBorderBottom;
315                $atomic->geometry->marginLeft = $atomicMarginLeft;
316                $atomic->geometry->marginRight = $atomicMarginRight;
317                $atomic->geometry->marginTop = $atomicMarginTop;
318                $atomic->geometry->marginBottom = $atomicMarginBottom;
319            }
320            $currentX += $width;
321            $atLineStart = false;
322            if ($isMandatory) {
323                $effective = $this->lineHeightFor($currentFragments, $lineHeight, $lineHeightMultiplier);
324                $lines[] = new LineBox($y, $effective, $currentFragments);
325                $y += $effective;
326                $currentFragments = [];
327                $currentFragmentIsWs = [];
328                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y);
329                $currentX = $bounds['left'];
330                $lineMaxRight = $bounds['right'];
331                $atLineStart = true;
332            }
333        }
334        if ($currentFragments !== []) {
335            $effective = $this->lineHeightFor($currentFragments, $lineHeight, $lineHeightMultiplier);
336            $lines[] = new LineBox($y, $effective, $currentFragments);
337            $y += $effective;
338        }
339
340        // CSS UI 3 §6.2: `text-overflow: ellipsis` truncates each line's
341        // tail when its content exceeds the available width. Runs before
342        // text-align so the alignment math operates on the truncated rect.
343        $lines = $this->applyTextOverflow($lines, $availableWidth, $parent, $shapingCtx, $letterSpacing);
344
345        $lines = $this->applyTextAlign($lines, $availableWidth, $parent);
346        // CSS Writing Modes 4 §3 — for a vertical-mode block container
347        // hosting an inline formatting context, lines should sit at the
348        // block-start edge of the container's content area:
349        //  - `vrl` / `sideways-rl`: block-start = right edge → shift the
350        //    line's fragments rightward by (availableWidth - lineWidth)
351        //    so the visible content lands at the right.
352        //  - `vlr` / `sideways-lr`: block-start = left edge → no shift.
353        //
354        // Phase-4 scaffold: text glyphs still lay out horizontally
355        // (per the current Shaper-driven flow) — full vertical text
356        // shaping with rotated glyphs comes in a later commit. The
357        // immediate win is that single-line atomic inline content
358        // (`<img>`, inline-block divs) lands at the visually-correct
359        // block-start edge in `vrl` containers.
360        $lines = $this->applyVerticalLineShift($lines, $availableWidth, $parent);
361        return [$lines, $y];
362    }
363
364    /**
365     * @param list<LineBox> $lines
366     * @return list<LineBox>
367     */
368    private function applyVerticalLineShift(array $lines, float $availableWidth, Box $parent): array
369    {
370        $wm = WritingMode::fromStyle($parent->style);
371        if (!$wm->isVertical()) {
372            return $lines;
373        }
374        // CSS WM 4 §3 — transpose the horizontal IFC to the vertical block flow.
375        // Each line becomes a vertical column: the line's INLINE offset (the
376        // fragment's x — where `text-indent` / `text-align` placed it) moves to
377        // the vertical axis (`line.y`, which the painter reads as the column's
378        // vertical start), and columns stack along the BLOCK axis (physical x),
379        // each occupying the line's cross-size (`height`). `vlr` / `sideways-lr`
380        // grow rightward from the left content edge; `vrl` / `sideways-rl` grow
381        // leftward from the right edge (block-start = right). The painter's
382        // vertical branch (paintFragment) rotates glyphs 90° CW, reads
383        // `fragment.x` as the column's left edge and `line.y` as its top.
384        //
385        // Phase B increment 1: single-fragment lines (single glyph / single
386        // atomic — the text-indent / text-align near-miss cluster) transpose
387        // exactly. Multi-fragment vertical advance is increment 2; those lines
388        // keep the legacy `vrl` right-shift (or pass through for `vlr`).
389        $rtl = $wm->blockDirection() === -1;
390        $out = [];
391        $cumBlock = 0.0;
392        foreach ($lines as $line) {
393            // Transpose needs a real cross-size to place the column along the
394            // block axis; a degenerate line box (`line-height: 0`) has none, so
395            // fall back to the legacy path rather than divide the block axis by
396            // zero-height columns.
397            if (count($line->fragments) === 1 && $line->height > 0.0) {
398                $f = $line->fragments[0];
399                $blockLeft = $rtl
400                    ? max(0.0, $availableWidth - $cumBlock - $line->height)
401                    : $cumBlock;
402                $out[] = new LineBox($f->x, $line->height, [
403                    new InlineFragment(
404                        $blockLeft,
405                        $f->width,
406                        $f->shapedRun,
407                        $f->baselineShift,
408                        $f->href,
409                        $f->isBold,
410                        $f->isItalic,
411                        $f->decorationLines,
412                        $f->textColor,
413                        $f->backgroundColor,
414                        $f->linkTitle,
415                        $f->decorationColor,
416                        $f->isWhitespace,
417                    ),
418                ]);
419                $cumBlock += $line->height;
420                continue;
421            }
422            if ($rtl) {
423                $lineWidth = $line->totalWidth();
424                $shift = $availableWidth - $lineWidth - $cumBlock;
425                $out[] = new LineBox(
426                    0.0,
427                    $line->height,
428                    $shift > 0.0 ? $this->shiftFragments($line->fragments, $shift) : $line->fragments,
429                );
430            } else {
431                $out[] = $line;
432            }
433            $cumBlock += $line->height;
434        }
435        return $out;
436    }
437
438    /**
439     * Fallback layout for blocks whose inline-formatting context has
440     * no shaping font available. Closes the geometry gap from #39 for
441     * documents that contain only inline replaced content (img, svg,
442     * math, inline-block divs) and never registered a default font.
443     *
444     * Walks the parent's direct children, reads cascaded width /
445     * height off each AtomicInlineBox, sets its geometry, and
446     * advances a cursor. No line wrapping — atoms that overflow the
447     * available width stack anyway (the painter clips per-page).
448     * Non-atomic children (TextBox, InlineBox, LineBreakBox) are
449     * skipped because they need a font to lay out.
450     *
451     * Returns no LineBox: the painter doesn't iterate lines to find
452     * an AtomicInlineBox, it walks the box tree top-down, so setting
453     * geometry directly is sufficient for paintImage's namespace
454     * dispatch (paintInlineSvg / paintInlineMath) to render.
455     *
456     * @return array{list<LineBox>, float}
457     */
458    private function layoutAtomicOnly(Box $parent): array
459    {
460        $currentX = 0.0;
461        $maxHeight = 0.0;
462        // Line-box tracking: atomics flow left-to-right and wrap to a new
463        // line when the next one won't fit in the IFC available width
464        // (CSS 2.1 §9.4.2). `$lineTop` is the current line's top offset
465        // from the parent's content top; `$lineHeight` its tallest box.
466        $lineTop = 0.0;
467        $lineHeight = 0.0;
468        foreach ($parent->children as $child) {
469            if (!($child instanceof AtomicInlineBox)) {
470                continue;
471            }
472            $widthValue = $child->style->get('width');
473            // A percentage width (e.g. `<img width="100%">`) resolves
474            // against the inline-formatting-context's available width —
475            // the same basis the shaped path uses. Without this a
476            // percentage-sized replaced element collapses to 0 in the
477            // no-font fallback and paints nothing.
478            $width = match (true) {
479                $widthValue instanceof Length && $widthValue->value > 0.0
480                    => $widthValue->value,
481                $widthValue instanceof \Phpdftk\Css\Value\Percentage && $widthValue->value > 0.0
482                    => $this->currentAvailableWidth * ($widthValue->value / 100.0),
483                default => 0.0,
484            };
485            if ($width <= 0.0) {
486                // Atomic-content painters have their own intrinsic-
487                // size fallbacks (svg attrs / viewBox; math defaults).
488                // Don't second-guess them here — leave geometry at 0
489                // so the painter's fallback chain still runs.
490                continue;
491            }
492            // Border + padding insets. This fallback historically ignored
493            // borders entirely, so an inline-block sized partly by its
494            // border/padding (e.g. the CSS2 `border-{top,bottom}-width`
495            // tests, or a square with a uniform border) collapsed to its
496            // content box and painted no border. Fold both axes' insets
497            // into the box: grow the height/advance by the inset and offset
498            // the content box so the border box's top-left edge stays at
499            // the box origin. Both axes are handled symmetrically so a
500            // uniformly-bordered inline-block keeps a centred content box.
501            $padTop = self::atomicLength($child->style->get('padding-top'));
502            $padBottom = self::atomicLength($child->style->get('padding-bottom'));
503            $padLeft = self::atomicLength($child->style->get('padding-left'));
504            $padRight = self::atomicLength($child->style->get('padding-right'));
505            $borderTop = self::atomicBorderWidth($child->style, 'top');
506            $borderBottom = self::atomicBorderWidth($child->style, 'bottom');
507            $borderLeft = self::atomicBorderWidth($child->style, 'left');
508            $borderRight = self::atomicBorderWidth($child->style, 'right');
509            $verticalInset = $padTop + $padBottom + $borderTop + $borderBottom;
510            $horizontalInset = $padLeft + $padRight + $borderLeft + $borderRight;
511            // CSS 2.2 §10.8 — an inline-block's margins take part in layout:
512            // horizontal margins add to the inline advance it occupies, and
513            // its margin box (with vertical margins) is what contributes to
514            // line height. This no-font fallback previously dropped them, so
515            // adjacent inline-blocks touched instead of showing their gaps.
516            $marginTop = self::atomicLength($child->style->get('margin-top'));
517            $marginBottom = self::atomicLength($child->style->get('margin-bottom'));
518            $marginLeft = self::atomicLength($child->style->get('margin-left'));
519            $marginRight = self::atomicLength($child->style->get('margin-right'));
520            // CSS Sizing 3 §6.2 — under `box-sizing: border-box` the
521            // declared width/height already includes the inset, so the
522            // content box shrinks; otherwise the inset grows the outer box.
523            $borderBox = self::atomicIsBorderBoxSizing($child->style);
524            $contentWidth = $borderBox ? max(0.0, $width - $horizontalInset) : $width;
525            $outerWidth = $contentWidth + $horizontalInset;
526            $heightValue = $child->style->get('height');
527            // A unitless `0` cascades as Integer, not Length, but is still
528            // an explicit length (the only valid unitless one).
529            $declaredHeight = match (true) {
530                $heightValue instanceof Length => $heightValue->value,
531                $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value,
532                default => null,
533            };
534            if ($declaredHeight !== null) {
535                // Explicit height (including `0`) is authoritative.
536                $declaredHeight = max(0.0, $declaredHeight);
537                $contentHeight = $borderBox
538                    ? max(0.0, $declaredHeight - $verticalInset)
539                    : $declaredHeight;
540            } else {
541                // Auto height with no measurable content (no shaping font):
542                // square the OUTER box to its width — the historical
543                // contract the shaped path also follows — but never below
544                // the inset, since a border box can't be shorter than its
545                // own border + padding (the CSS2 border-width tests).
546                $contentHeight = max(0.0, max($outerWidth, $verticalInset) - $verticalInset);
547            }
548            // CSS Sizing 3 §5.2 — replaced min/max-width / -height clamps
549            // (incl. min/max-content transferred through the intrinsic
550            // ratio). Without this `max-width: min-content` on a sized
551            // <canvas>/<img> is ignored.
552            [$contentWidth, $contentHeight] = $this->clampAtomicReplaced($child->style, $contentWidth, $contentHeight);
553            $outerWidth = $contentWidth + $horizontalInset;
554            $outerHeight = $contentHeight + $verticalInset;
555            // The item's inline footprint is its margin box; the block-axis
556            // footprint (line-height contribution) is likewise the margin box.
557            $outerAdvance = $marginLeft + $outerWidth + $marginRight;
558            $marginBoxHeight = $marginTop + $outerHeight + $marginBottom;
559            // CSS 2.1 §9.4.2 — wrap to a new line when the current line
560            // already holds content and this box would overflow the IFC
561            // available width. (A single box wider than the line still
562            // gets its own line rather than an infinite loop.)
563            if ($currentX > 0.0
564                && $currentX + $outerAdvance > $this->currentAvailableWidth + 0.01
565            ) {
566                $lineTop += $lineHeight;
567                $currentX = 0.0;
568                $lineHeight = 0.0;
569            }
570            // Offset the content box by the left/top margin + inset so the
571            // border box's top-left edge sits inside the margin box.
572            $child->geometry->x = $parent->geometry->x + $currentX + $marginLeft + $borderLeft + $padLeft;
573            $child->geometry->y = $parent->geometry->y + $lineTop + $marginTop + $borderTop + $padTop;
574            $child->geometry->width = $contentWidth;
575            $child->geometry->height = $contentHeight;
576            $child->geometry->paddingTop = $padTop;
577            $child->geometry->paddingBottom = $padBottom;
578            $child->geometry->paddingLeft = $padLeft;
579            $child->geometry->paddingRight = $padRight;
580            $child->geometry->borderTop = $borderTop;
581            $child->geometry->borderBottom = $borderBottom;
582            $child->geometry->borderLeft = $borderLeft;
583            $child->geometry->borderRight = $borderRight;
584            $child->geometry->marginTop = $marginTop;
585            $child->geometry->marginBottom = $marginBottom;
586            $child->geometry->marginLeft = $marginLeft;
587            $child->geometry->marginRight = $marginRight;
588            $currentX += $outerAdvance;
589            if ($marginBoxHeight > $lineHeight) {
590                $lineHeight = $marginBoxHeight;
591            }
592            if ($lineTop + $lineHeight > $maxHeight) {
593                $maxHeight = $lineTop + $lineHeight;
594            }
595        }
596        return [[], $maxHeight];
597    }
598
599    /**
600     * Resolve CSS Text 3 §11.2 `tab-size` to an integer space count.
601     *
602     * - `<integer>` / `<number>`: direct space count.
603     * - `<length>`: divide by a glyph-space advance estimate
604     *   (0.25 × font-size — a sane default for sans-serif) and round
605     *   to the nearest integer ≥ 0. This is an approximation since
606     *   tab-stop alignment isn't implemented, but converts a
607     *   length-based author intent to the closest N-space expansion.
608     * - Anything else (`auto`, unknown keywords): the spec default 8.
609     */
610    private function resolveTabSize(Box $box): int
611    {
612        $value = $box->style->get('tab-size');
613        if ($value instanceof \Phpdftk\Css\Value\Integer) {
614            return max(0, $value->value);
615        }
616        if ($value instanceof \Phpdftk\Css\Value\Number) {
617            return max(0, (int) round($value->value));
618        }
619        if ($value instanceof \Phpdftk\Css\Value\Length) {
620            $fontSize = $this->dominantFontSize($box, new LayoutContext(
621                0.0,
622                0.0,
623                0.0,
624                0.0,
625                new \Phpdftk\Css\Cascade\LengthContext(),
626            ));
627            $spaceAdvance = max(0.1, $fontSize * 0.25);
628            return max(0, (int) round($value->value / $spaceAdvance));
629        }
630        return 8;
631    }
632
633    /**
634     * Resolve the left and right inset of a line at relative-Y `$y`
635     * against the active {@see FloatContext}. Returns offsets relative
636     * to the parent's content-edge X — so `left` is the line's start X
637     * within the parent's box, and `right` is the line's max-end X.
638     *
639     * Without floats this is just `[0, $availableWidth]`. With a left
640     * float overlapping the line, `left` rises; with a right float,
641     * `right` falls.
642     *
643     * Phase-1 simplification: samples at the line's top edge only.
644     * Browsers conceptually sample across the full line range and take
645     * the most-constrained bounds.
646     *
647     * @return array{left: float, right: float}
648     */
649    private function lineBounds(Box $parent, float $availableWidth, LayoutContext $context, float $relY): array
650    {
651        $floatCtx = $context->floatContext;
652        if ($floatCtx === null) {
653            return ['left' => 0.0, 'right' => $availableWidth];
654        }
655        $parentX = $parent->geometry->x;
656        $parentY = $parent->geometry->y;
657        $absY = $parentY + $relY;
658        $absLeft = $floatCtx->leftEdgeAt($absY, $parentX);
659        $absRight = $floatCtx->rightEdgeAt($absY, $parentX + $availableWidth);
660        return [
661            'left' => max(0.0, $absLeft - $parentX),
662            'right' => max(0.0, $absRight - $parentX),
663        ];
664    }
665
666    /**
667     * Drop fragments from each overflowing line until an ellipsis glyph
668     * fits at the end. Only applies when the parent's `text-overflow` is
669     * `ellipsis`; the default `clip` keyword silently lets the content
670     * overflow (matching the no-op CSS spec behaviour).
671     *
672     * @param list<LineBox> $lines
673     * @return list<LineBox>
674     */
675    private function applyTextOverflow(
676        array $lines,
677        float $availableWidth,
678        Box $parent,
679        ShapingContext $shapingCtx,
680        float $letterSpacing,
681    ): array {
682        $value = $parent->style->get('text-overflow');
683        if (!($value instanceof \Phpdftk\Css\Value\Keyword)
684            || strtolower($value->name) !== 'ellipsis'
685        ) {
686            return $lines;
687        }
688        $ellipsis = $this->shaper->shapeRun("\u{2026}", $shapingCtx);
689        if ($ellipsis->glyphs === []) {
690            return $lines;
691        }
692        if ($letterSpacing !== 0.0) {
693            $ellipsis = $this->applyLetterSpacing($ellipsis, $letterSpacing);
694        }
695        $ellipsisWidth = $ellipsis->totalAdvance;
696
697        $out = [];
698        foreach ($lines as $line) {
699            if ($line->totalWidth() <= $availableWidth) {
700                $out[] = $line;
701                continue;
702            }
703            // Drop fragments from the end until the remaining content +
704            // ellipsis fits, then trim glyphs off the trailing fragment
705            // if it still overflows. Per CSS UI 3 §6.2 the ellipsis sits
706            // immediately adjacent to the last visible glyph.
707            $fragments = $line->fragments;
708            $cutoff = $availableWidth - $ellipsisWidth;
709            while ($fragments !== []) {
710                $last = $fragments[array_key_last($fragments)];
711                if ($last->x + $last->width <= $cutoff) {
712                    break;
713                }
714                // Try a per-glyph trim of the trailing fragment before
715                // discarding it whole — `ppppp` overflowing `400px /
716                // 100px-per-glyph` keeps `ppp` + ellipsis, not just an
717                // orphan ellipsis at x = 0.
718                $trimmed = $this->trimFragmentToFit($last, $cutoff);
719                if ($trimmed !== null) {
720                    $fragments[array_key_last($fragments)] = $trimmed;
721                    break;
722                }
723                array_pop($fragments);
724            }
725            if ($fragments === []) {
726                // Nothing fits before the ellipsis; emit just the ellipsis
727                // at x = 0 so the user sees something.
728                $fragments[] = new InlineFragment(0.0, $ellipsisWidth, $ellipsis);
729            } else {
730                $last = $fragments[array_key_last($fragments)];
731                $tail = $last->x + $last->width;
732                $fragments[] = new InlineFragment($tail, $ellipsisWidth, $ellipsis);
733            }
734            $out[] = new LineBox($line->y, $line->height, $fragments);
735        }
736        return $out;
737    }
738
739    /**
740     * Trim glyphs off the tail of `$fragment` until its right edge sits
741     * at or before `$cutoff`. Returns a new `InlineFragment` with the
742     * shorter `ShapedRun`, or `null` when not a single glyph fits
743     * (caller should drop the whole fragment instead).
744     */
745    private function trimFragmentToFit(InlineFragment $fragment, float $cutoff): ?InlineFragment
746    {
747        $shaped = $fragment->shapedRun;
748        if ($shaped->glyphs === []) {
749            return null;
750        }
751        $maxWidth = max(0.0, $cutoff - $fragment->x);
752        $keptGlyphs = [];
753        $total = 0.0;
754        foreach ($shaped->glyphs as $g) {
755            if ($total + $g->advanceX > $maxWidth + 0.001) {
756                break;
757            }
758            $keptGlyphs[] = $g;
759            $total += $g->advanceX;
760        }
761        if ($keptGlyphs === []) {
762            return null;
763        }
764        $newShaped = new ShapedRun(
765            $shaped->font,
766            $shaped->fontSizePt,
767            $shaped->direction,
768            $keptGlyphs,
769            $total,
770        );
771        return new InlineFragment(
772            $fragment->x,
773            $total,
774            $newShaped,
775            $fragment->baselineShift,
776            $fragment->href,
777            $fragment->isBold,
778            $fragment->isItalic,
779            $fragment->decorationLines,
780            $fragment->textColor,
781            $fragment->backgroundColor,
782            $fragment->linkTitle,
783            $fragment->decorationColor,
784        );
785    }
786
787    /**
788     * Apply the parent's `text-align` to each line: `start` / `left` (default,
789     * no-op), `center`, `end` / `right`, or `justify`. Justify is approximated
790     * for the last line as left-aligned per CSS Text 3 §7.3 ("the last line
791     * of a block, and any line ending with a forced line break, is start-
792     * aligned"); inter-fragment justification on non-final lines distributes
793     * extra space evenly across the gaps between fragments.
794     *
795     * @param list<LineBox> $lines
796     * @return list<LineBox>
797     */
798    private function applyTextAlign(array $lines, float $availableWidth, Box $parent): array
799    {
800        $align = $this->textAlignKeyword($parent);
801        $alignLast = $this->textAlignLastKeyword($parent, $align);
802        // CSS Writing Modes 4 §3 — `text-align` aligns along the INLINE axis.
803        // In a vertical writing mode the inline axis is vertical, so alignment
804        // slack is measured against the container's inline size (its physical
805        // height), not the block-axis `availableWidth` the horizontal model
806        // was handed. (Increment 1 transposes the resulting inline offset onto
807        // the vertical axis downstream in `applyVerticalLineShift`.)
808        $wm = WritingMode::fromStyle($parent->style);
809        // The container's geometry height isn't committed yet during inline
810        // layout, but its cascaded `height` is already px-resolved in style
811        // (like `resolveTextIndent` reads). Use it as the vertical inline size.
812        $parentHeight = $parent->style->get('height');
813        $inlineExtent = $wm->isVertical() && $parentHeight instanceof Length && $parentHeight->value > 0.0
814            ? $parentHeight->value
815            : $availableWidth;
816        // CSS Text 3 §7.2: `justify-all` is `justify` for every line
817        // including the trailing one. Normalise to `justify` for the
818        // body lines and force the last-line alignment to `justify`
819        // too (the `textAlignLastKeyword` fallback would otherwise
820        // start-align the last line for plain `justify`).
821        if ($align === 'justify-all') {
822            $align = 'justify';
823            $alignLast = 'justify';
824        }
825        // CSS Text 3 §7.5: `text-justify: none` disables justification.
826        // A `justify` text-align falls through to start-alignment.
827        if ($this->isTextJustifyNone($parent)) {
828            if ($align === 'justify') {
829                $align = 'start';
830            }
831            if ($alignLast === 'justify') {
832                $alignLast = 'start';
833            }
834        }
835        // CSS Text 3 §7.1 — resolve direction-relative `start` / `end`
836        // against the parent's writing direction. `start` is the
837        // inline-start edge (left in LTR, right in RTL); `end` is the
838        // inline-end edge (right in LTR, left in RTL). The physical
839        // `left` / `right` / `center` values pass through unchanged.
840        $isRtl = $this->isRtlDirection($parent);
841        $align = $this->resolveLogicalTextAlign($align, $isRtl);
842        $alignLast = $this->resolveLogicalTextAlign($alignLast, $isRtl);
843        if ($align === 'left') {
844            if ($alignLast === 'left' || $alignLast === 'auto') {
845                return $lines;
846            }
847        }
848        $count = count($lines);
849        $out = [];
850        foreach ($lines as $i => $line) {
851            // CSS Text 3 §5.5 — trailing whitespace at the end of a
852            // line hangs (zero-width for line measurement). For
853            // alignment slack this means we measure the line's
854            // visible content edge, NOT the full fragment tail.
855            $used = $this->lineUsedWidth($line);
856            $slack = $inlineExtent - $used;
857            if ($slack <= 0.0) {
858                $out[] = $line;
859                continue;
860            }
861            $isLast = $i === $count - 1;
862            $effective = $isLast ? $alignLast : $align;
863            $newFragments = match ($effective) {
864                'center' => $this->shiftFragments($line->fragments, $slack / 2.0),
865                'right' => $this->shiftFragments($line->fragments, $slack),
866                'justify' => $this->justifyFragments($line->fragments, $slack),
867                default => $line->fragments,
868            };
869            $out[] = new LineBox($line->y, $line->height, $newFragments);
870        }
871        return $out;
872    }
873
874    /**
875     * Compute the line's content edge for alignment, excluding any
876     * trailing whitespace fragments (CSS Text 3 §5.5). Used by
877     * `applyTextAlign` so the centre / right shift respects the
878     * visible content's right edge, not the hung-whitespace tail.
879     */
880    private function lineUsedWidth(LineBox $line): float
881    {
882        $right = 0.0;
883        $fragments = $line->fragments;
884        // Walk backwards skipping trailing whitespace fragments.
885        $lastVisible = count($fragments) - 1;
886        while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) {
887            $lastVisible--;
888        }
889        for ($i = 0; $i <= $lastVisible; $i++) {
890            $edge = $fragments[$i]->x + $fragments[$i]->width;
891            if ($edge > $right) {
892                $right = $edge;
893            }
894        }
895        return $right;
896    }
897
898    /**
899     * Read the parent's cascaded `direction` and report whether it
900     * resolves to `rtl`. Defaults to LTR when the property is
901     * missing or the value isn't a Keyword the spec recognises.
902     */
903    private function isRtlDirection(Box $parent): bool
904    {
905        $value = $parent->style->get('direction');
906        return $value instanceof \Phpdftk\Css\Value\Keyword
907            && strtolower($value->name) === 'rtl';
908    }
909
910    /**
911     * Map a CSS `text-align` keyword to its physical equivalent.
912     * `start` → `left` in LTR, `right` in RTL; `end` → `right` in
913     * LTR, `left` in RTL. The physical keywords pass through.
914     */
915    private function resolveLogicalTextAlign(string $align, bool $isRtl): string
916    {
917        return match ($align) {
918            'start' => $isRtl ? 'right' : 'left',
919            'end' => $isRtl ? 'left' : 'right',
920            default => $align,
921        };
922    }
923
924    /**
925     * CSS Text 3 §7.5 — `true` when the parent declares
926     * `text-justify: none`, in which case the justify branches of
927     * `text-align` and `text-align-last` collapse to start-alignment.
928     */
929    private function isTextJustifyNone(Box $parent): bool
930    {
931        $value = $parent->style->get('text-justify');
932        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
933            return false;
934        }
935        return strtolower($value->name) === 'none';
936    }
937
938    /**
939     * Resolve CSS Text 3 §7.4 `text-align-last`. `auto` (initial)
940     * inherits the block-aligned behaviour: when text-align is
941     * `justify` the last line is start-aligned, otherwise it matches
942     * text-align. Explicit values override.
943     */
944    private function textAlignLastKeyword(Box $parent, string $align): string
945    {
946        $value = $parent->style->get('text-align-last');
947        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
948            return 'auto';
949        }
950        $lower = strtolower($value->name);
951        if ($lower === 'auto') {
952            // text-align: justify → last line is start-aligned per spec
953            // (CSS Text 3 §7.4); `justify-all` is handled by the
954            // caller before this resolution runs.
955            return $align === 'justify' ? 'start' : $align;
956        }
957        // `text-align-last: justify-all` doesn't appear in any spec
958        // grammar — only `text-align: justify-all` exists. Treat any
959        // stray value as plain `justify` so the trailing line still
960        // gets the fully-justified shifting.
961        if ($lower === 'justify-all') {
962            return 'justify';
963        }
964        return $lower;
965    }
966
967    private function textAlignKeyword(Box $parent): string
968    {
969        $value = $parent->style->get('text-align');
970        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
971            return strtolower($value->name);
972        }
973        return 'start';
974    }
975
976    /**
977     * Apply CSS Text 3 §2 `text-transform` to a text run before shaping.
978     * `uppercase` / `lowercase` are full case mappings via `mb_strtoupper` /
979     * `mb_strtolower`; `capitalize` upper-cases the first grapheme of each
980     * whitespace-separated word; `full-width` / `full-size-kana` and other
981     * Phase-2 transforms fall through unchanged.
982     */
983    private function applyTextTransform(string $text, Box $box): string
984    {
985        $value = $box->style->get('text-transform');
986        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
987            return $text;
988        }
989        return match (strtolower($value->name)) {
990            'uppercase' => mb_strtoupper($text, 'UTF-8'),
991            'lowercase' => mb_strtolower($text, 'UTF-8'),
992            'capitalize' => $this->capitalizeWords($text),
993            // CSS Text 4 §2.1.4 — `full-width` maps the ASCII range
994            // U+0021..U+007E to the Unicode full-width forms
995            // U+FF01..U+FF5E, and ASCII space U+0020 to the
996            // ideographic space U+3000. Useful for monospace-like
997            // CJK alignment.
998            'full-width' => $this->toFullWidth($text),
999            default => $text,
1000        };
1001    }
1002
1003    private function toFullWidth(string $text): string
1004    {
1005        $out = '';
1006        foreach (mb_str_split($text, 1, 'UTF-8') as $ch) {
1007            $cp = mb_ord($ch, 'UTF-8');
1008            if ($cp === false) {
1009                $out .= $ch;
1010                continue;
1011            }
1012            $out .= match (true) {
1013                $cp === 0x0020 => mb_chr(0x3000, 'UTF-8'),
1014                $cp >= 0x0021 && $cp <= 0x007E => mb_chr($cp + 0xFEE0, 'UTF-8'),
1015                default => $ch,
1016            };
1017        }
1018        return $out;
1019    }
1020
1021    private function capitalizeWords(string $text): string
1022    {
1023        // Split on whitespace runs, capitalize the first codepoint of each
1024        // non-empty word, and rejoin with the original separators.
1025        $parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
1026        if ($parts === false) {
1027            return $text;
1028        }
1029        $out = '';
1030        foreach ($parts as $part) {
1031            if ($part === '' || preg_match('/^\s+$/u', $part) === 1) {
1032                $out .= $part;
1033                continue;
1034            }
1035            $first = mb_substr($part, 0, 1, 'UTF-8');
1036            $rest = mb_substr($part, 1, null, 'UTF-8');
1037            $out .= mb_strtoupper($first, 'UTF-8') . $rest;
1038        }
1039        return $out;
1040    }
1041
1042    /**
1043     * Resolve CSS Inline 3 §3 `line-height`:
1044     *  - `normal` → font-dependent multiplier (1.2 for Latin until proper
1045     *    OS/2 typo-metrics-driven line-height ships).
1046     *  - `<number>` → multiplier of font-size; value inherits as the number
1047     *    so children re-resolve against their own size.
1048     *  - `<length>` → absolute, already in px after `Cascade::resolveLengths`.
1049     *  - `<percentage>` → percentage of the element's own font-size.
1050     */
1051    private function resolveLineHeight(Box $parent, float $fontSize): float
1052    {
1053        $value = $parent->style->get('line-height');
1054        if ($value instanceof \Phpdftk\Css\Value\Keyword
1055            && strtolower($value->name) === 'normal'
1056        ) {
1057            return $fontSize * 1.2;
1058        }
1059        if ($value instanceof \Phpdftk\Css\Value\Number
1060            || $value instanceof \Phpdftk\Css\Value\Integer
1061        ) {
1062            return $fontSize * $value->value;
1063        }
1064        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1065            return $fontSize * ($value->value / 100.0);
1066        }
1067        if ($value instanceof Length) {
1068            return $value->value;
1069        }
1070        return $fontSize * 1.2;
1071    }
1072
1073    /**
1074     * The per-font-size multiplier for a `line-height` that scales with
1075     * each inline box's own font size — `normal` (1.2) and `<number>`
1076     * (the number) — or `null` when the value is an absolute `<length>`
1077     * (or `<percentage>`, which CSS computes to an absolute length).
1078     *
1079     * A larger inline child grows the line box by `childFontSize ×
1080     * multiplier` for the scalable forms; for the absolute forms the
1081     * authored length applies regardless of font size, so the line box
1082     * does not scale up with a larger child.
1083     */
1084    private function lineHeightMultiplier(Box $parent): ?float
1085    {
1086        $value = $parent->style->get('line-height');
1087        if ($value instanceof \Phpdftk\Css\Value\Keyword
1088            && strtolower($value->name) === 'normal'
1089        ) {
1090            return 1.2;
1091        }
1092        if ($value instanceof \Phpdftk\Css\Value\Number
1093            || $value instanceof \Phpdftk\Css\Value\Integer
1094        ) {
1095            return $value->value;
1096        }
1097        // Absent / unrecognised → treat as the initial `normal`.
1098        if (!($value instanceof Length)
1099            && !($value instanceof \Phpdftk\Css\Value\Percentage)
1100        ) {
1101            return 1.2;
1102        }
1103        return null;
1104    }
1105
1106    /**
1107     * Resolve the parent's `text-indent` CSS value against the available
1108     * width. Length resolves directly; Percentage resolves against the
1109     * block's content width per CSS Text 3 §3.1; everything else falls to 0.
1110     */
1111    private function resolveTextIndent(Box $parent, float $availableWidth): float
1112    {
1113        $value = $parent->style->get('text-indent');
1114        if ($value instanceof Length) {
1115            return $value->value;
1116        }
1117        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1118            return $availableWidth * ($value->value / 100.0);
1119        }
1120        return 0.0;
1121    }
1122
1123    private function whiteSpaceKeyword(Box $parent): string
1124    {
1125        $value = $parent->style->get('white-space');
1126        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1127            return strtolower($value->name);
1128        }
1129        return 'normal';
1130    }
1131
1132    /**
1133     * @param list<InlineFragment> $fragments
1134     * @return list<InlineFragment>
1135     */
1136    private function shiftFragments(array $fragments, float $dx): array
1137    {
1138        $out = [];
1139        foreach ($fragments as $f) {
1140            $out[] = new InlineFragment($f->x + $dx, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor, $f->isWhitespace);
1141        }
1142        return $out;
1143    }
1144
1145    /**
1146     * Spread the slack across the gaps between fragments (CSS Text 3 §7.3
1147     * `justify-content` approximation for inter-word distribution).
1148     *
1149     * @param list<InlineFragment> $fragments
1150     * @return list<InlineFragment>
1151     */
1152    private function justifyFragments(array $fragments, float $slack): array
1153    {
1154        // CSS Text 3 §7.5 — trailing whitespace at the end of a line
1155        // is excluded from justification (it hangs). Skip trailing
1156        // whitespace fragments when counting gaps and when shifting.
1157        $lastVisible = count($fragments) - 1;
1158        while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) {
1159            $lastVisible--;
1160        }
1161        if ($lastVisible < 1) {
1162            return $fragments;
1163        }
1164        $gaps = $lastVisible;
1165        $delta = $slack / $gaps;
1166        $out = [];
1167        foreach ($fragments as $i => $f) {
1168            // Trailing-ws fragments pinned to whatever the last
1169            // visible fragment's right edge becomes — they aren't
1170            // shifted (they hang past the line edge).
1171            $shift = $i <= $lastVisible ? $i * $delta : $lastVisible * $delta;
1172            $out[] = new InlineFragment($f->x + $shift, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor, $f->isWhitespace);
1173        }
1174        return $out;
1175    }
1176
1177    /**
1178     * Tokenise the inline subtree at line-break opportunities and shape
1179     * each token. Each token records its width, its source kind
1180     * (whitespace / non-whitespace), and whether it closes a mandatory
1181     * break.
1182     *
1183     * @param list<string> $decorationLines
1184     * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}>
1185     */
1186    private function collectTokens(
1187        Box $parent,
1188        ShapingContext $shapingCtx,
1189        bool $collapseInternal,
1190        float $letterSpacing,
1191        float $wordSpacing,
1192        float $baselineShift,
1193        ?string $href,
1194        bool $isBold,
1195        bool $isItalic,
1196        array $decorationLines,
1197        ?\Phpdftk\Css\Value\Color $textColor,
1198        ?\Phpdftk\Css\Value\Color $backgroundColor,
1199        ?string $linkTitle,
1200        ?\Phpdftk\Css\Value\Color $decorationColor,
1201        bool $splitWsBoundaries = false,
1202    ): array {
1203        $out = [];
1204        foreach ($parent->children as $child) {
1205            $this->walkInline(
1206                $child,
1207                $shapingCtx,
1208                $out,
1209                $collapseInternal,
1210                $letterSpacing,
1211                $wordSpacing,
1212                $baselineShift,
1213                $href,
1214                $isBold,
1215                $isItalic,
1216                $decorationLines,
1217                $textColor,
1218                $backgroundColor,
1219                $linkTitle,
1220                $decorationColor,
1221                $splitWsBoundaries,
1222            );
1223        }
1224        // CSS 2.1 §16.6.1 — collapse runs of whitespace across the
1225        // *entire* inline tree, not per text node. The per-TextBox
1226        // collapse in `walkInline` only sees one node at a time, so
1227        // `<span>a </span><span> b</span>` arrives here as two
1228        // adjacent whitespace tokens. Drop the second of any
1229        // consecutive whitespace pair (only when collapsing is on).
1230        if ($collapseInternal) {
1231            $deduped = [];
1232            $prevWasWs = false;
1233            foreach ($out as $token) {
1234                if ($token['isWhitespace'] && $prevWasWs) {
1235                    continue;
1236                }
1237                $deduped[] = $token;
1238                $prevWasWs = $token['isWhitespace'];
1239            }
1240            $out = $deduped;
1241        }
1242        return $out;
1243    }
1244
1245    /**
1246     * @param list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}> $tokens
1247     * @param list<string> $decorationLines
1248     */
1249    private function walkInline(
1250        Box $box,
1251        ShapingContext $shapingCtx,
1252        array &$tokens,
1253        bool $collapseInternal,
1254        float $letterSpacing,
1255        float $wordSpacing,
1256        float $baselineShift,
1257        ?string $href,
1258        bool $isBold,
1259        bool $isItalic,
1260        array $decorationLines,
1261        ?\Phpdftk\Css\Value\Color $textColor,
1262        ?\Phpdftk\Css\Value\Color $backgroundColor,
1263        ?string $linkTitle,
1264        ?\Phpdftk\Css\Value\Color $decorationColor,
1265        bool $splitWsBoundaries = false,
1266    ): void {
1267        if ($box instanceof TextBox) {
1268            $text = $box->text;
1269            if ($collapseInternal) {
1270                // CSS Text 3 §4.1.1: in `normal` / `nowrap`, runs of
1271                // whitespace collapse to a single space. Newlines collapse
1272                // alongside spaces / tabs / form feeds.
1273                $text = preg_replace('/[ \t\n\r\f]+/', ' ', $text) ?? $text;
1274            } else {
1275                // CSS Text 3 §11.2 — in white-space modes that preserve
1276                // tabs (`pre`, `pre-wrap`), each U+0009 expands to N
1277                // spaces. Phase-1 simplification: fixed expansion
1278                // instead of tab-stop alignment (which would require
1279                // tracking column position across mid-text breaks).
1280                $tabSize = $this->resolveTabSize($box);
1281                if ($tabSize > 0) {
1282                    $text = str_replace("\t", str_repeat(' ', $tabSize), $text);
1283                } else {
1284                    $text = str_replace("\t", '', $text);
1285                }
1286            }
1287            // CSS Text 3 §2: `text-transform` runs before shaping so the
1288            // shaper sees the transformed codepoints.
1289            $text = $this->applyTextTransform($text, $box);
1290            $breakAll = $this->isBreakAll($box);
1291            foreach ($this->tokeniseText($text, $shapingCtx, $letterSpacing, $wordSpacing, $breakAll, $splitWsBoundaries) as $token) {
1292                $token['baselineShift'] = $baselineShift;
1293                $token['href'] = $href;
1294                $token['isBold'] = $isBold;
1295                $token['isItalic'] = $isItalic;
1296                $token['decorationLines'] = $decorationLines;
1297                $token['textColor'] = $textColor;
1298                $token['backgroundColor'] = $backgroundColor;
1299                $token['linkTitle'] = $linkTitle;
1300                $token['decorationColor'] = $decorationColor;
1301                $tokens[] = $token;
1302            }
1303            return;
1304        }
1305        if ($box instanceof LineBreakBox) {
1306            // `<br>` — hard break that survives `white-space: normal`'s
1307            // collapsing. Emit a zero-width mandatory-break token so the
1308            // line-fitter closes the current line and starts a new one.
1309            $tokens[] = [
1310                'shapedRun' => new ShapedRun(
1311                    $shapingCtx->font,
1312                    $shapingCtx->fontSizePt,
1313                    $shapingCtx->direction,
1314                    [],
1315                    0.0,
1316                ),
1317                'isWhitespace' => false,
1318                'kind' => LineBreakKind::Mandatory,
1319            ];
1320            return;
1321        }
1322        if ($box instanceof AtomicInlineBox) {
1323            // Resolve the box's intrinsic *outer* horizontal advance —
1324            // what the line breaker needs — and the *content-box*
1325            // width that the painter draws into. The two differ
1326            // whenever the atomic carries padding / border, and they
1327            // diverge further under `box-sizing: border-box` (CSS
1328            // Sizing 3 §6.2): under border-box, the declared `width`
1329            // already includes padding + border, so the content
1330            // shrinks by that inset instead of the outer growing.
1331            $widthValue = $box->style->get('width');
1332            // CSS Sizing 3 §6.2 — percentages on atomic-inline
1333            // `width` resolve against the inline-formatting-context's
1334            // containing block (= the `availableWidth` parameter).
1335            // Without this, `<canvas style="width: 100%">` collapses
1336            // to 0 instead of stretching to fill the line.
1337            $declaredWidth = match (true) {
1338                $widthValue instanceof Length => $widthValue->value,
1339                $widthValue instanceof \Phpdftk\Css\Value\Percentage
1340                    => $this->currentAvailableWidth * ($widthValue->value / 100.0),
1341                default => 0.0,
1342            };
1343            $atomicPadLeft = self::atomicLength($box->style->get('padding-left'));
1344            $atomicPadRight = self::atomicLength($box->style->get('padding-right'));
1345            $atomicBorderLeft = self::atomicBorderWidth($box->style, 'left');
1346            $atomicBorderRight = self::atomicBorderWidth($box->style, 'right');
1347            $horizontalInset = $atomicPadLeft + $atomicPadRight + $atomicBorderLeft + $atomicBorderRight;
1348            // CSS 2.2 §10.8 — an inline-block's margins participate in layout:
1349            // horizontal margins add to the inline advance it occupies on the
1350            // line; vertical margins are part of its margin box (which
1351            // determines its line-height contribution). Previously dropped, so
1352            // adjacent inline-blocks touched instead of showing their gaps.
1353            $atomicMarginLeft = self::atomicLength($box->style->get('margin-left'));
1354            $atomicMarginRight = self::atomicLength($box->style->get('margin-right'));
1355            $atomicMarginTop = self::atomicLength($box->style->get('margin-top'));
1356            $atomicMarginBottom = self::atomicLength($box->style->get('margin-bottom'));
1357            $atomicBorderBox = self::atomicIsBorderBoxSizing($box->style);
1358            if ($declaredWidth > 0.0) {
1359                if ($atomicBorderBox) {
1360                    // Declared width is the border-box; content shrinks
1361                    // by the inset, outer stays at the declared value.
1362                    $atomicContentWidth = max(0.0, $declaredWidth - $horizontalInset);
1363                    $atomicOuterWidth = $declaredWidth;
1364                } else {
1365                    // Declared width is the content-box; outer grows by
1366                    // the inset.
1367                    $atomicContentWidth = $declaredWidth;
1368                    $atomicOuterWidth = $declaredWidth + $horizontalInset;
1369                }
1370            } else {
1371                // No declared width (e.g. `width: auto`) — defer to
1372                // intrinsic sizing the painter or downstream layout
1373                // resolves. Outer = content = 0 so the line-breaker
1374                // doesn't allocate any space; the painter falls back
1375                // to its own intrinsic-size path.
1376                $atomicContentWidth = 0.0;
1377                $atomicOuterWidth = $horizontalInset;
1378            }
1379            $tokens[] = [
1380                'shapedRun' => new ShapedRun(
1381                    $shapingCtx->font,
1382                    $shapingCtx->fontSizePt,
1383                    $shapingCtx->direction,
1384                    [],
1385                    // Advance = margin-box inline size so the line-breaker and
1386                    // fitter allocate the item's full horizontal footprint.
1387                    $atomicMarginLeft + $atomicOuterWidth + $atomicMarginRight,
1388                ),
1389                'isWhitespace' => false,
1390                'kind' => LineBreakKind::Allowed,
1391                'baselineShift' => $baselineShift,
1392                'href' => $href,
1393                'isBold' => $isBold,
1394                'isItalic' => $isItalic,
1395                'decorationLines' => $decorationLines,
1396                'textColor' => $textColor,
1397                'backgroundColor' => $backgroundColor,
1398                'linkTitle' => $linkTitle,
1399                'decorationColor' => $decorationColor,
1400                'atomicBox' => $box,
1401                'atomicContentWidth' => $atomicContentWidth,
1402                'atomicOuterWidth' => $atomicOuterWidth,
1403                'atomicPadLeft' => $atomicPadLeft,
1404                'atomicPadRight' => $atomicPadRight,
1405                'atomicBorderLeft' => $atomicBorderLeft,
1406                'atomicBorderRight' => $atomicBorderRight,
1407                'atomicMarginLeft' => $atomicMarginLeft,
1408                'atomicMarginRight' => $atomicMarginRight,
1409                'atomicMarginTop' => $atomicMarginTop,
1410                'atomicMarginBottom' => $atomicMarginBottom,
1411                'atomicBorderBox' => $atomicBorderBox,
1412            ];
1413            return;
1414        }
1415        if ($box instanceof InlineBox) {
1416            // CSS Inline 3 §4.5 `vertical-align: sub` / `super` shifts the
1417            // child fragments' baselines. Composes with any outer shift so
1418            // nested `<sup><sub>x</sub></sup>` still has a sensible effect.
1419            $boxShift = $this->resolveVerticalAlign($box, $shapingCtx->fontSizePt);
1420            // HTML 4 / 5 `<a href="...">` — descendants inherit the href so
1421            // the painter can emit a `/Link` annotation per fragment. The
1422            // companion `<a title="...">` lands on the annotation's
1423            // `/Contents` for hover tooltips.
1424            $childHref = $href;
1425            $childTitle = $linkTitle;
1426            if ($box->element !== null
1427                && strtolower($box->element->localName) === 'a'
1428            ) {
1429                $linkUrl = $box->element->getAttribute('href');
1430                if ($linkUrl !== null && $linkUrl !== '') {
1431                    $childHref = $linkUrl;
1432                    $title = $box->element->getAttribute('title');
1433                    $childTitle = $title === null || $title === '' ? null : $title;
1434                }
1435            }
1436            // Inline-level emphasis: resolve this box's own weight/style
1437            // request against the FontResolver. A real face match flips
1438            // the per-fragment fake-bold / fake-italic flags off; an
1439            // unmatched request OR no faceMap entry leaves the cascade's
1440            // synthetic-effect flags on so the painter draws the fallback.
1441            // OR with the inherited flags so `<strong><em>X</em></strong>`
1442            // keeps both effects even when only one resolves to a real face.
1443            $boxMatch = $this->resolveBoxFont($box, $shapingCtx->font);
1444            $childBold = $boxMatch['isBold'] || $isBold;
1445            $childItalic = $boxMatch['isItalic'] || $isItalic;
1446            // CSS Text Decoration 4 §2 says decorations set on an inline
1447            // apply to all in-flow descendant text. Union the box's
1448            // decoration lines with whatever the enclosing context set.
1449            $childDeco = $this->mergeDecorationLines($decorationLines, $this->decorationLines($box));
1450            // §3: `text-decoration-color` doesn't inherit, but when an
1451            // inline element sets a colour explicitly that colour applies
1452            // to its descendant fragments' decorations. A child only
1453            // overrides if it sets its own value — otherwise it keeps the
1454            // closest ancestor's choice.
1455            $childDecoColor = $this->resolveDecorationColor($box) ?? $decorationColor;
1456            // The inline box's own cascaded `color` overrides the inherited
1457            // one — `<a>` gets blue from the UA stylesheet even when its
1458            // parent is black.
1459            $childColor = $this->resolveColor($box) ?? $textColor;
1460            // `background-color` is not inherited but propagates downward
1461            // for inline rendering — every descendant fragment of a
1462            // `<mark>` should carry the yellow rect.
1463            $boxBg = $this->resolveBackground($box);
1464            $childBg = $boxBg ?? $backgroundColor;
1465            // Mixed-size inline runs: if this inline carries a different
1466            // computed `font-size` than the active shaping context, build
1467            // a per-subtree context so descendants shape at the right size.
1468            // Same for `font-family` — when an inline names a font that's
1469            // registered in the FontResolver, switch the shaping font.
1470            $childCtx = $shapingCtx;
1471            $boxFontSize = $this->boxFontSize($box) ?? $shapingCtx->fontSizePt;
1472            $boxFont = $boxMatch['font'] ?? $shapingCtx->font;
1473            $fontSizeChanged = abs($boxFontSize - $shapingCtx->fontSizePt) > 0.001;
1474            $fontChanged = $boxFont !== $shapingCtx->font;
1475            if ($fontSizeChanged || $fontChanged) {
1476                $childCtx = new ShapingContext($boxFont, $boxFontSize);
1477            }
1478            foreach ($box->children as $c) {
1479                $this->walkInline(
1480                    $c,
1481                    $childCtx,
1482                    $tokens,
1483                    $collapseInternal,
1484                    $letterSpacing,
1485                    $wordSpacing,
1486                    $baselineShift + $boxShift,
1487                    $childHref,
1488                    $childBold,
1489                    $childItalic,
1490                    $childDeco,
1491                    $childColor,
1492                    $childBg,
1493                    $childTitle,
1494                    $childDecoColor,
1495                    $splitWsBoundaries,
1496                );
1497            }
1498        }
1499    }
1500
1501    /**
1502     * Per CSS Inline 3 §3, the line box's used height is the maximum of the
1503     * inline heights it contains. Use the parent's resolved line-height as
1504     * the baseline, then grow if a fragment carries a larger font.
1505     *
1506     * A fragment with a larger font than the parent contributes its own
1507     * line height. For a scalable `line-height` (`normal` / `<number>`,
1508     * which inherit per-font-size) that contribution is `fragmentFontSize
1509     * × $multiplier`; for an absolute `<length>` / `<percentage>`
1510     * (`$multiplier === null`) the authored line-height applies regardless
1511     * of font size, so the line box does not grow with a larger child.
1512     *
1513     * The old code used a hardcoded `max($parentLineHeight, maxFontSize ×
1514     * 1.2)`, which ignored any explicit `line-height` below `normal`:
1515     * `font: 80px/1` resolves the parent line-height to 80, yet
1516     * `max(80, 80 × 1.2)` forced the line box to 96.
1517     *
1518     * @param list<InlineFragment> $fragments
1519     */
1520    private function lineHeightFor(array $fragments, float $parentLineHeight, ?float $multiplier): float
1521    {
1522        if ($multiplier === null) {
1523            // Absolute line-height: the used value is the authored length
1524            // for every inline box on the line, independent of font size.
1525            return $parentLineHeight;
1526        }
1527        $maxFontSize = 0.0;
1528        foreach ($fragments as $f) {
1529            if ($f->shapedRun->fontSizePt > $maxFontSize) {
1530                $maxFontSize = $f->shapedRun->fontSizePt;
1531            }
1532        }
1533        return max($parentLineHeight, $maxFontSize * $multiplier);
1534    }
1535
1536    /**
1537     * The box's cascaded `font-size` in user-space units, or null when the
1538     * cascade didn't produce a `Length` (the cascade should always produce
1539     * one after `resolveLengths`; null is just a safety fallback).
1540     */
1541    private function boxFontSize(Box $box): ?float
1542    {
1543        $value = $box->style->get('font-size');
1544        return $value instanceof Length ? $value->value : null;
1545    }
1546
1547    /**
1548     * Resolve the cascaded `font-weight` to a numeric value in the CSS
1549     * Fonts 4 1–1000 range. Keywords map per spec: `normal` → 400,
1550     * `bold` / `bolder` → 700, `lighter` → 100.
1551     */
1552    private function resolveWeight(Box $box): int
1553    {
1554        $value = $box->style->get('font-weight');
1555        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1556            return match (strtolower($value->name)) {
1557                'bold', 'bolder' => 700,
1558                'lighter' => 100,
1559                default => 400,
1560            };
1561        }
1562        if ($value instanceof \Phpdftk\Css\Value\Integer
1563            || $value instanceof \Phpdftk\Css\Value\Number
1564        ) {
1565            return max(1, min(1000, (int) $value->value));
1566        }
1567        return 400;
1568    }
1569
1570    /**
1571     * Resolve the cascaded `font-style` to a lower-case keyword in the
1572     * `normal` | `italic` | `oblique` set. Unrecognised values fall back
1573     * to `normal`.
1574     */
1575    private function resolveStyle(Box $box): string
1576    {
1577        $value = $box->style->get('font-style');
1578        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1579            $lc = strtolower($value->name);
1580            if (in_array($lc, ['italic', 'oblique'], true)) {
1581                return $lc;
1582            }
1583        }
1584        return 'normal';
1585    }
1586
1587    /**
1588     * Resolve the cascaded `font-variant-*` family + `font-feature-
1589     * settings` into the OpenType feature-tag list the shaper
1590     * consumes. Implements the mappings in CSS Fonts 4 §6 from
1591     * each high-level value keyword to the underlying OpenType
1592     * GSUB / GPOS feature tags.
1593     *
1594     * Tags from font-variant-* are emitted as bare tag strings
1595     * (= "enable"); font-feature-settings entries with a non-1
1596     * integer are emitted as `tag=N` so the shaper can encode
1597     * the variant index. The default `kern liga` baseline is
1598     * always present.
1599     *
1600     * @return list<string>
1601     */
1602    private function resolveOpenTypeFeatures(Box $box): array
1603    {
1604        $tags = ['kern', 'liga'];
1605        $add = function (string $tag) use (&$tags): void {
1606            if (!in_array($tag, $tags, true)) {
1607                $tags[] = $tag;
1608            }
1609        };
1610        $disable = function (string $tag) use (&$tags): void {
1611            $tags = array_values(array_filter($tags, fn(string $t) => $t !== $tag));
1612            $tags[] = $tag . '=0';
1613        };
1614        $variantMap = [
1615            'font-variant-caps' => [
1616                'small-caps' => ['smcp'],
1617                'all-small-caps' => ['smcp', 'c2sc'],
1618                'petite-caps' => ['pcap'],
1619                'all-petite-caps' => ['pcap', 'c2pc'],
1620                'unicase' => ['unic'],
1621                'titling-caps' => ['titl'],
1622            ],
1623            'font-variant-numeric' => [
1624                'lining-nums' => ['lnum'],
1625                'oldstyle-nums' => ['onum'],
1626                'proportional-nums' => ['pnum'],
1627                'tabular-nums' => ['tnum'],
1628                'diagonal-fractions' => ['frac'],
1629                'stacked-fractions' => ['afrc'],
1630                'ordinal' => ['ordn'],
1631                'slashed-zero' => ['zero'],
1632            ],
1633            'font-variant-position' => [
1634                'sub' => ['subs'],
1635                'super' => ['sups'],
1636            ],
1637            'font-variant-east-asian' => [
1638                'jis78' => ['jp78'],
1639                'jis83' => ['jp83'],
1640                'jis90' => ['jp90'],
1641                'jis04' => ['jp04'],
1642                'simplified' => ['smpl'],
1643                'traditional' => ['trad'],
1644                'full-width' => ['fwid'],
1645                'proportional-width' => ['pwid'],
1646                'ruby' => ['ruby'],
1647            ],
1648        ];
1649        foreach ($variantMap as $prop => $kwMap) {
1650            $value = $box->style->get($prop);
1651            foreach ($this->iterateKeywords($value) as $kw) {
1652                foreach ($kwMap[$kw] ?? [] as $tag) {
1653                    $add($tag);
1654                }
1655            }
1656        }
1657        // font-variant-ligatures has both enable / disable forms.
1658        $ligValue = $box->style->get('font-variant-ligatures');
1659        foreach ($this->iterateKeywords($ligValue) as $kw) {
1660            match ($kw) {
1661                'common-ligatures' => $add('liga'),
1662                'no-common-ligatures' => $disable('liga'),
1663                'discretionary-ligatures' => $add('dlig'),
1664                'no-discretionary-ligatures' => $disable('dlig'),
1665                'historical-ligatures' => $add('hlig'),
1666                'no-historical-ligatures' => $disable('hlig'),
1667                'contextual' => $add('calt'),
1668                'no-contextual' => $disable('calt'),
1669                default => null,
1670            };
1671        }
1672        // font-feature-settings — typed values land as
1673        // FontFeatureSettings; pass each entry through.
1674        $fss = $box->style->get('font-feature-settings');
1675        if ($fss instanceof \Phpdftk\Css\Value\FontFeatureSettings) {
1676            foreach ($fss->features as $entry) {
1677                if ($entry->value === 1) {
1678                    $add($entry->tag);
1679                } elseif ($entry->value === 0) {
1680                    $disable($entry->tag);
1681                } else {
1682                    // Variant index — encode as tag=N.
1683                    $add($entry->tag . '=' . $entry->value);
1684                }
1685            }
1686        }
1687        return $tags;
1688    }
1689
1690    /**
1691     * Walk a cascaded value yielding each keyword name it
1692     * carries. Handles bare Keyword and Space-separated ValueList
1693     * forms (the two shapes font-variant-* values arrive in).
1694     *
1695     * @return iterable<string>
1696     */
1697    private function iterateKeywords(?\Phpdftk\Css\Value\Value $value): iterable
1698    {
1699        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1700            yield strtolower($value->name);
1701            return;
1702        }
1703        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
1704            foreach ($value->values as $v) {
1705                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
1706                    yield strtolower($v->name);
1707                }
1708            }
1709        }
1710    }
1711
1712    /**
1713     * Resolve the cascaded `font-stretch` to its percentage value on
1714     * the CSS Fonts 4 §3.4 axis (50..200). Accepts the named keyword
1715     * forms (`condensed`, `expanded`, ...) and bare percentages.
1716     */
1717    private function resolveStretch(Box $box): float
1718    {
1719        $value = $box->style->get('font-stretch');
1720        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1721            return match (strtolower($value->name)) {
1722                'ultra-condensed' => 50.0,
1723                'extra-condensed' => 62.5,
1724                'condensed' => 75.0,
1725                'semi-condensed' => 87.5,
1726                'semi-expanded' => 112.5,
1727                'expanded' => 125.0,
1728                'extra-expanded' => 150.0,
1729                'ultra-expanded' => 200.0,
1730                default => 100.0,
1731            };
1732        }
1733        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1734            return max(50.0, min(200.0, (float) $value->value));
1735        }
1736        return 100.0;
1737    }
1738
1739    /**
1740     * Combined font/weight/style resolution for the box. Picks the
1741     * concrete `OpenTypeData` via {@see FontResolver::resolveMatch()} and
1742     * derives the post-match "still needs synthetic effect" flags by
1743     * comparing the matched face's axes against the requested cascade.
1744     *
1745     * @return array{font: ?\Phpdftk\FontParser\FontFaceData, isBold: bool, isItalic: bool}
1746     */
1747    private function resolveBoxFont(Box $box, ?\Phpdftk\FontParser\FontFaceData $fallback): array
1748    {
1749        $weight = $this->resolveWeight($box);
1750        $style = $this->resolveStyle($box);
1751        $stretch = $this->resolveStretch($box);
1752        $requestBold = $weight >= 600;
1753        $requestItalic = $style !== 'normal';
1754        $resolver = $this->currentFontResolver;
1755        $match = $resolver?->resolveMatch(
1756            $box->style->get('font-family'),
1757            $weight,
1758            $style,
1759            $stretch,
1760        );
1761        $font = $match?->face->data ?? $fallback;
1762        $isBold = $requestBold && ($match === null || !$match->matchesWeight);
1763        $isItalic = $requestItalic && ($match === null || !$match->matchesStyle);
1764        return ['font' => $font, 'isBold' => $isBold, 'isItalic' => $isItalic];
1765    }
1766
1767    /**
1768     * Read the box's cascaded `text-decoration-line` and return the set of
1769     * line keywords it carries — empty list for `none` / unset.
1770     *
1771     * @return list<string>
1772     */
1773    private function decorationLines(Box $box): array
1774    {
1775        $value = $box->style->get('text-decoration-line');
1776        if ($value === null
1777            || ($value instanceof \Phpdftk\Css\Value\Keyword
1778                && strtolower($value->name) === 'none')
1779        ) {
1780            return [];
1781        }
1782        $names = [];
1783        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1784            $names[] = strtolower($value->name);
1785        } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) {
1786            foreach ($value->values as $v) {
1787                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
1788                    $kw = strtolower($v->name);
1789                    if ($kw !== 'none') {
1790                        $names[] = $kw;
1791                    }
1792                }
1793            }
1794        }
1795        return array_values(array_unique(array_filter(
1796            $names,
1797            static fn(string $n): bool => in_array($n, ['underline', 'overline', 'line-through'], true),
1798        )));
1799    }
1800
1801    /**
1802     * Combine outer + inner text-decoration line lists into a deduped list.
1803     *
1804     * @param list<string> $outer
1805     * @param list<string> $inner
1806     * @return list<string>
1807     */
1808    private function mergeDecorationLines(array $outer, array $inner): array
1809    {
1810        return array_values(array_unique(array_merge($outer, $inner)));
1811    }
1812
1813    /**
1814     * Read the box's cascaded `color`, or null when the cascade didn't
1815     * produce a `Color` value (e.g. unresolved keyword fallback).
1816     */
1817    private function resolveColor(Box $box): ?\Phpdftk\Css\Value\Color
1818    {
1819        $value = $box->style->get('color');
1820        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
1821    }
1822
1823    private function resolveBackground(Box $box): ?\Phpdftk\Css\Value\Color
1824    {
1825        $value = $box->style->get('background-color');
1826        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
1827    }
1828
1829    /**
1830     * Read the box's cascaded `text-decoration-color`, or null when the
1831     * property is unset / inherits to the default keyword. CSS Text
1832     * Decoration 4 §3: the property does *not* inherit through inlines,
1833     * so callers explicitly pick whichever closer ancestor set it.
1834     */
1835    private function resolveDecorationColor(Box $box): ?\Phpdftk\Css\Value\Color
1836    {
1837        $value = $box->style->get('text-decoration-color');
1838        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
1839    }
1840
1841    /**
1842     * CSS Text 3 §5 / §6 — soft-wrap opportunities exist between
1843     * every two codepoints under: `word-break: break-all`,
1844     * `overflow-wrap: anywhere`, and `line-break: anywhere`. All
1845     * three are checked here so the line-fitter splits at any
1846     * character regardless of which property the author used.
1847     */
1848    private function isBreakAll(Box $box): bool
1849    {
1850        $wb = $box->style->get('word-break');
1851        if ($wb instanceof \Phpdftk\Css\Value\Keyword && strtolower($wb->name) === 'break-all') {
1852            return true;
1853        }
1854        $ow = $box->style->get('overflow-wrap');
1855        if ($ow instanceof \Phpdftk\Css\Value\Keyword) {
1856            $name = strtolower($ow->name);
1857            // `anywhere` adds break opportunities AND contributes to
1858            // min-content intrinsic sizing. `break-word` (and its
1859            // legacy `word-wrap: break-word` alias, which the cascade
1860            // normalises into `overflow-wrap`) adds the same
1861            // codepoint-level opportunities for line-fitting but does
1862            // NOT change intrinsic sizing — see CSS Text 3 §5.5. For
1863            // the line-fitter both fall into the same "every code-
1864            // point is a break opportunity" path; the min/max-content
1865            // measurer gates on `anywhere` only via
1866            // `intrinsicBreaksAnywhere`.
1867            if ($name === 'anywhere' || $name === 'break-word') {
1868                return true;
1869            }
1870        }
1871        // Legacy `word-wrap: break-word` is the same property under a
1872        // different name. The shorthand expander normalises it into
1873        // `overflow-wrap`, but author CSS that sets `word-wrap`
1874        // directly still lands here — read both.
1875        $ww = $box->style->get('word-wrap');
1876        if ($ww instanceof \Phpdftk\Css\Value\Keyword
1877            && strtolower($ww->name) === 'break-word'
1878        ) {
1879            return true;
1880        }
1881        $lb = $box->style->get('line-break');
1882        if ($lb instanceof \Phpdftk\Css\Value\Keyword && strtolower($lb->name) === 'anywhere') {
1883            return true;
1884        }
1885        return false;
1886    }
1887
1888    /**
1889     * CSS Inline 3 §4.5 `vertical-align`: Phase-1 honours the `sub` and
1890     * `super` keywords, lifting / lowering the fragment's baseline by a
1891     * font-size-relative amount. Browser defaults: `super` ≈ +0.5em lift,
1892     * `sub` ≈ +0.2em drop. Returns the offset in layout-Y space (negative
1893     * lifts, positive drops). All other values (baseline / Length /
1894     * Percentage / top / middle / bottom / text-top / text-bottom) fall
1895     * through to 0 for now — full vertical-align lands with mixed-size
1896     * inline runs.
1897     */
1898    private function resolveVerticalAlign(Box $box, float $fontSize): float
1899    {
1900        $value = $box->style->get('vertical-align');
1901        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
1902            return 0.0;
1903        }
1904        return match (strtolower($value->name)) {
1905            'super' => -$fontSize * 0.5,
1906            'sub' => $fontSize * 0.2,
1907            default => 0.0,
1908        };
1909    }
1910
1911    /**
1912     * Tokenise plain text at UAX #14 break opportunities, shaping each
1913     * resulting segment. Each segment is one token. Whitespace segments
1914     * are tagged so the line-fitter can collapse them at line edges. When
1915     * `$letterSpacing` is non-zero, every shaped glyph's advance is bumped
1916     * by that amount per CSS Text 3 §10 — the painter picks the difference
1917     * up automatically via its TJ-kerning path.
1918     *
1919     * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}>
1920     */
1921    private function tokeniseText(
1922        string $text,
1923        ShapingContext $shapingCtx,
1924        float $letterSpacing,
1925        float $wordSpacing,
1926        bool $breakAll = false,
1927        bool $splitWsBoundaries = false,
1928    ): array {
1929        if ($text === '') {
1930            return [];
1931        }
1932        if ($breakAll) {
1933            // CSS Text 3 §5 `word-break: break-all` — every codepoint is a
1934            // valid break point. Walk UTF-8 codepoints and emit one
1935            // segment per character. Whitespace runs still collapse into
1936            // their own segment so word/letter-spacing logic stays sane.
1937            $segments = [];
1938            $bytes = strlen($text);
1939            $i = 0;
1940            while ($i < $bytes) {
1941                $b = ord($text[$i]);
1942                $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4));
1943                $segments[] = [
1944                    'text' => substr($text, $i, $cpLen),
1945                    'kind' => LineBreakKind::Allowed,
1946                ];
1947                $i += $cpLen;
1948            }
1949        } else {
1950            $breaks = iterator_to_array($this->lineBreaker->breakOpportunities($text), false);
1951            $segments = [];
1952            $start = 0;
1953            foreach ($breaks as $opp) {
1954                if ($opp->offset > $start) {
1955                    $segments[] = ['text' => substr($text, $start, $opp->offset - $start), 'kind' => $opp->kind];
1956                    $start = $opp->offset;
1957                }
1958            }
1959            if ($start < strlen($text)) {
1960                $segments[] = ['text' => substr($text, $start), 'kind' => LineBreakKind::Allowed];
1961            }
1962            // CSS Text 3 §5.5 — for the hanging-trailing-whitespace
1963            // behaviour to take effect under `pre-wrap` / `break-spaces`,
1964            // each contiguous whitespace run must be its own token. UAX-14
1965            // bundles `XX<ws>` into one segment (the break opportunity
1966            // sits at the end of the whitespace), which prevents the
1967            // line-fitter from telling the trailing ws apart from the
1968            // leading word at wrap time. Refine bundled segments here:
1969            // split any segment that mixes ws and non-ws into alternating
1970            // runs. Only applied when the caller opts in — under `normal`
1971            // / `nowrap` the bundled segments are correct (whitespace
1972            // collapses to single spaces that contribute to line width).
1973            if ($splitWsBoundaries) {
1974                $refined = [];
1975                foreach ($segments as $seg) {
1976                    if (preg_match('/[ \t\n\r\f]/', $seg['text']) !== 1
1977                        || preg_match('/[^ \t\n\r\f]/', $seg['text']) !== 1
1978                    ) {
1979                        $refined[] = $seg;
1980                        continue;
1981                    }
1982                    if (preg_match_all('/[ \t\n\r\f]+|[^ \t\n\r\f]+/u', $seg['text'], $m) === false) {
1983                        $refined[] = $seg;
1984                        continue;
1985                    }
1986                    $lastIdx = count($m[0]) - 1;
1987                    foreach ($m[0] as $i => $part) {
1988                        $refined[] = [
1989                            'text' => $part,
1990                            // The original break opportunity sits at
1991                            // the end of the bundled segment — keep
1992                            // that on the last sub-segment; intermediate
1993                            // boundaries get a plain `Allowed`
1994                            // opportunity so the breaker can wrap there
1995                            // too.
1996                            'kind' => $i === $lastIdx ? $seg['kind'] : LineBreakKind::Allowed,
1997                        ];
1998                    }
1999                }
2000                $segments = $refined;
2001            }
2002        }
2003
2004        // CSS Writing Modes 3 §2 / Unicode UAX #9 — split each segment
2005        // at bidi-direction boundaries so a single text node like
2006        // "First שלום world" produces three separate shaping runs
2007        // (LTR-RTL-LTR) the layout can place individually. Without
2008        // this split, the run containing both LTR and RTL characters
2009        // shapes as a single block at one bidi level, miscoordinating
2010        // with browser-emitted swatch reference geometries.
2011        $segments = $this->splitBidiRuns($segments);
2012
2013        $out = [];
2014        foreach ($segments as $seg) {
2015            $isWs = preg_match('/^[ \t\n\r\f]+$/', $seg['text']) === 1;
2016            $runDirection = $seg['direction'] ?? null;
2017            $runCtx = $shapingCtx;
2018            if ($runDirection === 'rtl') {
2019                $runCtx = new ShapingContext(
2020                    $shapingCtx->font,
2021                    $shapingCtx->fontSizePt,
2022                    $shapingCtx->script,
2023                    $shapingCtx->language,
2024                    \Phpdftk\Text\ShapingDirection::Rtl,
2025                    $shapingCtx->features,
2026                );
2027            }
2028            $shaped = $this->shaper->shapeRun($seg['text'], $runCtx);
2029            if ($letterSpacing !== 0.0 && $shaped->glyphs !== []) {
2030                $shaped = $this->applyLetterSpacing($shaped, $letterSpacing);
2031            }
2032            if ($wordSpacing !== 0.0 && $shaped->glyphs !== []) {
2033                // CSS Text 3 §9: `word-spacing` adds advance only at word-
2034                // separator glyphs (U+0020 / U+00A0 at MVP).
2035                $shaped = $this->applyWordSpacing($shaped, $seg['text'], $wordSpacing);
2036            }
2037            $out[] = [
2038                'shapedRun' => $shaped,
2039                'isWhitespace' => $isWs,
2040                'kind' => $seg['kind'],
2041            ];
2042        }
2043        return $out;
2044    }
2045
2046    /**
2047     * Unicode UAX #9 (Bidi) — minimal per-codepoint split. Walks each
2048     * segment, classifies codepoints into LTR / RTL / neutral via
2049     * `IntlChar::charDirection`, groups consecutive same-direction
2050     * characters into runs, and emits one segment per run. Neutrals
2051     * adopt the direction of their surrounding run (left-context
2052     * fallback). When `intl` is unavailable or the segment is pure
2053     * one-direction, segments pass through untouched.
2054     *
2055     * Each returned segment carries a `direction` field (`'ltr'` /
2056     * `'rtl'`) so the shaping pass can switch ShapingDirection per
2057     * run without re-classifying codepoints.
2058     *
2059     * @param list<array{text: string, kind: LineBreakKind}> $segments
2060     * @return list<array{text: string, kind: LineBreakKind, direction?: string}>
2061     */
2062    private function splitBidiRuns(array $segments): array
2063    {
2064        if (!class_exists(\IntlChar::class)) {
2065            return $segments;
2066        }
2067        $out = [];
2068        foreach ($segments as $seg) {
2069            $text = $seg['text'];
2070            // Quick exit: if no codepoint has explicit RTL direction,
2071            // the whole segment is LTR. Avoids the per-codepoint walk
2072            // for the common Latin case.
2073            if (preg_match('/[\x{0590}-\x{08FF}\x{FB1D}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text) !== 1) {
2074                $seg['direction'] = 'ltr';
2075                $out[] = $seg;
2076                continue;
2077            }
2078            $bytes = strlen($text);
2079            $i = 0;
2080            $currentRun = '';
2081            $currentDir = null;
2082            while ($i < $bytes) {
2083                $b = ord($text[$i]);
2084                $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4));
2085                $chunk = substr($text, $i, $cpLen);
2086                $cp = mb_ord($chunk, 'UTF-8');
2087                $i += $cpLen;
2088                if ($cp === false) {
2089                    $currentRun .= $chunk;
2090                    continue;
2091                }
2092                $bidiClass = \IntlChar::charDirection($cp);
2093                // L (LEFT_TO_RIGHT) → LTR; R / AL (RIGHT_TO_LEFT,
2094                // RIGHT_TO_LEFT_ARABIC) → RTL; everything else is a
2095                // neutral and inherits the surrounding run.
2096                $dirHere = match ($bidiClass) {
2097                    \IntlChar::CHAR_DIRECTION_LEFT_TO_RIGHT => 'ltr',
2098                    \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT,
2099                    \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT_ARABIC => 'rtl',
2100                    default => null,
2101                };
2102                if ($dirHere === null) {
2103                    $currentRun .= $chunk;
2104                    continue;
2105                }
2106                if ($currentDir === null) {
2107                    $currentDir = $dirHere;
2108                }
2109                if ($dirHere !== $currentDir) {
2110                    // Direction boundary — flush the current run.
2111                    if ($currentRun !== '') {
2112                        $out[] = [
2113                            'text' => $currentRun,
2114                            'kind' => LineBreakKind::Allowed,
2115                            'direction' => $currentDir,
2116                        ];
2117                    }
2118                    $currentRun = $chunk;
2119                    $currentDir = $dirHere;
2120                } else {
2121                    $currentRun .= $chunk;
2122                }
2123            }
2124            if ($currentRun !== '') {
2125                $out[] = [
2126                    'text' => $currentRun,
2127                    // The last sub-run keeps the parent segment's
2128                    // original break-kind (so a UAX-14 mandatory
2129                    // break at the source's end still fires).
2130                    'kind' => $seg['kind'],
2131                    'direction' => $currentDir ?? 'ltr',
2132                ];
2133            }
2134        }
2135        return $out;
2136    }
2137
2138    /**
2139     * Return a new `ShapedRun` with every glyph's `advanceX` bumped by
2140     * `$letterSpacing` and the `totalAdvance` summed accordingly.
2141     */
2142    private function applyLetterSpacing(ShapedRun $run, float $letterSpacing): ShapedRun
2143    {
2144        $glyphs = [];
2145        $total = 0.0;
2146        foreach ($run->glyphs as $g) {
2147            $newAdvance = $g->advanceX + $letterSpacing;
2148            $glyphs[] = new ShapedGlyph(
2149                $g->glyphId,
2150                $g->sourceOffset,
2151                $g->sourceLength,
2152                $newAdvance,
2153                $g->advanceY,
2154                $g->offsetX,
2155                $g->offsetY,
2156            );
2157            $total += $newAdvance;
2158        }
2159        return new ShapedRun(
2160            $run->font,
2161            $run->fontSizePt,
2162            $run->direction,
2163            $glyphs,
2164            $total,
2165        );
2166    }
2167
2168    /**
2169     * CSS Text 3 §10: `letter-spacing` keyword `normal` resolves to 0;
2170     * any `Length` (already in px after `Cascade::resolveLengths`) is the
2171     * extra advance applied to every glyph.
2172     */
2173    private function resolveLetterSpacing(Box $parent): float
2174    {
2175        $value = $parent->style->get('letter-spacing');
2176        if ($value instanceof Length) {
2177            return $value->value;
2178        }
2179        return 0.0;
2180    }
2181
2182    /**
2183     * CSS Text 3 §9: `word-spacing` adds advance only at word-separator
2184     * glyphs. `normal` → 0; any `Length` is the extra advance per separator.
2185     */
2186    private function resolveWordSpacing(Box $parent): float
2187    {
2188        $value = $parent->style->get('word-spacing');
2189        if ($value instanceof Length) {
2190            return $value->value;
2191        }
2192        return 0.0;
2193    }
2194
2195    /**
2196     * Bump the advance of every glyph whose source codepoint is a CSS
2197     * word separator (U+0020 SPACE or U+00A0 NO-BREAK SPACE). Builds and
2198     * returns a new `ShapedRun`.
2199     */
2200    private function applyWordSpacing(ShapedRun $run, string $sourceText, float $wordSpacing): ShapedRun
2201    {
2202        $glyphs = [];
2203        $total = 0.0;
2204        foreach ($run->glyphs as $g) {
2205            $bump = $this->isWordSeparatorAt($sourceText, $g->sourceOffset) ? $wordSpacing : 0.0;
2206            $newAdvance = $g->advanceX + $bump;
2207            $glyphs[] = new ShapedGlyph(
2208                $g->glyphId,
2209                $g->sourceOffset,
2210                $g->sourceLength,
2211                $newAdvance,
2212                $g->advanceY,
2213                $g->offsetX,
2214                $g->offsetY,
2215            );
2216            $total += $newAdvance;
2217        }
2218        return new ShapedRun(
2219            $run->font,
2220            $run->fontSizePt,
2221            $run->direction,
2222            $glyphs,
2223            $total,
2224        );
2225    }
2226
2227    private function isWordSeparatorAt(string $text, int $offset): bool
2228    {
2229        if ($offset < 0 || $offset >= strlen($text)) {
2230            return false;
2231        }
2232        $b = ord($text[$offset]);
2233        if ($b === 0x20) {
2234            return true;
2235        }
2236        // U+00A0 NO-BREAK SPACE → UTF-8 bytes 0xC2 0xA0.
2237        return $b === 0xC2 && ($offset + 1) < strlen($text) && ord($text[$offset + 1]) === 0xA0;
2238    }
2239
2240    /**
2241     * The dominant font-size for the inline run. Phase 1F.2 reads it from
2242     * the parent's cascaded `font-size`; mixed-size content is a Phase 2
2243     * follow-up alongside multi-font runs.
2244     */
2245    private function dominantFontSize(Box $parent, LayoutContext $context): float
2246    {
2247        $value = $parent->style->get('font-size');
2248        if ($value instanceof Length) {
2249            return $value->value;
2250        }
2251        return $context->lengthContext->currentFontSize;
2252    }
2253
2254    /**
2255     * Pull a Length value off a cascaded property in pixels. Atomic
2256     * inline boxes don't have an in-progress containing-block width
2257     * to resolve percentages against at token-collection time (the
2258     * containing block isn't passed to {@see collectTokens}), so
2259     * Percentages resolve to 0 here. That matches the older atomic
2260     * behaviour and is acceptable for the in-scope test surface
2261     * (no `<img padding-left="50%">` fixtures); percentage-padding
2262     * on replaced inlines is a future enhancement once the inline
2263     * layout owns its parent's content width directly.
2264     */
2265    private static function atomicLength(?\Phpdftk\Css\Value\Value $value): float
2266    {
2267        return $value instanceof Length
2268            ? \Phpdftk\Css\Cascade\LengthResolver::clampPx($value->value)
2269            : 0.0;
2270    }
2271
2272    /**
2273     * Side-specific border width for atomic inline boxes, accounting
2274     * for `border-<side>-style: none` (which collapses the width to
2275     * zero per CSS Backgrounds 3 §4.4) and the `thin`/`medium`/`thick`
2276     * keyword widths.
2277     */
2278    private static function atomicBorderWidth(\Phpdftk\Css\Cascade\CascadedValues $style, string $side): float
2279    {
2280        $styleValue = $style->get("border-$side-style");
2281        if ($styleValue instanceof \Phpdftk\Css\Value\Keyword
2282            && strtolower($styleValue->name) === 'none'
2283        ) {
2284            return 0.0;
2285        }
2286        $width = $style->get("border-$side-width");
2287        if ($width instanceof Length) {
2288            return \Phpdftk\Css\Cascade\LengthResolver::clampPx($width->value);
2289        }
2290        if ($width instanceof \Phpdftk\Css\Value\Keyword) {
2291            return match (strtolower($width->name)) {
2292                'thin' => 1.0,
2293                'medium' => 3.0,
2294                'thick' => 5.0,
2295                default => 0.0,
2296            };
2297        }
2298        return 0.0;
2299    }
2300
2301    /**
2302     * CSS Sizing 3 §6.2 — `true` when the cascaded `box-sizing` is
2303     * `border-box`, meaning declared width/height include the
2304     * padding + border edges.
2305     */
2306    private static function atomicIsBorderBoxSizing(\Phpdftk\Css\Cascade\CascadedValues $style): bool
2307    {
2308        $value = $style->get('box-sizing');
2309        return $value instanceof \Phpdftk\Css\Value\Keyword
2310            && strtolower($value->name) === 'border-box';
2311    }
2312
2313    /**
2314     * Intrinsic aspect ratio (width / height) of a replaced atomic box,
2315     * read from the `aspect-ratio` cascade value the box generator
2316     * exposes for `<img>` / `<canvas>`. Null when absent or malformed.
2317     */
2318    private static function atomicAspectRatio(\Phpdftk\Css\Cascade\CascadedValues $style): ?float
2319    {
2320        $value = $style->get('aspect-ratio');
2321        if ($value instanceof \Phpdftk\Css\Value\ValueList
2322            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
2323            && count($value->values) >= 2
2324        ) {
2325            $w = self::atomicNumeric($value->values[0]);
2326            $h = self::atomicNumeric($value->values[1]);
2327            if ($w !== null && $h !== null && $h > 0.0) {
2328                return $w / $h;
2329            }
2330        }
2331        $direct = self::atomicNumeric($value);
2332        return ($direct !== null && $direct > 0.0) ? $direct : null;
2333    }
2334
2335    private static function atomicNumeric(?\Phpdftk\Css\Value\Value $v): ?float
2336    {
2337        if ($v instanceof \Phpdftk\Css\Value\Integer) {
2338            return (float) $v->value;
2339        }
2340        if ($v instanceof \Phpdftk\Css\Value\Number) {
2341            return $v->value;
2342        }
2343        return null;
2344    }
2345
2346    /**
2347     * CSS Sizing 3 §5.2 + csswg issue 3973 — resolve a min/max-width or
2348     * min/max-height value for a replaced atomic box to pixels. Lengths
2349     * pass through; `min-content` / `max-content` / `fit-content`
2350     * transfer the box's definite cross size through the intrinsic
2351     * aspect ratio (a replaced element's content-based size in one axis
2352     * is the other axis times the ratio). Returns null when the
2353     * constraint does not apply (`none` / `auto` / `stretch`, a block-
2354     * axis percentage, or a keyword with no ratio to transfer through).
2355     */
2356    private function resolveAtomicMinMax(
2357        \Phpdftk\Css\Cascade\CascadedValues $style,
2358        string $property,
2359        float $crossContentSize,
2360        ?float $ratio,
2361        bool $isWidth,
2362    ): ?float {
2363        $v = $style->get($property);
2364        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
2365            $name = strtolower($v->name);
2366            if (in_array($name, ['min-content', 'max-content', 'fit-content'], true)) {
2367                if ($ratio === null || $ratio <= 0.0) {
2368                    return null;
2369                }
2370                return $isWidth ? $crossContentSize * $ratio : $crossContentSize / $ratio;
2371            }
2372            return null;
2373        }
2374        if ($v instanceof Length) {
2375            return \Phpdftk\Css\Cascade\LengthResolver::clampPx($v->value);
2376        }
2377        if ($v instanceof \Phpdftk\Css\Value\Percentage && $isWidth) {
2378            return $this->currentAvailableWidth * ($v->value / 100.0);
2379        }
2380        return null;
2381    }
2382
2383    /**
2384     * Apply the replaced-element min/max-width / -height clamps to a
2385     * resolved (content-box) width / height pair. The width clamps
2386     * transfer the original height through the ratio (and vice versa)
2387     * so each keyword resolves against the definite cross size, not a
2388     * value another clamp on the same call already changed.
2389     *
2390     * @return array{0: float, 1: float} clamped [width, height]
2391     */
2392    private function clampAtomicReplaced(
2393        \Phpdftk\Css\Cascade\CascadedValues $style,
2394        float $width,
2395        float $height,
2396    ): array {
2397        $ratio = self::atomicAspectRatio($style);
2398        $origWidth = $width;
2399        $origHeight = $height;
2400        $maxW = $this->resolveAtomicMinMax($style, 'max-width', $origHeight, $ratio, true);
2401        if ($maxW !== null && $width > $maxW) {
2402            $width = $maxW;
2403        }
2404        $minW = $this->resolveAtomicMinMax($style, 'min-width', $origHeight, $ratio, true);
2405        if ($minW !== null && $width < $minW) {
2406            $width = $minW;
2407        }
2408        $maxH = $this->resolveAtomicMinMax($style, 'max-height', $origWidth, $ratio, false);
2409        if ($maxH !== null && $height > $maxH) {
2410            $height = $maxH;
2411        }
2412        $minH = $this->resolveAtomicMinMax($style, 'min-height', $origWidth, $ratio, false);
2413        if ($minH !== null && $height < $minH) {
2414            $height = $minH;
2415        }
2416        return [$width, $height];
2417    }
2418}