Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.49% covered (warning)
87.49%
1385 / 1583
47.37% covered (danger)
47.37%
36 / 76
CRAP
0.00% covered (danger)
0.00%
0 / 1
InlineLayout
87.49% covered (warning)
87.49%
1385 / 1583
47.37% covered (danger)
47.37%
36 / 76
995.13
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
96.86% covered (success)
96.86%
216 / 223
0.00% covered (danger)
0.00%
0 / 1
42
 applyVerticalLineShift
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
9
 commitVerticalAtomicGeometry
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 layoutAtomicOnly
100.00% covered (success)
100.00%
85 / 85
100.00% covered (success)
100.00%
1 / 1
27
 resolveTabSize
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 verticalInlineMeasure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 lineBounds
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 applyTextOverflow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 appendEllipsisToLine
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
9.33
 clampHostLines
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
4.11
 lineExtent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 lineClampCount
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 legacyWebkitClampApplies
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
7.19
 applyLineClamp
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 trimFragmentToFit
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
5
 applyTextAlign
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
18.68
 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveLineHeight
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 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
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
7
 walkInline
87.56% covered (warning)
87.56%
190 / 217
0.00% covered (danger)
0.00%
0 / 1
35.10
 inlineInlineAxisInset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 inlineSpacerToken
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 fragmentAscent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fragmentDescent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 finalizeLine
92.45% covered (success)
92.45%
49 / 53
0.00% covered (danger)
0.00%
0 / 1
20.17
 resolveAtomicHeights
82.61% covered (warning)
82.61%
38 / 46
0.00% covered (danger)
0.00%
0 / 1
10.53
 commitAtomicFragmentY
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 commitAtomicFragmentX
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 atomicBaselineFromMarginTop
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fragmentExtents
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 withBaselineShift
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 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
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
54.67
 resolveBoxFont
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 allowsSynthesis
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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
 resolveVerticalAlignKeyword
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 tokeniseText
94.12% covered (success)
94.12%
64 / 68
0.00% covered (danger)
0.00%
0 / 1
23.11
 trimTrailingSpace
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
4
 trailingCollapsibleAdvance
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 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
 atomicDefiniteContentHeight
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
11.06
 atomicAspectRatio
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 atomicNumeric
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resolveAtomicMinMax
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
19.45
 clampAtomicReplaced
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
23.14
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    /**
62     * CSS Writing Modes 4 §3 — the measure lines WRAP against, when that
63     * differs from the physical width. Null in a horizontal writing mode
64     * (and in a vertical one whose block size is indefinite), which keeps
65     * every horizontal path byte-identical.
66     */
67    private ?float $currentWrapMeasure = null;
68
69    /**
70     * CSS Sizing 4 §6.4 — true when the container's BLOCK size (its
71     * physical width, in a vertical writing mode) is `auto` and will be
72     * set from this pass's column total. The `vrl` / `sideways-rl`
73     * transpose then anchors the columns against that total rather than
74     * against the pre-shrink physical width it was handed.
75     */
76    private bool $currentBlockSizeAuto = false;
77
78    /**
79     * True while laying out an inline formatting context whose block
80     * container is in a vertical writing mode (CSS Writing Modes 4 §3).
81     * The pass runs in a horizontal coordinate space and
82     * {@see applyVerticalLineShift} transposes it at the end, so this is
83     * what tells the atomic-inline sizing which physical dimension is the
84     * INLINE one: an `<img>` keeps its physical `width`/`height`, so in a
85     * vertical mode its inline extent is its HEIGHT and its contribution
86     * to the line's cross-size is its WIDTH — the opposite of horizontal-tb.
87     */
88    private bool $currentIsVertical = false;
89
90    /**
91     * The containing block's content height and whether it is definite,
92     * threaded from the block layout so an atomic replaced element can
93     * resolve a percentage `height` / `max-height` / `min-height` and,
94     * via its intrinsic ratio, derive an auto inline size (CSS 2.1
95     * §10.3.2). `0` / `false` when the CB height is indefinite.
96     */
97    private float $currentCbHeight = 0.0;
98
99    private bool $currentCbHeightDefinite = false;
100
101    public function __construct(
102        private readonly Shaper $shaper = new Shaper(),
103        private readonly LineBreaker $lineBreaker = new LineBreaker(),
104    ) {}
105
106    /**
107     * Lay out the inline children of `$parent` within `$availableWidth`,
108     * starting at Y = 0 in the parent's coordinate space. Returns the
109     * generated line boxes (positions relative to the parent's content
110     * area) and the total height consumed.
111     *
112     * @return array{list<LineBox>, float} (lines, totalHeight)
113     */
114    public function layout(
115        Box $parent,
116        float $availableWidth,
117        LayoutContext $context,
118        ?float $inlineExtent = null,
119        bool $blockSizeAuto = false,
120    ): array {
121        $this->currentFontResolver = $context->fontResolver;
122        $this->currentAvailableWidth = $availableWidth;
123        $this->currentCbHeight = $context->containingBlockHeight;
124        $this->currentCbHeightDefinite = $context->inFlowHeightDefinite;
125        $this->currentWrapMeasure = $this->verticalInlineMeasure($parent, $inlineExtent);
126        $this->currentBlockSizeAuto = $blockSizeAuto;
127        $this->currentIsVertical = WritingMode::fromStyle($parent->style)->isVertical();
128        if ($availableWidth <= 0.0) {
129            return [[], 0.0];
130        }
131        // Resolve the shaping font + post-match synthetic-effect flags via
132        // CSS Fonts 4 §6 weight/style matching. When a real face matches
133        // the cascaded weight/style, `isBold`/`isItalic` come back false so
134        // the painter doesn't double up with fake-bold / fake-italic.
135        $parentMatch = $this->resolveBoxFont($parent, $context->defaultFont);
136        $font = $parentMatch['font'];
137        $fontSize = $this->dominantFontSize($parent, $context);
138        if ($font === null) {
139            // No font means text shaping is impossible — but inline
140            // replaced content (img, svg, math, inline-block divs)
141            // doesn't need a font for layout. Fall back to a minimal
142            // atomic-only pass that pulls width / height off the
143            // cascade so paintImage and the foreign-content painters
144            // (paintInlineSvg, paintInlineMath) see real geometry
145            // instead of the (0, 0, 0, 0) box the full early return
146            // used to produce. Closes #39 for the atomic-content
147            // case; text-bearing documents still need an explicit
148            // default font.
149            return $this->layoutAtomicOnly($parent);
150        }
151        $shapingCtx = new ShapingContext($font, $fontSize, features: $this->resolveOpenTypeFeatures($parent));
152        $lineHeight = $this->resolveLineHeight($parent, $fontSize);
153        // CSS2 §10.8 strut: the block container's own font establishes a
154        // zero-width inline box that every line box contains, so an empty
155        // line or one of only-smaller text still reserves the block's line
156        // height. `lineMetrics` folds this in alongside the real fragments.
157        $strutAscent = ($font->ascent / max(1, $font->unitsPerEm)) * $fontSize;
158        $strutDescent = (abs($font->descent) / max(1, $font->unitsPerEm)) * $fontSize;
159        // x-height drives `vertical-align: middle` (box centre aligns with the
160        // baseline minus half the parent's x-height). Fall back to 0.5em when
161        // the font carries no x-height metric.
162        $strutXHeight = $font->xHeight > 0
163            ? ($font->xHeight / max(1, $font->unitsPerEm)) * $fontSize
164            : $fontSize * 0.5;
165        $whiteSpace = $this->whiteSpaceKeyword($parent);
166        // CSS Text 3 §4 — wrap permission table:
167        //   normal / pre-wrap / pre-line / break-spaces → allow soft wrap
168        //   nowrap / pre → no soft wrap
169        $allowSoftWrap = $whiteSpace !== 'nowrap' && $whiteSpace !== 'pre';
170        // CSS Text 3 §4 — leading-whitespace collapse table:
171        //   normal / nowrap / pre-line → collapse leading whitespace
172        //   pre / pre-wrap / break-spaces → preserve leading whitespace
173        // (`break-spaces` is `pre-wrap` plus the additional rule that
174        // every preserved space is a wrap opportunity. Leading-edge
175        // semantics match `pre-wrap` — both keep the leading run.)
176        $collapseLeadingWhitespace = $whiteSpace !== 'pre'
177            && $whiteSpace !== 'pre-wrap'
178            && $whiteSpace !== 'break-spaces';
179        $collapseInternalWhitespace = $whiteSpace === 'normal' || $whiteSpace === 'nowrap';
180
181        $letterSpacing = $this->resolveLetterSpacing($parent);
182        $wordSpacing = $this->resolveWordSpacing($parent);
183        // CSS Text 3 §5.5 — when `pre-wrap` / `break-spaces` is in
184        // effect, trailing whitespace at the end of a line hangs. For
185        // that to work, the tokeniser must emit a separate token for
186        // each whitespace run; otherwise UAX-14's `XX<ws>` bundle
187        // hides the trailing ws from the line fitter.
188        $hangsTrailingWhitespacePreCompute = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces';
189        $tokens = $this->collectTokens(
190            $parent,
191            $shapingCtx,
192            $collapseInternalWhitespace,
193            $letterSpacing,
194            $wordSpacing,
195            baselineShift: 0.0,
196            lineHeight: $lineHeight,
197            verticalAlign: 'baseline',
198            href: null,
199            isBold: $parentMatch['isBold'],
200            isItalic: $parentMatch['isItalic'],
201            decorationLines: $this->decorationLines($parent),
202            textColor: $this->resolveColor($parent),
203            backgroundColor: null,
204            linkTitle: null,
205            decorationColor: $this->resolveDecorationColor($parent),
206            splitWsBoundaries: $hangsTrailingWhitespacePreCompute,
207        );
208        if ($tokens === []) {
209            return [[], 0.0];
210        }
211
212        // CSS Text 3 §3.1: `text-indent` shifts the first inline box of the
213        // first formatted line. Length resolves directly; Percentage resolves
214        // against the block's content width (our `$availableWidth`).
215        $textIndent = $this->resolveTextIndent($parent, $availableWidth);
216
217        // CSS 2.1 §9.5.3 — line boxes shorten on the side(s) where a
218        // float is currently active. Compute the per-line (left, right)
219        // bounds against the float context each time we start a new line.
220        // The strut is only a LOWER bound on the line's block extent; each
221        // token that joins the line may grow `$lineBand` (see below).
222        $lineBand = $lineHeight;
223        $bounds = $this->lineBounds($parent, $availableWidth, $context, 0.0, $lineBand);
224        $lines = [];
225        $currentFragments = [];
226        // CSS Text 3 §5.5 — when in `pre-wrap` / `break-spaces`, trailing
227        // whitespace at the end of a line hangs (renders past the line edge
228        // with zero contribution to line measurement). We mirror that by
229        // tracking which fragments in `$currentFragments` were whitespace
230        // so we can drop them from the line when wrapping.
231        $hangsTrailingWhitespace = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces';
232        /** @var list<bool> $currentFragmentIsWs */
233        $currentFragmentIsWs = [];
234        // Advance of the collapsible whitespace the line currently ends with.
235        // CSS Text 3 §4.1.3 removes it, so it must come off the line's WIDTH
236        // as well as off the fit test — otherwise a line packed to its full
237        // measure reports as over-wide and `text-align` sees negative slack.
238        $currentTrailingSpace = 0.0;
239        $currentX = $bounds['left'] + $textIndent;
240        $lineMaxRight = $bounds['right'];
241        $atLineStart = true;
242        $y = 0.0;
243        foreach ($tokens as $token) {
244            $width = $token['shapedRun']->totalAdvance;
245            // CSS Text 3 §4.1.3 — a sequence of COLLAPSIBLE spaces at the end
246            // of a line is removed, so it must not decide whether the line
247            // fits. A break opportunity sits after the space (UAX #14), which
248            // makes "word + its trailing space" a single token; measuring the
249            // space here broke every line one space early, and a line crafted
250            // to fill its measure exactly lost its last word.
251            //
252            // `pre-wrap` / `break-spaces` keep their spaces (they hang
253            // instead), and that path already drops the fragments after the
254            // fact, so it is left alone.
255            $fitWidth = $hangsTrailingWhitespace
256                ? $width
257                : $width - $token['trailingSpace'];
258            $isMandatory = $token['kind'] === LineBreakKind::Mandatory;
259
260            if ($collapseLeadingWhitespace && $token['isWhitespace'] && $atLineStart) {
261                // Leading whitespace at a line start is collapsed.
262                continue;
263            }
264            // CSS Shapes 1 §1.2 — an atomic inline's own block extent sets
265            // the band the float contour is resolved over, so it has to be
266            // known BEFORE this line's bounds are (re-)queried below. Only
267            // the height resolution moves up here; committing the box's
268            // geometry still waits until the inline cursor is final.
269            $atomic = $token['atomicBox'] ?? null;
270            $atomicContentHeight = 0.0;
271            $atomicOuterHeight = 0.0;
272            $atomicPadTop = 0.0;
273            $atomicPadBottom = 0.0;
274            $atomicBorderTop = 0.0;
275            $atomicBorderBottom = 0.0;
276            if ($atomic !== null) {
277                // The token captured the box-sizing-resolved content +
278                // outer widths; resolve the heights with the same
279                // semantics here. Falling back to width when height is
280                // unset keeps the square-replaced-element default
281                // (img with intrinsic ratio) the existing tests rely on.
282                // The token resolved the box-sizing-aware block size at
283                // creation time (the inline advance depends on it in a
284                // vertical writing mode, so it cannot wait until here).
285                [
286                    $atomicContentHeight,
287                    $atomicOuterHeight,
288                    $atomicPadTop,
289                    $atomicPadBottom,
290                    $atomicBorderTop,
291                    $atomicBorderBottom,
292                ] = $token['atomicHeights'] ?? $this->resolveAtomicHeights(
293                    $atomic,
294                    (bool) ($token['atomicBorderBox'] ?? false),
295                    $width,
296                );
297            }
298            // A line may not break inside an inline box's own padding /
299            // border, so its spacer never opens a break opportunity: wrapping
300            // before it would strand the inset on the next line, away from
301            // the text it belongs to.
302            // A ZERO-WIDTH token always fits, so it must never trigger a
303            // wrap. Without this an already-overflowing line followed by a
304            // `<br>` breaks BEFORE the break itself: the `<br>` lands alone
305            // on a line of its own and everything after is pushed a whole
306            // line down. `border-padding-bleed-001` overflows its 596px
307            // measure with 640px of Ahem text, so its `<br>` produced a third
308            // line box where two are correct.
309            if ($allowSoftWrap
310                && $fitWidth > 0.0
311                && ($token['noWrapBefore'] ?? false) === false
312                && $currentX + $fitWidth > $lineMaxRight
313                && $currentFragments !== []
314            ) {
315                // Wrap before placing this token.
316                // For `pre-wrap` / `break-spaces`: trailing whitespace at the
317                // end of the current line "hangs" — drop those fragments so
318                // they don't push the line width and don't get re-emitted on
319                // the next line. The overflowing whitespace token that
320                // triggered this wrap also hangs (we drop it below).
321                if ($hangsTrailingWhitespace) {
322                    while ($currentFragmentIsWs !== [] && end($currentFragmentIsWs) === true) {
323                        array_pop($currentFragments);
324                        array_pop($currentFragmentIsWs);
325                    }
326                    // Re-derive currentX from the surviving fragments so
327                    // line-width-based math (e.g. alignment) sees the
328                    // post-hang width.
329                    $currentX = $currentFragments === []
330                        ? $bounds['left'] + $textIndent
331                        : end($currentFragments)->x + end($currentFragments)->width;
332                }
333                $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace);
334                $currentTrailingSpace = 0.0;
335                [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight);
336                $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments);
337                $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight);
338                $y += $effective;
339                $currentFragments = [];
340                $currentFragmentIsWs = [];
341                $lineBand = $lineHeight;
342                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand);
343                $currentX = $bounds['left'];
344                $lineMaxRight = $bounds['right'];
345                $atLineStart = true;
346                if ($collapseLeadingWhitespace && $token['isWhitespace']) {
347                    // Drop whitespace at start of next line.
348                    continue;
349                }
350                if ($hangsTrailingWhitespace && $token['isWhitespace']) {
351                    // The overflowing whitespace hangs on the prior line —
352                    // don't carry it to the new line.
353                    continue;
354                }
355            }
356            // CSS Shapes 1 §1.2 — the float area is resolved per LINE BOX,
357            // over that line's own block extent. The strut is only a lower
358            // bound on it: a `line-height: 0` block whose lines are built
359            // from 36px-tall inline-blocks has a 36px band, and a curved or
360            // sloped contour intrudes far further across that band than it
361            // does at the line's top edge. So grow the band as each token
362            // joins the line and re-query the contour. Fragments already on
363            // the line shift by the difference, which is what re-running the
364            // line against the tighter bounds would have produced; the
365            // dominant case — an atomic that is the line's first token —
366            // has nothing to shift at all.
367            $tokenBand = $atomic !== null
368                ? $atomicOuterHeight
369                    + (float) ($token['atomicMarginTop'] ?? 0.0)
370                    + (float) ($token['atomicMarginBottom'] ?? 0.0)
371                : max(0.0, (float) ($token['lineHeight'] ?? -1.0));
372            if ($tokenBand > $lineBand + 0.001) {
373                $lineBand = $tokenBand;
374                $rebanded = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand);
375                $dx = $rebanded['left'] - $bounds['left'];
376                if ($dx !== 0.0 && $currentFragments !== []) {
377                    $currentFragments = $this->shiftFragments($currentFragments, $dx);
378                    $this->commitAtomicFragmentX($parent, $currentFragments);
379                }
380                $currentX += $dx;
381                $bounds = $rebanded;
382                $lineMaxRight = $rebanded['right'];
383            }
384            $currentFragments[] = new InlineFragment(
385                $currentX,
386                $width,
387                $token['shapedRun'],
388                $token['baselineShift'] ?? 0.0,
389                $token['href'] ?? null,
390                $token['isBold'] ?? false,
391                $token['isItalic'] ?? false,
392                $token['decorationLines'] ?? [],
393                $token['textColor'] ?? null,
394                $token['backgroundColor'] ?? null,
395                $token['linkTitle'] ?? null,
396                $token['decorationColor'] ?? null,
397                (bool) $token['isWhitespace'],
398                $token['lineHeight'] ?? -1.0,
399                $token['verticalAlign'] ?? 'baseline',
400                atomicBox: $token['atomicBox'] ?? null,
401                bgExtendAbove: $token['bgExtendAbove'] ?? 0.0,
402                bgExtendBelow: $token['bgExtendBelow'] ?? 0.0,
403            );
404            $currentFragmentIsWs[] = (bool) $token['isWhitespace'];
405            $currentTrailingSpace = $hangsTrailingWhitespace ? 0.0 : $token['trailingSpace'];
406            // Side-channel: AtomicInlineBox positions get committed back to
407            // the box's geometry so the painter can draw images / replaced
408            // content at the right spot. CSS Inline 3 §4.5: for the default
409            // `vertical-align: baseline`, the inline-block's baseline aligns
410            // with the parent line's baseline; for replaced elements like
411            // `<img>` the baseline is the bottom of the box. So position
412            // the box so its *bottom* sits at the line's baseline (line.y +
413            // ascent of the shaping font) — same convention the painter
414            // uses for text baselines.
415            if ($atomic !== null) {
416                $shapedRun = $token['shapedRun'];
417                $atomicFont = $shapedRun->font;
418                $atomicUpem = max(1, $atomicFont->unitsPerEm);
419                $atomicAscent = ($atomicFont->ascent / $atomicUpem) * $shapedRun->fontSizePt;
420                // Outer box top-left is `(currentX, y + ascent - outerHeight)`;
421                // the content box sits inside the padding + border edges.
422                $atomicContentWidth = $token['atomicContentWidth'] ?? $width;
423                $atomicPadLeft = $token['atomicPadLeft'] ?? 0.0;
424                $atomicBorderLeft = $token['atomicBorderLeft'] ?? 0.0;
425                $atomicPadRight = $token['atomicPadRight'] ?? 0.0;
426                $atomicBorderRight = $token['atomicBorderRight'] ?? 0.0;
427                $atomicMarginLeft = $token['atomicMarginLeft'] ?? 0.0;
428                $atomicMarginRight = $token['atomicMarginRight'] ?? 0.0;
429                $atomicMarginTop = $token['atomicMarginTop'] ?? 0.0;
430                $atomicMarginBottom = $token['atomicMarginBottom'] ?? 0.0;
431                // `$currentX` sits at the item's margin-box start; step past
432                // the left margin (plus border + padding) to the content box.
433                $atomic->geometry->x = $parent->geometry->x + $currentX
434                    + $atomicMarginLeft + $atomicBorderLeft + $atomicPadLeft;
435                // CSS 2.2 §10.8 — the inline-block's baseline (no in-flow line
436                // boxes here) is its bottom margin edge, so the margin box
437                // bottom aligns with the line baseline; the border box bottom
438                // sits a bottom-margin above it.
439                $atomic->geometry->y = $parent->geometry->y + $y
440                    + $atomicAscent - $atomicOuterHeight - $atomicMarginBottom
441                    + $atomicBorderTop + $atomicPadTop;
442                $atomic->geometry->width = $atomicContentWidth;
443                $atomic->geometry->height = $atomicContentHeight;
444                $atomic->geometry->paddingLeft = $atomicPadLeft;
445                $atomic->geometry->paddingRight = $atomicPadRight;
446                $atomic->geometry->paddingTop = $atomicPadTop;
447                $atomic->geometry->paddingBottom = $atomicPadBottom;
448                $atomic->geometry->borderLeft = $atomicBorderLeft;
449                $atomic->geometry->borderRight = $atomicBorderRight;
450                $atomic->geometry->borderTop = $atomicBorderTop;
451                $atomic->geometry->borderBottom = $atomicBorderBottom;
452                $atomic->geometry->marginLeft = $atomicMarginLeft;
453                $atomic->geometry->marginRight = $atomicMarginRight;
454                $atomic->geometry->marginTop = $atomicMarginTop;
455                $atomic->geometry->marginBottom = $atomicMarginBottom;
456            }
457            $currentX += $width;
458            $atLineStart = false;
459            if ($isMandatory) {
460                $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace);
461                $currentTrailingSpace = 0.0;
462                [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight);
463                $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments);
464                $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight);
465                $y += $effective;
466                $currentFragments = [];
467                $currentFragmentIsWs = [];
468                $lineBand = $lineHeight;
469                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand);
470                $currentX = $bounds['left'];
471                $lineMaxRight = $bounds['right'];
472                $atLineStart = true;
473            }
474        }
475        if ($currentFragments !== []) {
476            $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace);
477            $currentTrailingSpace = 0.0;
478            [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight);
479            $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments);
480            $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight);
481            $y += $effective;
482        }
483
484        // CSS Overflow 4 line-clamp: keep only the first N line boxes, mark
485        // the last retained one with a block ellipsis, and shrink the block's
486        // height to those lines. Runs before text-overflow so a clamped line
487        // that also overflows horizontally isn't given two ellipses, and
488        // before text-align so alignment sees the retained rect.
489        $clamp = $this->lineClampCount($parent);
490        if ($clamp !== null && count($lines) > $clamp) {
491            [$lines, $y] = $this->applyLineClamp($lines, $clamp, $availableWidth, $shapingCtx, $letterSpacing);
492        }
493
494        // CSS UI 3 §6.2: `text-overflow: ellipsis` truncates each line's
495        // tail when its content exceeds the available width. Runs before
496        // text-align so the alignment math operates on the truncated rect.
497        $lines = $this->applyTextOverflow($lines, $availableWidth, $parent, $shapingCtx, $letterSpacing);
498
499        $lines = $this->applyTextAlign($lines, $availableWidth, $parent);
500        // text-align shifts each line's fragments; re-commit any atomic /
501        // replaced box's inline-axis geometry so an <img> / inline-block on a
502        // centred / right-aligned / justified line paints at the shifted
503        // position rather than the line's pre-alignment start. Horizontal
504        // writing modes only — in a vertical mode the inline axis is Y and the
505        // atomic's geometry is (re)placed by the transpose in
506        // applyVerticalLineShift below, so an X commit here would fight it.
507        if (!WritingMode::fromStyle($parent->style)->isVertical()) {
508            foreach ($lines as $line) {
509                $this->commitAtomicFragmentX($parent, $line->fragments);
510            }
511        }
512        // CSS Writing Modes 4 §3 — for a vertical-mode block container
513        // hosting an inline formatting context, lines should sit at the
514        // block-start edge of the container's content area:
515        //  - `vrl` / `sideways-rl`: block-start = right edge → shift the
516        //    line's fragments rightward by (availableWidth - lineWidth)
517        //    so the visible content lands at the right.
518        //  - `vlr` / `sideways-lr`: block-start = left edge → no shift.
519        //
520        // Phase-4 scaffold: text glyphs still lay out horizontally
521        // (per the current Shaper-driven flow) — full vertical text
522        // shaping with rotated glyphs comes in a later commit. The
523        // immediate win is that single-line atomic inline content
524        // (`<img>`, inline-block divs) lands at the visually-correct
525        // block-start edge in `vrl` containers.
526        $lines = $this->applyVerticalLineShift($lines, $availableWidth, $parent);
527        return [$lines, $y];
528    }
529
530    /**
531     * @param list<LineBox> $lines
532     * @return list<LineBox>
533     */
534    private function applyVerticalLineShift(array $lines, float $availableWidth, Box $parent): array
535    {
536        $wm = WritingMode::fromStyle($parent->style);
537        if (!$wm->isVertical()) {
538            return $lines;
539        }
540        // CSS WM 4 §3 — transpose the horizontal IFC to the vertical block flow.
541        // Each line becomes a vertical column: the line's INLINE offset (the
542        // fragment's x — where `text-indent` / `text-align` placed it) moves to
543        // the vertical axis (`line.y`, which the painter reads as the column's
544        // vertical start), and columns stack along the BLOCK axis (physical x),
545        // each occupying the line's cross-size (`height`). `vlr` / `sideways-lr`
546        // grow rightward from the left content edge; `vrl` / `sideways-rl` grow
547        // leftward from the right edge (block-start = right). The painter's
548        // vertical branch (paintFragment) rotates glyphs 90° CW, reads
549        // `fragment.x` as the column's left edge and `line.y` as its top.
550        //
551        // Increment 2: EVERY line (single- or multi-fragment) transposes
552        // into one vertical column. All fragments of a source line share the
553        // column's block-axis position (`fragment.x = $blockLeft`); each
554        // keeps its inline advance as a `blockOffset` DOWN the column so the
555        // painter stacks them. `LineBox.y` becomes 0 (the column top; the
556        // per-fragment offset now carries the inline position). The former
557        // single-fragment special case is the degenerate blockOffset=x case
558        // of this loop.
559        $rtl = $wm->blockDirection() === -1;
560        // CSS Sizing 4 §6.4 — the `vrl` / `sideways-rl` columns are
561        // anchored to the container's block-START edge, i.e. the RIGHT
562        // edge of its content box. With an `auto` block size that edge is
563        // not `$availableWidth` (the pre-shrink stretch value handed to
564        // this pass) but the column total the caller is about to adopt as
565        // the used width — anchor against that instead, or the columns
566        // land off the shrunk box.
567        $anchor = $availableWidth;
568        if ($this->currentBlockSizeAuto) {
569            $anchor = 0.0;
570            foreach ($lines as $line) {
571                if ($line->height > 0.0) {
572                    $anchor += $line->height;
573                }
574            }
575        }
576        $out = [];
577        $cumBlock = 0.0;
578        foreach ($lines as $line) {
579            // Transpose needs a real cross-size to place the column along the
580            // block axis; a degenerate line box (`line-height: 0`) has none,
581            // so pass it through rather than seed a zero-width column.
582            if ($line->height <= 0.0) {
583                $out[] = $line;
584                continue;
585            }
586            // `vlr` / `sideways-lr`: columns grow rightward from the left
587            // content edge. `vrl` / `sideways-rl`: block-start = right, so
588            // grow leftward (place the column `$cumBlock` in from the right).
589            $blockLeft = $rtl
590                ? max(0.0, $anchor - $cumBlock - $line->height)
591                : $cumBlock;
592            $newFrags = [];
593            foreach ($line->fragments as $f) {
594                $newFrags[] = new InlineFragment(
595                    $blockLeft,
596                    $f->width,
597                    $f->shapedRun,
598                    $f->baselineShift,
599                    $f->href,
600                    $f->isBold,
601                    $f->isItalic,
602                    $f->decorationLines,
603                    $f->textColor,
604                    $f->backgroundColor,
605                    $f->linkTitle,
606                    $f->decorationColor,
607                    $f->isWhitespace,
608                    $f->lineHeight,
609                    $f->verticalAlign,
610                    blockOffset: $f->x,
611                    // The transposed fragment has to keep its atomic /
612                    // replaced box: the painter reads it to place inline
613                    // images, and commitVerticalAtomicGeometry below needs
614                    // it to re-seat their geometry in physical coordinates.
615                    atomicBox: $f->atomicBox,
616                    bgExtendAbove: $f->bgExtendAbove,
617                    bgExtendBelow: $f->bgExtendBelow,
618                );
619            }
620            $this->commitVerticalAtomicGeometry($parent, $newFrags, $blockLeft, $line->height, $rtl);
621            $out[] = new LineBox(0.0, $line->height, $newFrags, $line->baseline, $line->availableRight);
622            $cumBlock += $line->height;
623        }
624        return $out;
625    }
626
627    /**
628     * Place each atomic / replaced box of a TRANSPOSED line in physical
629     * coordinates (CSS Writing Modes 4 §3 / §7.1).
630     *
631     * `commitAtomicFragmentX` / `commitAtomicFragmentY` placed the box in
632     * the horizontal coordinate space the inline pass works in; after the
633     * transpose a fragment's inline position lives in `blockOffset`
634     * (physical +Y down the column) and the column's block-axis position
635     * in `$blockLeft` (physical X). A replaced box keeps its physical
636     * `width`/`height`, so only its ORIGIN moves.
637     *
638     * The box sits at the column's block-START edge, which is the column's
639     * left edge for `vertical-lr` / `sideways-lr` and its right edge for
640     * `vertical-rl` / `sideways-rl`.
641     *
642     * @param list<InlineFragment> $fragments
643     */
644    private function commitVerticalAtomicGeometry(
645        Box $parent,
646        array $fragments,
647        float $blockLeft,
648        float $columnExtent,
649        bool $blockRtl,
650    ): void {
651        foreach ($fragments as $fragment) {
652            $atomic = $fragment->atomicBox;
653            if ($atomic === null) {
654                continue;
655            }
656            $g = $atomic->geometry;
657            $outerWidth = $g->marginLeft + $g->borderLeft + $g->paddingLeft + $g->width
658                + $g->paddingRight + $g->borderRight + $g->marginRight;
659            $blockStartInset = $blockRtl ? max(0.0, $columnExtent - $outerWidth) : 0.0;
660            $g->x = $parent->geometry->x + $blockLeft + $blockStartInset
661                + $g->marginLeft + $g->borderLeft + $g->paddingLeft;
662            $g->y = $parent->geometry->y + $fragment->blockOffset
663                + $g->marginTop + $g->borderTop + $g->paddingTop;
664        }
665    }
666
667    /**
668     * Fallback layout for blocks whose inline-formatting context has
669     * no shaping font available. Closes the geometry gap from #39 for
670     * documents that contain only inline replaced content (img, svg,
671     * math, inline-block divs) and never registered a default font.
672     *
673     * Walks the parent's direct children, reads cascaded width /
674     * height off each AtomicInlineBox, sets its geometry, and
675     * advances a cursor. No line wrapping — atoms that overflow the
676     * available width stack anyway (the painter clips per-page).
677     * Non-atomic children (TextBox, InlineBox, LineBreakBox) are
678     * skipped because they need a font to lay out.
679     *
680     * Returns no LineBox: the painter doesn't iterate lines to find
681     * an AtomicInlineBox, it walks the box tree top-down, so setting
682     * geometry directly is sufficient for paintImage's namespace
683     * dispatch (paintInlineSvg / paintInlineMath) to render.
684     *
685     * @return array{list<LineBox>, float}
686     */
687    private function layoutAtomicOnly(Box $parent): array
688    {
689        $currentX = 0.0;
690        $maxHeight = 0.0;
691        // Line-box tracking: atomics flow left-to-right and wrap to a new
692        // line when the next one won't fit in the IFC available width
693        // (CSS 2.1 §9.4.2). `$lineTop` is the current line's top offset
694        // from the parent's content top; `$lineHeight` its tallest box.
695        $lineTop = 0.0;
696        $lineHeight = 0.0;
697        foreach ($parent->children as $child) {
698            if (!($child instanceof AtomicInlineBox)) {
699                continue;
700            }
701            // Border + padding insets, resolved BEFORE the inline size so a
702            // definite height can derive an auto width via the intrinsic
703            // ratio (CSS 2.1 §10.3.2). Folding both axes' insets in keeps a
704            // uniformly-bordered inline-block's border box intact (the CSS2
705            // border-width tests).
706            $padTop = self::atomicLength($child->style->get('padding-top'));
707            $padBottom = self::atomicLength($child->style->get('padding-bottom'));
708            $padLeft = self::atomicLength($child->style->get('padding-left'));
709            $padRight = self::atomicLength($child->style->get('padding-right'));
710            $borderTop = self::atomicBorderWidth($child->style, 'top');
711            $borderBottom = self::atomicBorderWidth($child->style, 'bottom');
712            $borderLeft = self::atomicBorderWidth($child->style, 'left');
713            $borderRight = self::atomicBorderWidth($child->style, 'right');
714            $verticalInset = $padTop + $padBottom + $borderTop + $borderBottom;
715            $horizontalInset = $padLeft + $padRight + $borderLeft + $borderRight;
716            // CSS 2.2 §10.8 — an inline-block's margins take part in layout:
717            // horizontal margins add to its inline advance, its margin box
718            // contributes to line height.
719            $marginTop = self::atomicLength($child->style->get('margin-top'));
720            $marginBottom = self::atomicLength($child->style->get('margin-bottom'));
721            $marginLeft = self::atomicLength($child->style->get('margin-left'));
722            $marginRight = self::atomicLength($child->style->get('margin-right'));
723            // CSS Sizing 3 §6.2 — with `box-sizing: border-box` the declared
724            // width/height already includes the inset.
725            $borderBox = self::atomicIsBorderBoxSizing($child->style);
726            // Resolve a definite CONTENT height first: an explicit length /
727            // `0`, or a percentage against a definite containing-block
728            // height. A definite height lets an auto inline size come from
729            // the intrinsic ratio below.
730            $heightValue = $child->style->get('height');
731            $declaredHeight = match (true) {
732                $heightValue instanceof Length => $heightValue->value,
733                $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value,
734                $heightValue instanceof \Phpdftk\Css\Value\Percentage
735                    && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0
736                    => $this->currentCbHeight * ($heightValue->value / 100.0),
737                default => null,
738            };
739            $definiteContentHeight = null;
740            if ($declaredHeight !== null) {
741                $declaredHeight = max(0.0, $declaredHeight);
742                $definiteContentHeight = $borderBox
743                    ? max(0.0, $declaredHeight - $verticalInset)
744                    : $declaredHeight;
745            }
746            // Inline (content-box) width: an explicit length, a percentage
747            // against the IFC available width (the basis the shaped path
748            // uses), else — when auto with a definite height and intrinsic
749            // ratio — height × ratio (§10.3.2 replaced sizing).
750            $ratio = self::atomicAspectRatio($child->style);
751            $widthValue = $child->style->get('width');
752            $width = match (true) {
753                $widthValue instanceof Length && $widthValue->value > 0.0
754                    => $widthValue->value,
755                $widthValue instanceof \Phpdftk\Css\Value\Percentage && $widthValue->value > 0.0
756                    => $this->currentAvailableWidth * ($widthValue->value / 100.0),
757                default => 0.0,
758            };
759            $contentWidth = $borderBox ? max(0.0, $width - $horizontalInset) : $width;
760            if ($width <= 0.0
761                && $definiteContentHeight !== null && $definiteContentHeight > 0.0
762                && $ratio !== null && $ratio > 0.0
763            ) {
764                $contentWidth = $definiteContentHeight * $ratio;
765            }
766            if ($contentWidth <= 0.0) {
767                // Atomic-content painters have their own intrinsic-size
768                // fallbacks (svg attrs / viewBox; math defaults). Leave
769                // geometry at 0 so the painter's fallback chain still runs.
770                continue;
771            }
772            $outerWidth = $contentWidth + $horizontalInset;
773            // Content height: the definite value resolved above, else square
774            // the outer box to its width (the historical no-font contract
775            // the shaped path also follows), floored at the inset.
776            $contentHeight = $definiteContentHeight
777                ?? max(0.0, max($outerWidth, $verticalInset) - $verticalInset);
778            // CSS Sizing 3 §5.2 — replaced min/max-width / -height clamps
779            // (incl. min/max-content transferred through the intrinsic
780            // ratio). Without this `max-width: min-content` on a sized
781            // <canvas>/<img> is ignored.
782            [$contentWidth, $contentHeight] = $this->clampAtomicReplaced($child->style, $contentWidth, $contentHeight);
783            $outerWidth = $contentWidth + $horizontalInset;
784            $outerHeight = $contentHeight + $verticalInset;
785            // The item's inline footprint is its margin box; the block-axis
786            // footprint (line-height contribution) is likewise the margin box.
787            $outerAdvance = $marginLeft + $outerWidth + $marginRight;
788            $marginBoxHeight = $marginTop + $outerHeight + $marginBottom;
789            // CSS 2.1 §9.4.2 — wrap to a new line when the current line
790            // already holds content and this box would overflow the IFC
791            // available width. (A single box wider than the line still
792            // gets its own line rather than an infinite loop.)
793            if ($currentX > 0.0
794                && $currentX + $outerAdvance
795                    > ($this->currentWrapMeasure ?? $this->currentAvailableWidth) + 0.01
796            ) {
797                $lineTop += $lineHeight;
798                $currentX = 0.0;
799                $lineHeight = 0.0;
800            }
801            // Offset the content box by the left/top margin + inset so the
802            // border box's top-left edge sits inside the margin box.
803            $child->geometry->x = $parent->geometry->x + $currentX + $marginLeft + $borderLeft + $padLeft;
804            $child->geometry->y = $parent->geometry->y + $lineTop + $marginTop + $borderTop + $padTop;
805            $child->geometry->width = $contentWidth;
806            $child->geometry->height = $contentHeight;
807            $child->geometry->paddingTop = $padTop;
808            $child->geometry->paddingBottom = $padBottom;
809            $child->geometry->paddingLeft = $padLeft;
810            $child->geometry->paddingRight = $padRight;
811            $child->geometry->borderTop = $borderTop;
812            $child->geometry->borderBottom = $borderBottom;
813            $child->geometry->borderLeft = $borderLeft;
814            $child->geometry->borderRight = $borderRight;
815            $child->geometry->marginTop = $marginTop;
816            $child->geometry->marginBottom = $marginBottom;
817            $child->geometry->marginLeft = $marginLeft;
818            $child->geometry->marginRight = $marginRight;
819            $currentX += $outerAdvance;
820            if ($marginBoxHeight > $lineHeight) {
821                $lineHeight = $marginBoxHeight;
822            }
823            if ($lineTop + $lineHeight > $maxHeight) {
824                $maxHeight = $lineTop + $lineHeight;
825            }
826        }
827        return [[], $maxHeight];
828    }
829
830    /**
831     * Resolve CSS Text 3 §11.2 `tab-size` to an integer space count.
832     *
833     * - `<integer>` / `<number>`: direct space count.
834     * - `<length>`: divide by a glyph-space advance estimate
835     *   (0.25 × font-size — a sane default for sans-serif) and round
836     *   to the nearest integer ≥ 0. This is an approximation since
837     *   tab-stop alignment isn't implemented, but converts a
838     *   length-based author intent to the closest N-space expansion.
839     * - Anything else (`auto`, unknown keywords): the spec default 8.
840     */
841    private function resolveTabSize(Box $box): int
842    {
843        $value = $box->style->get('tab-size');
844        if ($value instanceof \Phpdftk\Css\Value\Integer) {
845            return max(0, $value->value);
846        }
847        if ($value instanceof \Phpdftk\Css\Value\Number) {
848            return max(0, (int) round($value->value));
849        }
850        if ($value instanceof \Phpdftk\Css\Value\Length) {
851            $fontSize = $this->dominantFontSize($box, new LayoutContext(
852                0.0,
853                0.0,
854                0.0,
855                0.0,
856                new \Phpdftk\Css\Cascade\LengthContext(),
857            ));
858            $spaceAdvance = max(0.1, $fontSize * 0.25);
859            return max(0, (int) round($value->value / $spaceAdvance));
860        }
861        return 8;
862    }
863
864    /**
865     * CSS Writing Modes 4 §3 — the container's INLINE size, when it is not
866     * the physical width. In a vertical writing mode the inline axis runs
867     * vertically, so lines must wrap against the container's block size
868     * (its physical height) and only then be transposed onto the vertical
869     * axis by {@see applyVerticalLineShift}. Wrapping against the physical
870     * width and transposing afterwards breaks lines on the wrong axis.
871     *
872     * The geometry height is not committed yet while the inline pass runs,
873     * so this reads the cascaded `height`, which the cascade has already
874     * resolved to px. Returns null for a horizontal container, and for a
875     * vertical one whose block size is indefinite: the used height of
876     * those is only settled AFTER inline layout, so wrapping against it
877     * would be a sizing cycle. Those keep the physical-width measure.
878     */
879    private function verticalInlineMeasure(Box $parent, ?float $inlineExtent = null): ?float
880    {
881        if (!WritingMode::fromStyle($parent->style)->isVertical()) {
882            return null;
883        }
884        $height = $parent->style->get('height');
885        if ($height instanceof Length && $height->value > 0.0) {
886            return $height->value;
887        }
888        // CSS Sizing 4 §6.4 — a block-level box in a vertical containing
889        // block STRETCH-fits its inline size (physical height) to that
890        // containing block, so an `auto` height is still a definite
891        // measure to wrap against. BlockLayout resolves the stretch
892        // before this pass runs and threads the used value in here.
893        if ($inlineExtent !== null && $inlineExtent > 0.0) {
894            return $inlineExtent;
895        }
896        // A §7.3 orthogonal-flow fallback (take the inline size from the
897        // nearest definite ancestor block size) was measured here and
898        // scored EXACTLY the same as this narrower gate, so it is left out:
899        // it widens the blast radius for no gain.
900        return null;
901    }
902
903    /**
904     * Resolve the left and right inset of a line at relative-Y `$y`
905     * against the active {@see FloatContext}. Returns offsets relative
906     * to the parent's content-edge X — so `left` is the line's start X
907     * within the parent's box, and `right` is the line's max-end X.
908     *
909     * Without floats this is just `[0, $availableWidth]`. With a left
910     * float overlapping the line, `left` rises; with a right float,
911     * `right` falls.
912     *
913     * CSS 2.1 §9.5 / CSS Shapes 1 §1.2 — a line box next to a float is
914     * shortened by the MOST-constrained intrusion over the line's full block
915     * extent, not just at its top edge. `$bandHeight` defines that extent and
916     * {@see FloatContext::leftEdgeInBand} takes the true extremum over it —
917     * analytic for `inset()` and `polygon()`, and for `circle()` / `ellipse()`
918     * the value at whichever band endpoint (or the vertical centre, when the
919     * band straddles it) intrudes furthest. A `$bandHeight` of 0 (the default)
920     * degrades to a single-point query at the top edge.
921     *
922     * @return array{left: float, right: float}
923     */
924    private function lineBounds(Box $parent, float $availableWidth, LayoutContext $context, float $relY, float $bandHeight = 0.0): array
925    {
926        // A vertical IFC wraps against the block size, and FloatContext is
927        // physically horizontal — it samples a Y band and returns left/right
928        // X, so feeding it this measure would compare different axes. Until
929        // exclusions are logical, a vertical IFC simply takes the full
930        // measure with no float narrowing.
931        if ($this->currentWrapMeasure !== null) {
932            return ['left' => 0.0, 'right' => $this->currentWrapMeasure];
933        }
934        $floatCtx = $context->floatContext;
935        if ($floatCtx === null) {
936            return ['left' => 0.0, 'right' => $availableWidth];
937        }
938        $parentX = $parent->geometry->x;
939        $absTop = $parent->geometry->y + $relY;
940        $absBottom = $absTop + max(0.0, $bandHeight);
941        $maxLeft = $floatCtx->leftEdgeInBand($absTop, $absBottom, $parentX);
942        $minRight = $floatCtx->rightEdgeInBand($absTop, $absBottom, $parentX + $availableWidth);
943        return [
944            'left' => max(0.0, $maxLeft - $parentX),
945            'right' => max(0.0, $minRight - $parentX),
946        ];
947    }
948
949    /**
950     * Drop fragments from each overflowing line until an ellipsis glyph
951     * fits at the end. Only applies when the parent's `text-overflow` is
952     * `ellipsis`; the default `clip` keyword silently lets the content
953     * overflow (matching the no-op CSS spec behaviour).
954     *
955     * @param list<LineBox> $lines
956     * @return list<LineBox>
957     */
958    private function applyTextOverflow(
959        array $lines,
960        float $availableWidth,
961        Box $parent,
962        ShapingContext $shapingCtx,
963        float $letterSpacing,
964    ): array {
965        $value = $parent->style->get('text-overflow');
966        if (!($value instanceof \Phpdftk\Css\Value\Keyword)
967            || strtolower($value->name) !== 'ellipsis'
968        ) {
969            return $lines;
970        }
971        $out = [];
972        foreach ($lines as $line) {
973            // `force: false` — only lines whose content actually overflows the
974            // available width get an ellipsis (CSS UI 3 §6.2).
975            $out[] = $this->appendEllipsisToLine($line, $availableWidth, $shapingCtx, $letterSpacing, false);
976        }
977        return $out;
978    }
979
980    /**
981     * Append a U+2026 ellipsis to the end of a single line, trimming the
982     * trailing content so the ellipsis fits within `$availableWidth`. When
983     * `$force` is false the line is returned untouched unless its content
984     * exceeds the width (the `text-overflow: ellipsis` contract); when true
985     * the ellipsis is added regardless of overflow (the line-clamp
986     * block-ellipsis, required on the last retained line even if it fits).
987     */
988    private function appendEllipsisToLine(
989        LineBox $line,
990        float $availableWidth,
991        ShapingContext $shapingCtx,
992        float $letterSpacing,
993        bool $force,
994    ): LineBox {
995        if (!$force && $line->totalWidth() <= $availableWidth) {
996            return $line;
997        }
998        $ellipsis = $this->shaper->shapeRun("\u{2026}", $shapingCtx);
999        if ($ellipsis->glyphs === []) {
1000            return $line;
1001        }
1002        if ($letterSpacing !== 0.0) {
1003            $ellipsis = $this->applyLetterSpacing($ellipsis, $letterSpacing);
1004        }
1005        $ellipsisWidth = $ellipsis->totalAdvance;
1006
1007        // Drop fragments from the end until the remaining content + ellipsis
1008        // fits, then trim glyphs off the trailing fragment if it still
1009        // overflows. Per CSS UI 3 §6.2 the ellipsis sits immediately adjacent
1010        // to the last visible glyph.
1011        $fragments = $line->fragments;
1012        $cutoff = $availableWidth - $ellipsisWidth;
1013        while ($fragments !== []) {
1014            $last = $fragments[array_key_last($fragments)];
1015            if ($last->x + $last->width <= $cutoff) {
1016                break;
1017            }
1018            // Try a per-glyph trim of the trailing fragment before discarding
1019            // it whole — `ppppp` overflowing `400px / 100px-per-glyph` keeps
1020            // `ppp` + ellipsis, not just an orphan ellipsis at x = 0.
1021            $trimmed = $this->trimFragmentToFit($last, $cutoff);
1022            if ($trimmed !== null) {
1023                $fragments[array_key_last($fragments)] = $trimmed;
1024                break;
1025            }
1026            array_pop($fragments);
1027        }
1028        if ($fragments === []) {
1029            // Nothing fits before the ellipsis; emit just the ellipsis at
1030            // x = 0 so the user sees something.
1031            $fragments[] = new InlineFragment(0.0, $ellipsisWidth, $ellipsis);
1032        } else {
1033            $last = $fragments[array_key_last($fragments)];
1034            $tail = $last->x + $last->width;
1035            $fragments[] = new InlineFragment($tail, $ellipsisWidth, $ellipsis);
1036        }
1037
1038        return new LineBox($line->y, $line->height, $fragments, $line->baseline, $line->availableRight);
1039    }
1040
1041    /**
1042     * CSS Overflow 4 §6 — truncate an already-laid-out line host to its
1043     * first `$keep` line boxes and force the block ellipsis onto the last
1044     * retained one.
1045     *
1046     * The single-block clamp in {@see layout()} only sees the lines one box
1047     * produced for itself. `line-clamp` counts the lines of the clamp
1048     * container's whole formatting context, so the block-level pass in
1049     * {@see BlockLayout} walks the subtree and calls back in here for the
1050     * host that straddles the clamp point. The ellipsis is forced even when
1051     * `count($lines) === $keep`: the caller only reaches this host because
1052     * more lines follow it, which is exactly the condition the block
1053     * ellipsis marks.
1054     *
1055     * Returns the block-axis extent of the retained lines in the host's own
1056     * coordinate space (0 when every line was dropped).
1057     */
1058    public function clampHostLines(Box $host, int $keep, LayoutContext $context): float
1059    {
1060        if ($keep <= 0) {
1061            $host->lineBoxes = [];
1062
1063            return 0.0;
1064        }
1065        $lines = $host->lineBoxes;
1066        if ($lines === []) {
1067            return 0.0;
1068        }
1069        $this->currentFontResolver = $context->fontResolver;
1070        $match = $this->resolveBoxFont($host, $context->defaultFont);
1071        $font = $match['font'];
1072        if ($font === null) {
1073            // No shapeable font — drop the surplus lines without an
1074            // ellipsis rather than leaving the overflow visible.
1075            $host->lineBoxes = array_slice($lines, 0, $keep);
1076
1077            return $this->lineExtent($host->lineBoxes);
1078        }
1079        $shapingCtx = new ShapingContext(
1080            $font,
1081            $this->dominantFontSize($host, $context),
1082            features: $this->resolveOpenTypeFeatures($host),
1083        );
1084        [$kept, $height] = $this->applyLineClamp(
1085            $lines,
1086            $keep,
1087            $host->geometry->width,
1088            $shapingCtx,
1089            $this->resolveLetterSpacing($host),
1090        );
1091        $host->lineBoxes = $kept;
1092
1093        return $height;
1094    }
1095
1096    /**
1097     * Block-axis extent of a line list (the bottom edge of its last line).
1098     *
1099     * @param list<LineBox> $lines
1100     */
1101    private function lineExtent(array $lines): float
1102    {
1103        $height = 0.0;
1104        foreach ($lines as $line) {
1105            $height = max($height, $line->y + $line->height);
1106        }
1107
1108        return $height;
1109    }
1110
1111    /**
1112     * The effective `line-clamp` count for a block, or null when the block is
1113     * not clamped. The standard `line-clamp: <integer>` applies to any block;
1114     * the legacy `-webkit-line-clamp: <integer>` only takes effect under the
1115     * legacy `display: -webkit-box` / `-webkit-box-orient: vertical` pairing
1116     * (CSS Overflow 4 Appendix; WPT asserts it is otherwise inert).
1117     */
1118    private function lineClampCount(Box $box): ?int
1119    {
1120        $value = $box->style->get('line-clamp');
1121        if ($value instanceof \Phpdftk\Css\Value\Integer && $value->value > 0) {
1122            return $value->value;
1123        }
1124        $legacy = $box->style->get('-webkit-line-clamp');
1125        if ($legacy instanceof \Phpdftk\Css\Value\Integer
1126            && $legacy->value > 0
1127            && $this->legacyWebkitClampApplies($box)
1128        ) {
1129            return $legacy->value;
1130        }
1131
1132        return null;
1133    }
1134
1135    /**
1136     * Whether the legacy `-webkit-line-clamp` gate is satisfied: the box must
1137     * be a legacy `-webkit-box` / `-webkit-inline-box` with a vertical
1138     * `-webkit-box-orient`.
1139     */
1140    private function legacyWebkitClampApplies(Box $box): bool
1141    {
1142        $display = $box->style->get('display');
1143        if (!($display instanceof \Phpdftk\Css\Value\Keyword)) {
1144            return false;
1145        }
1146        $d = strtolower($display->name);
1147        if ($d !== '-webkit-box' && $d !== '-webkit-inline-box') {
1148            return false;
1149        }
1150        $orient = $box->style->get('-webkit-box-orient');
1151
1152        return $orient instanceof \Phpdftk\Css\Value\Keyword
1153            && in_array(strtolower($orient->name), ['vertical', 'block-axis'], true);
1154    }
1155
1156    /**
1157     * Clamp the line list to `$maxLines`: keep the first N lines, mark the
1158     * last retained line with a forced block ellipsis, and return the reduced
1159     * line list together with the new block-axis extent (so the block shrinks
1160     * to exactly the retained lines). Caller guarantees `count($lines) >
1161     * $maxLines >= 1`.
1162     *
1163     * @param list<LineBox> $lines
1164     * @return array{list<LineBox>, float}
1165     */
1166    private function applyLineClamp(
1167        array $lines,
1168        int $maxLines,
1169        float $availableWidth,
1170        ShapingContext $shapingCtx,
1171        float $letterSpacing,
1172    ): array {
1173        $kept = array_slice($lines, 0, $maxLines);
1174        $lastIdx = array_key_last($kept);
1175        $kept[$lastIdx] = $this->appendEllipsisToLine($kept[$lastIdx], $availableWidth, $shapingCtx, $letterSpacing, true);
1176        $height = 0.0;
1177        foreach ($kept as $line) {
1178            $height = max($height, $line->y + $line->height);
1179        }
1180
1181        return [$kept, $height];
1182    }
1183
1184    /**
1185     * Trim glyphs off the tail of `$fragment` until its right edge sits
1186     * at or before `$cutoff`. Returns a new `InlineFragment` with the
1187     * shorter `ShapedRun`, or `null` when not a single glyph fits
1188     * (caller should drop the whole fragment instead).
1189     */
1190    private function trimFragmentToFit(InlineFragment $fragment, float $cutoff): ?InlineFragment
1191    {
1192        $shaped = $fragment->shapedRun;
1193        if ($shaped->glyphs === []) {
1194            return null;
1195        }
1196        $maxWidth = max(0.0, $cutoff - $fragment->x);
1197        $keptGlyphs = [];
1198        $total = 0.0;
1199        foreach ($shaped->glyphs as $g) {
1200            if ($total + $g->advanceX > $maxWidth + 0.001) {
1201                break;
1202            }
1203            $keptGlyphs[] = $g;
1204            $total += $g->advanceX;
1205        }
1206        if ($keptGlyphs === []) {
1207            return null;
1208        }
1209        $newShaped = new ShapedRun(
1210            $shaped->font,
1211            $shaped->fontSizePt,
1212            $shaped->direction,
1213            $keptGlyphs,
1214            $total,
1215        );
1216        return new InlineFragment(
1217            $fragment->x,
1218            $total,
1219            $newShaped,
1220            $fragment->baselineShift,
1221            $fragment->href,
1222            $fragment->isBold,
1223            $fragment->isItalic,
1224            $fragment->decorationLines,
1225            $fragment->textColor,
1226            $fragment->backgroundColor,
1227            $fragment->linkTitle,
1228            $fragment->decorationColor,
1229            $fragment->isWhitespace,
1230            $fragment->lineHeight,
1231            $fragment->verticalAlign,
1232        );
1233    }
1234
1235    /**
1236     * Apply the parent's `text-align` to each line: `start` / `left` (default,
1237     * no-op), `center`, `end` / `right`, or `justify`. Justify is approximated
1238     * for the last line as left-aligned per CSS Text 3 §7.3 ("the last line
1239     * of a block, and any line ending with a forced line break, is start-
1240     * aligned"); inter-fragment justification on non-final lines distributes
1241     * extra space evenly across the gaps between fragments.
1242     *
1243     * @param list<LineBox> $lines
1244     * @return list<LineBox>
1245     */
1246    private function applyTextAlign(array $lines, float $availableWidth, Box $parent): array
1247    {
1248        $align = $this->textAlignKeyword($parent);
1249        $alignLast = $this->textAlignLastKeyword($parent, $align);
1250        // CSS Writing Modes 4 §3 — `text-align` aligns along the INLINE axis.
1251        // In a vertical writing mode the inline axis is vertical, so alignment
1252        // slack is measured against the container's inline size (its physical
1253        // height), not the block-axis `availableWidth` the horizontal model
1254        // was handed. (Increment 1 transposes the resulting inline offset onto
1255        // the vertical axis downstream in `applyVerticalLineShift`.)
1256        $wm = WritingMode::fromStyle($parent->style);
1257        // Same measure the fitter wrapped against, so alignment slack is
1258        // computed on the inline axis in both writing modes.
1259        $inlineExtent = $this->currentWrapMeasure ?? $availableWidth;
1260        // CSS Text 3 §7.2: `justify-all` is `justify` for every line
1261        // including the trailing one. Normalise to `justify` for the
1262        // body lines and force the last-line alignment to `justify`
1263        // too (the `textAlignLastKeyword` fallback would otherwise
1264        // start-align the last line for plain `justify`).
1265        if ($align === 'justify-all') {
1266            $align = 'justify';
1267            $alignLast = 'justify';
1268        }
1269        // CSS Text 3 §7.5: `text-justify: none` disables justification.
1270        // A `justify` text-align falls through to start-alignment.
1271        if ($this->isTextJustifyNone($parent)) {
1272            if ($align === 'justify') {
1273                $align = 'start';
1274            }
1275            if ($alignLast === 'justify') {
1276                $alignLast = 'start';
1277            }
1278        }
1279        // CSS Text 3 §7.1 — resolve direction-relative `start` / `end`
1280        // against the parent's writing direction. `start` is the
1281        // inline-start edge (left in LTR, right in RTL); `end` is the
1282        // inline-end edge (right in LTR, left in RTL). The physical
1283        // `left` / `right` / `center` values pass through unchanged.
1284        $isRtl = $this->isRtlDirection($parent);
1285        $align = $this->resolveLogicalTextAlign($align, $isRtl);
1286        $alignLast = $this->resolveLogicalTextAlign($alignLast, $isRtl);
1287        if ($align === 'left') {
1288            if ($alignLast === 'left' || $alignLast === 'auto') {
1289                return $lines;
1290            }
1291        }
1292        $count = count($lines);
1293        $out = [];
1294        foreach ($lines as $i => $line) {
1295            // CSS Text 3 §5.5 — trailing whitespace at the end of a
1296            // line hangs (zero-width for line measurement). For
1297            // alignment slack this means we measure the line's
1298            // visible content edge, NOT the full fragment tail.
1299            $used = $this->lineUsedWidth($line);
1300            // CSS 2.1 §9.5 — a line beside a float is SHORTER than the
1301            // container. Measuring slack against the container's inline
1302            // size instead sends right-aligned and centred text sliding
1303            // under the float.
1304            //
1305            // The fitter's bound is only comparable when it was measured in
1306            // the same axis alignment uses. That now holds for a vertical
1307            // IFC too whenever the fitter had a definite block size to wrap
1308            // against (`currentWrapMeasure`); a vertical container with an
1309            // indefinite block size still wraps against the physical width,
1310            // so its bound belongs to the other axis and is ignored.
1311            $axesAgree = !$wm->isVertical() || $this->currentWrapMeasure !== null;
1312            $lineExtent = $axesAgree && $line->availableRight !== null
1313                ? $line->availableRight
1314                : $inlineExtent;
1315            $slack = $lineExtent - $used;
1316            if ($slack <= 0.0) {
1317                $out[] = $line;
1318                continue;
1319            }
1320            $isLast = $i === $count - 1;
1321            $effective = $isLast ? $alignLast : $align;
1322            $newFragments = match ($effective) {
1323                'center' => $this->shiftFragments($line->fragments, $slack / 2.0),
1324                'right' => $this->shiftFragments($line->fragments, $slack),
1325                'justify' => $this->justifyFragments($line->fragments, $slack),
1326                default => $line->fragments,
1327            };
1328            $out[] = new LineBox($line->y, $line->height, $newFragments, $line->baseline, $line->availableRight);
1329        }
1330        return $out;
1331    }
1332
1333    /**
1334     * Compute the line's content edge for alignment, excluding any
1335     * trailing whitespace fragments (CSS Text 3 §5.5). Used by
1336     * `applyTextAlign` so the centre / right shift respects the
1337     * visible content's right edge, not the hung-whitespace tail.
1338     */
1339    private function lineUsedWidth(LineBox $line): float
1340    {
1341        $right = 0.0;
1342        $fragments = $line->fragments;
1343        // Walk backwards skipping trailing whitespace fragments.
1344        $lastVisible = count($fragments) - 1;
1345        while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) {
1346            $lastVisible--;
1347        }
1348        for ($i = 0; $i <= $lastVisible; $i++) {
1349            $edge = $fragments[$i]->x + $fragments[$i]->width;
1350            if ($edge > $right) {
1351                $right = $edge;
1352            }
1353        }
1354        return $right;
1355    }
1356
1357    /**
1358     * Read the parent's cascaded `direction` and report whether it
1359     * resolves to `rtl`. Defaults to LTR when the property is
1360     * missing or the value isn't a Keyword the spec recognises.
1361     */
1362    private function isRtlDirection(Box $parent): bool
1363    {
1364        $value = $parent->style->get('direction');
1365        return $value instanceof \Phpdftk\Css\Value\Keyword
1366            && strtolower($value->name) === 'rtl';
1367    }
1368
1369    /**
1370     * Map a CSS `text-align` keyword to its physical equivalent.
1371     * `start` → `left` in LTR, `right` in RTL; `end` → `right` in
1372     * LTR, `left` in RTL. The physical keywords pass through.
1373     */
1374    private function resolveLogicalTextAlign(string $align, bool $isRtl): string
1375    {
1376        return match ($align) {
1377            'start' => $isRtl ? 'right' : 'left',
1378            'end' => $isRtl ? 'left' : 'right',
1379            default => $align,
1380        };
1381    }
1382
1383    /**
1384     * CSS Text 3 §7.5 — `true` when the parent declares
1385     * `text-justify: none`, in which case the justify branches of
1386     * `text-align` and `text-align-last` collapse to start-alignment.
1387     */
1388    private function isTextJustifyNone(Box $parent): bool
1389    {
1390        $value = $parent->style->get('text-justify');
1391        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
1392            return false;
1393        }
1394        return strtolower($value->name) === 'none';
1395    }
1396
1397    /**
1398     * Resolve CSS Text 3 §7.4 `text-align-last`. `auto` (initial)
1399     * inherits the block-aligned behaviour: when text-align is
1400     * `justify` the last line is start-aligned, otherwise it matches
1401     * text-align. Explicit values override.
1402     */
1403    private function textAlignLastKeyword(Box $parent, string $align): string
1404    {
1405        $value = $parent->style->get('text-align-last');
1406        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
1407            return 'auto';
1408        }
1409        $lower = strtolower($value->name);
1410        if ($lower === 'auto') {
1411            // text-align: justify → last line is start-aligned per spec
1412            // (CSS Text 3 §7.4); `justify-all` is handled by the
1413            // caller before this resolution runs.
1414            return $align === 'justify' ? 'start' : $align;
1415        }
1416        // `text-align-last: justify-all` doesn't appear in any spec
1417        // grammar — only `text-align: justify-all` exists. Treat any
1418        // stray value as plain `justify` so the trailing line still
1419        // gets the fully-justified shifting.
1420        if ($lower === 'justify-all') {
1421            return 'justify';
1422        }
1423        return $lower;
1424    }
1425
1426    private function textAlignKeyword(Box $parent): string
1427    {
1428        $value = $parent->style->get('text-align');
1429        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1430            return strtolower($value->name);
1431        }
1432        return 'start';
1433    }
1434
1435    /**
1436     * Apply CSS Text 3 §2 `text-transform` to a text run before shaping.
1437     * `uppercase` / `lowercase` are full case mappings via `mb_strtoupper` /
1438     * `mb_strtolower`; `capitalize` upper-cases the first grapheme of each
1439     * whitespace-separated word; `full-width` / `full-size-kana` and other
1440     * Phase-2 transforms fall through unchanged.
1441     */
1442    private function applyTextTransform(string $text, Box $box): string
1443    {
1444        $value = $box->style->get('text-transform');
1445        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
1446            return $text;
1447        }
1448        return \Phpdftk\Text\TextTransform::apply($text, $value->name);
1449    }
1450
1451    /**
1452     * Resolve CSS Inline 3 §3 `line-height`:
1453     *  - `normal` → font-dependent multiplier (1.2 for Latin until proper
1454     *    OS/2 typo-metrics-driven line-height ships).
1455     *  - `<number>` → multiplier of font-size; value inherits as the number
1456     *    so children re-resolve against their own size.
1457     *  - `<length>` → absolute, already in px after `Cascade::resolveLengths`.
1458     *  - `<percentage>` → percentage of the element's own font-size.
1459     */
1460    private function resolveLineHeight(Box $parent, float $fontSize): float
1461    {
1462        $value = $parent->style->get('line-height');
1463        if ($value instanceof \Phpdftk\Css\Value\Keyword
1464            && strtolower($value->name) === 'normal'
1465        ) {
1466            return $fontSize * 1.2;
1467        }
1468        if ($value instanceof \Phpdftk\Css\Value\Number
1469            || $value instanceof \Phpdftk\Css\Value\Integer
1470        ) {
1471            return $fontSize * $value->value;
1472        }
1473        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1474            return $fontSize * ($value->value / 100.0);
1475        }
1476        if ($value instanceof Length) {
1477            return $value->value;
1478        }
1479        return $fontSize * 1.2;
1480    }
1481
1482    /**
1483     * Resolve the parent's `text-indent` CSS value against the available
1484     * width. Length resolves directly; Percentage resolves against the
1485     * block's content width per CSS Text 3 §3.1; everything else falls to 0.
1486     */
1487    private function resolveTextIndent(Box $parent, float $availableWidth): float
1488    {
1489        $value = $parent->style->get('text-indent');
1490        if ($value instanceof Length) {
1491            return $value->value;
1492        }
1493        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1494            return $availableWidth * ($value->value / 100.0);
1495        }
1496        return 0.0;
1497    }
1498
1499    private function whiteSpaceKeyword(Box $parent): string
1500    {
1501        $value = $parent->style->get('white-space');
1502        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1503            return strtolower($value->name);
1504        }
1505        return 'normal';
1506    }
1507
1508    /**
1509     * Translate a line's fragments along the inline axis. `InlineFragment`
1510     * is readonly, so each one is rebuilt at the new `x`.
1511     *
1512     * Public because `BlockLayout` reuses it when it moves whole lines —
1513     * e.g. distributing an inline formatting context's lines into
1514     * multi-column columns.
1515     *
1516     * @param list<InlineFragment> $fragments
1517     * @return list<InlineFragment>
1518     */
1519    public function shiftFragments(array $fragments, float $dx): array
1520    {
1521        $out = [];
1522        foreach ($fragments as $f) {
1523            $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, $f->lineHeight, $f->verticalAlign, $f->blockOffset, $f->atomicBox, $f->bgExtendAbove, $f->bgExtendBelow);
1524        }
1525        return $out;
1526    }
1527
1528    /**
1529     * Spread the slack across the gaps between fragments (CSS Text 3 §7.3
1530     * `justify-content` approximation for inter-word distribution).
1531     *
1532     * @param list<InlineFragment> $fragments
1533     * @return list<InlineFragment>
1534     */
1535    private function justifyFragments(array $fragments, float $slack): array
1536    {
1537        // CSS Text 3 §7.5 — trailing whitespace at the end of a line
1538        // is excluded from justification (it hangs). Skip trailing
1539        // whitespace fragments when counting gaps and when shifting.
1540        $lastVisible = count($fragments) - 1;
1541        while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) {
1542            $lastVisible--;
1543        }
1544        if ($lastVisible < 1) {
1545            return $fragments;
1546        }
1547        $gaps = $lastVisible;
1548        $delta = $slack / $gaps;
1549        $out = [];
1550        foreach ($fragments as $i => $f) {
1551            // Trailing-ws fragments pinned to whatever the last
1552            // visible fragment's right edge becomes — they aren't
1553            // shifted (they hang past the line edge).
1554            $shift = $i <= $lastVisible ? $i * $delta : $lastVisible * $delta;
1555            $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, $f->lineHeight, $f->verticalAlign, $f->blockOffset, $f->atomicBox, $f->bgExtendAbove, $f->bgExtendBelow);
1556        }
1557        return $out;
1558    }
1559
1560    /**
1561     * Tokenise the inline subtree at line-break opportunities and shape
1562     * each token. Each token records its width, its source kind
1563     * (whitespace / non-whitespace), and whether it closes a mandatory
1564     * break.
1565     *
1566     * @param list<string> $decorationLines
1567     * @return list<array{
1568     *     shapedRun: ShapedRun,
1569     *     isWhitespace: bool,
1570     *     kind: LineBreakKind,
1571     *     trailingSpace: float,
1572     *     noWrapBefore?: bool,
1573     *     baselineShift?: float,
1574     *     lineHeight?: float,
1575     *     verticalAlign?: string,
1576     *     href?: string|null,
1577     *     isBold?: bool,
1578     *     isItalic?: bool,
1579     *     decorationLines?: list<string>,
1580     *     textColor?: \Phpdftk\Css\Value\Color|null,
1581     *     backgroundColor?: \Phpdftk\Css\Value\Color|null,
1582     *     linkTitle?: string|null,
1583     *     decorationColor?: \Phpdftk\Css\Value\Color|null,
1584     *     bgExtendAbove?: float,
1585     *     bgExtendBelow?: float,
1586     *     atomicBox?: AtomicInlineBox,
1587     *     atomicContentWidth?: float,
1588     *     atomicOuterWidth?: float,
1589     *     atomicPadLeft?: float,
1590     *     atomicPadRight?: float,
1591     *     atomicBorderLeft?: float,
1592     *     atomicBorderRight?: float,
1593     *     atomicMarginLeft?: float,
1594     *     atomicMarginRight?: float,
1595     *     atomicMarginTop?: float,
1596     *     atomicMarginBottom?: float,
1597     *     atomicBorderBox?: bool,
1598     * }>
1599     */
1600    private function collectTokens(
1601        Box $parent,
1602        ShapingContext $shapingCtx,
1603        bool $collapseInternal,
1604        float $letterSpacing,
1605        float $wordSpacing,
1606        float $baselineShift,
1607        float $lineHeight,
1608        string $verticalAlign,
1609        ?string $href,
1610        bool $isBold,
1611        bool $isItalic,
1612        array $decorationLines,
1613        ?\Phpdftk\Css\Value\Color $textColor,
1614        ?\Phpdftk\Css\Value\Color $backgroundColor,
1615        ?string $linkTitle,
1616        ?\Phpdftk\Css\Value\Color $decorationColor,
1617        bool $splitWsBoundaries = false,
1618    ): array {
1619        $out = [];
1620        foreach ($parent->children as $child) {
1621            $this->walkInline(
1622                $child,
1623                $shapingCtx,
1624                $out,
1625                $collapseInternal,
1626                $letterSpacing,
1627                $wordSpacing,
1628                $baselineShift,
1629                $lineHeight,
1630                $verticalAlign,
1631                $href,
1632                $isBold,
1633                $isItalic,
1634                $decorationLines,
1635                $textColor,
1636                $backgroundColor,
1637                $linkTitle,
1638                $decorationColor,
1639                $splitWsBoundaries,
1640            );
1641        }
1642        // CSS 2.1 §16.6.1 — collapse runs of whitespace across the
1643        // *entire* inline tree, not per text node. The per-TextBox
1644        // collapse in `walkInline` only sees one node at a time, so
1645        // `<span>a </span><span> b</span>` arrives here as two
1646        // adjacent whitespace tokens. Drop any whitespace token whose
1647        // predecessor already ENDS in collapsible whitespace (only when
1648        // collapsing is on).
1649        //
1650        // "Ends in whitespace" is not the same as "is whitespace": the
1651        // tokeniser bundles a word with its trailing space (UAX #14 puts
1652        // the break opportunity AFTER the space), so `X <span> <i>s`
1653        // arrives as [`X `, ` `, `s`] — a run this pass used to keep
1654        // BOTH spaces of, because the first token isn't whitespace-only.
1655        // That put a spurious second space in front of every inline box
1656        // whose own content starts with collapsible whitespace.
1657        if ($collapseInternal) {
1658            $deduped = [];
1659            $prevEndsInWs = false;
1660            foreach ($out as $token) {
1661                if ($token['isWhitespace'] && $prevEndsInWs) {
1662                    continue;
1663                }
1664                $deduped[] = $token;
1665                $prevEndsInWs = $token['isWhitespace'] || $token['trailingSpace'] > 0.0;
1666            }
1667            $out = $deduped;
1668        }
1669        return $out;
1670    }
1671
1672    /**
1673     * @param list<array{
1674     *     shapedRun: ShapedRun,
1675     *     isWhitespace: bool,
1676     *     kind: LineBreakKind,
1677     *     trailingSpace: float,
1678     *     noWrapBefore?: bool,
1679     *     baselineShift?: float,
1680     *     lineHeight?: float,
1681     *     verticalAlign?: string,
1682     *     href?: string|null,
1683     *     isBold?: bool,
1684     *     isItalic?: bool,
1685     *     decorationLines?: list<string>,
1686     *     textColor?: \Phpdftk\Css\Value\Color|null,
1687     *     backgroundColor?: \Phpdftk\Css\Value\Color|null,
1688     *     linkTitle?: string|null,
1689     *     decorationColor?: \Phpdftk\Css\Value\Color|null,
1690     *     bgExtendAbove?: float,
1691     *     bgExtendBelow?: float,
1692     *     atomicBox?: AtomicInlineBox,
1693     *     atomicContentWidth?: float,
1694     *     atomicOuterWidth?: float,
1695     *     atomicPadLeft?: float,
1696     *     atomicPadRight?: float,
1697     *     atomicBorderLeft?: float,
1698     *     atomicBorderRight?: float,
1699     *     atomicMarginLeft?: float,
1700     *     atomicMarginRight?: float,
1701     *     atomicMarginTop?: float,
1702     *     atomicMarginBottom?: float,
1703     *     atomicBorderBox?: bool,
1704     * }> $tokens
1705     * @param list<string> $decorationLines
1706     */
1707    private function walkInline(
1708        Box $box,
1709        ShapingContext $shapingCtx,
1710        array &$tokens,
1711        bool $collapseInternal,
1712        float $letterSpacing,
1713        float $wordSpacing,
1714        float $baselineShift,
1715        float $lineHeight,
1716        string $verticalAlign,
1717        ?string $href,
1718        bool $isBold,
1719        bool $isItalic,
1720        array $decorationLines,
1721        ?\Phpdftk\Css\Value\Color $textColor,
1722        ?\Phpdftk\Css\Value\Color $backgroundColor,
1723        ?string $linkTitle,
1724        ?\Phpdftk\Css\Value\Color $decorationColor,
1725        bool $splitWsBoundaries = false,
1726        float $bgExtendAbove = 0.0,
1727        float $bgExtendBelow = 0.0,
1728    ): void {
1729        if ($box instanceof TextBox) {
1730            $text = $box->text;
1731            if ($collapseInternal) {
1732                // CSS Text 3 §4.1.1: in `normal` / `nowrap`, runs of
1733                // whitespace collapse to a single space. Newlines collapse
1734                // alongside spaces / tabs / form feeds.
1735                $text = preg_replace('/[ \t\n\r\f]+/', ' ', $text) ?? $text;
1736            } else {
1737                // CSS Text 3 §11.2 — in white-space modes that preserve
1738                // tabs (`pre`, `pre-wrap`), each U+0009 expands to N
1739                // spaces. Phase-1 simplification: fixed expansion
1740                // instead of tab-stop alignment (which would require
1741                // tracking column position across mid-text breaks).
1742                $tabSize = $this->resolveTabSize($box);
1743                if ($tabSize > 0) {
1744                    $text = str_replace("\t", str_repeat(' ', $tabSize), $text);
1745                } else {
1746                    $text = str_replace("\t", '', $text);
1747                }
1748            }
1749            // CSS Text 3 §2: `text-transform` runs before shaping so the
1750            // shaper sees the transformed codepoints.
1751            $text = $this->applyTextTransform($text, $box);
1752            $breakAll = $this->isBreakAll($box);
1753            foreach ($this->tokeniseText($text, $shapingCtx, $letterSpacing, $wordSpacing, $breakAll, $splitWsBoundaries) as $token) {
1754                $token['baselineShift'] = $baselineShift;
1755                $token['lineHeight'] = $lineHeight;
1756                $token['verticalAlign'] = $verticalAlign;
1757                $token['href'] = $href;
1758                $token['isBold'] = $isBold;
1759                $token['isItalic'] = $isItalic;
1760                $token['decorationLines'] = $decorationLines;
1761                $token['textColor'] = $textColor;
1762                $token['backgroundColor'] = $backgroundColor;
1763                $token['bgExtendAbove'] = $bgExtendAbove;
1764                $token['bgExtendBelow'] = $bgExtendBelow;
1765                $token['linkTitle'] = $linkTitle;
1766                $token['decorationColor'] = $decorationColor;
1767                $tokens[] = $token;
1768            }
1769            return;
1770        }
1771        if ($box instanceof LineBreakBox) {
1772            // `<br>` — hard break that survives `white-space: normal`'s
1773            // collapsing. Emit a zero-width mandatory-break token so the
1774            // line-fitter closes the current line and starts a new one.
1775            $tokens[] = [
1776                'shapedRun' => new ShapedRun(
1777                    $shapingCtx->font,
1778                    $shapingCtx->fontSizePt,
1779                    $shapingCtx->direction,
1780                    [],
1781                    0.0,
1782                ),
1783                'isWhitespace' => false,
1784                'trailingSpace' => 0.0,
1785                'kind' => LineBreakKind::Mandatory,
1786                'lineHeight' => $lineHeight,
1787                'verticalAlign' => $verticalAlign,
1788            ];
1789            return;
1790        }
1791        if ($box instanceof AtomicInlineBox) {
1792            // Resolve the box's intrinsic *outer* horizontal advance —
1793            // what the line breaker needs — and the *content-box*
1794            // width that the painter draws into. The two differ
1795            // whenever the atomic carries padding / border, and they
1796            // diverge further under `box-sizing: border-box` (CSS
1797            // Sizing 3 §6.2): under border-box, the declared `width`
1798            // already includes padding + border, so the content
1799            // shrinks by that inset instead of the outer growing.
1800            $widthValue = $box->style->get('width');
1801            // CSS Sizing 3 §6.2 — percentages on atomic-inline
1802            // `width` resolve against the inline-formatting-context's
1803            // containing block (= the `availableWidth` parameter).
1804            // Without this, `<canvas style="width: 100%">` collapses
1805            // to 0 instead of stretching to fill the line.
1806            $declaredWidth = match (true) {
1807                $widthValue instanceof Length => $widthValue->value,
1808                $widthValue instanceof \Phpdftk\Css\Value\Percentage
1809                    => $this->currentAvailableWidth * ($widthValue->value / 100.0),
1810                default => 0.0,
1811            };
1812            $atomicPadLeft = self::atomicLength($box->style->get('padding-left'));
1813            $atomicPadRight = self::atomicLength($box->style->get('padding-right'));
1814            $atomicBorderLeft = self::atomicBorderWidth($box->style, 'left');
1815            $atomicBorderRight = self::atomicBorderWidth($box->style, 'right');
1816            $horizontalInset = $atomicPadLeft + $atomicPadRight + $atomicBorderLeft + $atomicBorderRight;
1817            // CSS 2.2 §10.8 — an inline-block's margins participate in layout:
1818            // horizontal margins add to the inline advance it occupies on the
1819            // line; vertical margins are part of its margin box (which
1820            // determines its line-height contribution). Previously dropped, so
1821            // adjacent inline-blocks touched instead of showing their gaps.
1822            $atomicMarginLeft = self::atomicLength($box->style->get('margin-left'));
1823            $atomicMarginRight = self::atomicLength($box->style->get('margin-right'));
1824            $atomicMarginTop = self::atomicLength($box->style->get('margin-top'));
1825            $atomicMarginBottom = self::atomicLength($box->style->get('margin-bottom'));
1826            $atomicBorderBox = self::atomicIsBorderBoxSizing($box->style);
1827            // CSS 2.1 §9.4.2 — when the box has an inner formatting
1828            // context, `BlockLayout::layoutInlineAtomicContents()` has
1829            // already laid it out and resolved its used content size
1830            // (shrink-to-fit for `width: auto`, §10.3.9). Take that size
1831            // and the box-model edges it resolved verbatim: recomputing
1832            // them here from the cascade would desynchronise the advance
1833            // the line breaker allocates from the geometry the children
1834            // were actually laid out in.
1835            if ($box->laidOutContentWidth !== null) {
1836                $g = $box->geometry;
1837                $atomicPadLeft = $g->paddingLeft;
1838                $atomicPadRight = $g->paddingRight;
1839                $atomicBorderLeft = $g->borderLeft;
1840                $atomicBorderRight = $g->borderRight;
1841                $atomicMarginLeft = $g->marginLeft;
1842                $atomicMarginRight = $g->marginRight;
1843                $atomicMarginTop = $g->marginTop;
1844                $atomicMarginBottom = $g->marginBottom;
1845                $horizontalInset = $atomicPadLeft + $atomicPadRight
1846                    + $atomicBorderLeft + $atomicBorderRight;
1847                $atomicContentWidth = $box->laidOutContentWidth;
1848                $atomicOuterWidth = $atomicContentWidth + $horizontalInset;
1849            } elseif ($declaredWidth > 0.0) {
1850                if ($atomicBorderBox) {
1851                    // Declared width is the border-box; content shrinks
1852                    // by the inset, outer stays at the declared value.
1853                    $atomicContentWidth = max(0.0, $declaredWidth - $horizontalInset);
1854                    $atomicOuterWidth = $declaredWidth;
1855                } else {
1856                    // Declared width is the content-box; outer grows by
1857                    // the inset.
1858                    $atomicContentWidth = $declaredWidth;
1859                    $atomicOuterWidth = $declaredWidth + $horizontalInset;
1860                }
1861            } else {
1862                // `width: auto`: CSS 2.1 §10.3.2 — a replaced element with a
1863                // definite height and an intrinsic aspect ratio derives its
1864                // width from height × ratio. Resolve the definite content
1865                // height (an explicit length, or a percentage against a
1866                // definite CB height) and transfer it through the ratio, so
1867                // the shaped path sizes an auto-width `<img height="100%">`
1868                // the same way layoutAtomicOnly() does. Without a definite
1869                // height or a ratio, defer to the painter's intrinsic
1870                // fallback (content = 0, no allocated advance).
1871                $definiteHeight = $this->atomicDefiniteContentHeight($box);
1872                $ratio = self::atomicAspectRatio($box->style);
1873                if ($definiteHeight !== null && $definiteHeight > 0.0
1874                    && $ratio !== null && $ratio > 0.0
1875                ) {
1876                    $atomicContentWidth = $definiteHeight * $ratio;
1877                    $atomicOuterWidth = $atomicContentWidth + $horizontalInset;
1878                } else {
1879                    $atomicContentWidth = 0.0;
1880                    $atomicOuterWidth = $horizontalInset;
1881                }
1882            }
1883            // CSS Writing Modes 4 §7.1 — `width` / `height` on a replaced
1884            // or inline-block box stay PHYSICAL, so which of them is the
1885            // inline size depends on the writing mode. Resolve the block
1886            // extents here (not in the fitter loop) because the advance
1887            // below needs them in a vertical mode.
1888            $atomicHeights = $this->resolveAtomicHeights(
1889                $box,
1890                $atomicBorderBox,
1891                $atomicMarginLeft + $atomicOuterWidth + $atomicMarginRight,
1892            );
1893            // Advance = margin-box INLINE size so the line-breaker and fitter
1894            // allocate the item's full footprint along the inline axis: its
1895            // horizontal extent in horizontal-tb, its VERTICAL extent in a
1896            // vertical writing mode.
1897            $atomicAdvance = $this->currentIsVertical
1898                ? $atomicMarginTop + $atomicHeights[1] + $atomicMarginBottom
1899                : $atomicMarginLeft + $atomicOuterWidth + $atomicMarginRight;
1900            $tokens[] = [
1901                'shapedRun' => new ShapedRun(
1902                    $shapingCtx->font,
1903                    $shapingCtx->fontSizePt,
1904                    $shapingCtx->direction,
1905                    [],
1906                    $atomicAdvance,
1907                ),
1908                'isWhitespace' => false,
1909                'trailingSpace' => 0.0,
1910                'kind' => LineBreakKind::Allowed,
1911                'baselineShift' => $baselineShift,
1912                'lineHeight' => $lineHeight,
1913                'verticalAlign' => $this->resolveVerticalAlignKeyword($box),
1914                'href' => $href,
1915                'isBold' => $isBold,
1916                'isItalic' => $isItalic,
1917                'decorationLines' => $decorationLines,
1918                'textColor' => $textColor,
1919                'backgroundColor' => $backgroundColor,
1920                'linkTitle' => $linkTitle,
1921                'decorationColor' => $decorationColor,
1922                'atomicBox' => $box,
1923                'atomicHeights' => $atomicHeights,
1924                'atomicContentWidth' => $atomicContentWidth,
1925                'atomicOuterWidth' => $atomicOuterWidth,
1926                'atomicPadLeft' => $atomicPadLeft,
1927                'atomicPadRight' => $atomicPadRight,
1928                'atomicBorderLeft' => $atomicBorderLeft,
1929                'atomicBorderRight' => $atomicBorderRight,
1930                'atomicMarginLeft' => $atomicMarginLeft,
1931                'atomicMarginRight' => $atomicMarginRight,
1932                'atomicMarginTop' => $atomicMarginTop,
1933                'atomicMarginBottom' => $atomicMarginBottom,
1934                'atomicBorderBox' => $atomicBorderBox,
1935            ];
1936            return;
1937        }
1938        if ($box instanceof InlineBox) {
1939            // CSS Inline 3 §4.5 `vertical-align: sub` / `super` shifts the
1940            // child fragments' baselines. Composes with any outer shift so
1941            // nested `<sup><sub>x</sub></sup>` still has a sensible effect.
1942            $boxShift = $this->resolveVerticalAlign($box, $shapingCtx->fontSizePt);
1943            // HTML 4 / 5 `<a href="...">` — descendants inherit the href so
1944            // the painter can emit a `/Link` annotation per fragment. The
1945            // companion `<a title="...">` lands on the annotation's
1946            // `/Contents` for hover tooltips.
1947            $childHref = $href;
1948            $childTitle = $linkTitle;
1949            if ($box->element !== null
1950                && strtolower($box->element->localName) === 'a'
1951            ) {
1952                $linkUrl = $box->element->getAttribute('href');
1953                if ($linkUrl !== null && $linkUrl !== '') {
1954                    $childHref = $linkUrl;
1955                    $title = $box->element->getAttribute('title');
1956                    $childTitle = $title === null || $title === '' ? null : $title;
1957                }
1958            }
1959            // Inline-level emphasis: resolve this box's own weight/style
1960            // request against the FontResolver. A real face match flips
1961            // the per-fragment fake-bold / fake-italic flags off; an
1962            // unmatched request OR no faceMap entry leaves the cascade's
1963            // synthetic-effect flags on so the painter draws the fallback.
1964            // OR with the inherited flags so `<strong><em>X</em></strong>`
1965            // keeps both effects even when only one resolves to a real face.
1966            $boxMatch = $this->resolveBoxFont($box, $shapingCtx->font);
1967            $childBold = $boxMatch['isBold'] || $isBold;
1968            $childItalic = $boxMatch['isItalic'] || $isItalic;
1969            // CSS Text Decoration 4 §2 says decorations set on an inline
1970            // apply to all in-flow descendant text. Union the box's
1971            // decoration lines with whatever the enclosing context set.
1972            $childDeco = $this->mergeDecorationLines($decorationLines, $this->decorationLines($box));
1973            // §3: `text-decoration-color` doesn't inherit, but when an
1974            // inline element sets a colour explicitly that colour applies
1975            // to its descendant fragments' decorations. A child only
1976            // overrides if it sets its own value — otherwise it keeps the
1977            // closest ancestor's choice.
1978            $childDecoColor = $this->resolveDecorationColor($box) ?? $decorationColor;
1979            // The inline box's own cascaded `color` overrides the inherited
1980            // one — `<a>` gets blue from the UA stylesheet even when its
1981            // parent is black.
1982            $childColor = $this->resolveColor($box) ?? $textColor;
1983            // `background-color` is not inherited but propagates downward
1984            // for inline rendering — every descendant fragment of a
1985            // `<mark>` should carry the yellow rect.
1986            $boxBg = $this->resolveBackground($box);
1987            $childBg = $boxBg ?? $backgroundColor;
1988            // CSS 2.1 §10.6.1 — vertical padding / border on an inline box
1989            // leave line height alone but are still PAINTED, so the box's
1990            // background bleeds over the lines above and below. The insets
1991            // travel with the background they belong to: a box painting its
1992            // own background carries its own insets, otherwise the nearest
1993            // ancestor's pair passes through unchanged.
1994            $childExtendAbove = $bgExtendAbove;
1995            $childExtendBelow = $bgExtendBelow;
1996            if ($boxBg !== null) {
1997                $childExtendAbove = self::atomicBorderWidth($box->style, 'top')
1998                    + self::atomicLength($box->style->get('padding-top'));
1999                $childExtendBelow = self::atomicBorderWidth($box->style, 'bottom')
2000                    + self::atomicLength($box->style->get('padding-bottom'));
2001            }
2002            // Mixed-size inline runs: if this inline carries a different
2003            // computed `font-size` than the active shaping context, build
2004            // a per-subtree context so descendants shape at the right size.
2005            // Same for `font-family` — when an inline names a font that's
2006            // registered in the FontResolver, switch the shaping font.
2007            $childCtx = $shapingCtx;
2008            $boxFontSize = $this->boxFontSize($box) ?? $shapingCtx->fontSizePt;
2009            // CSS Inline 3 §3 — `line-height` inherits (as a number it
2010            // re-resolves against each box's own font-size), so resolve this
2011            // inline box's used line-height here and carry it down. Fragments
2012            // stamp it so `lineMetrics` can apply per-run half-leading
2013            // instead of the block's uniform value.
2014            $childLineHeight = $this->resolveLineHeight($box, $boxFontSize);
2015            // vertical-align does NOT inherit: each inline box gets its own
2016            // keyword (default `baseline`), applied to its direct content.
2017            $childVAlign = $this->resolveVerticalAlignKeyword($box);
2018            $boxFont = $boxMatch['font'] ?? $shapingCtx->font;
2019            $fontSizeChanged = abs($boxFontSize - $shapingCtx->fontSizePt) > 0.001;
2020            $fontChanged = $boxFont !== $shapingCtx->font;
2021            if ($fontSizeChanged || $fontChanged) {
2022                $childCtx = new ShapingContext($boxFont, $boxFontSize);
2023            }
2024            // CSS 2.1 §8.4 — horizontal padding, border and margin on an
2025            // INLINE box are laid out: they add to its advance, pushing the
2026            // following content along. (The vertical ones deliberately do
2027            // NOT affect line height per §10.6.1.) They were being dropped
2028            // entirely, so a padded `<span>` occupied exactly its text.
2029            //
2030            // The inset rides in as a zero-glyph spacer token carrying the
2031            // inline's own background, so the padding area paints with the
2032            // box rather than showing the parent through. It is emitted with
2033            // a non-breaking kind: a line may not break inside an inline's
2034            // own padding.
2035            $leadInset = $this->inlineInlineAxisInset($box, 'left');
2036            if ($leadInset > 0.0) {
2037                $tokens[] = $this->inlineSpacerToken(
2038                    $leadInset,
2039                    $childCtx,
2040                    $baselineShift + $boxShift,
2041                    $childLineHeight,
2042                    $childVAlign,
2043                    $childHref,
2044                    $childBg,
2045                    $childTitle,
2046                    $childExtendAbove,
2047                    $childExtendBelow,
2048                );
2049            }
2050            foreach ($box->children as $c) {
2051                $this->walkInline(
2052                    $c,
2053                    $childCtx,
2054                    $tokens,
2055                    $collapseInternal,
2056                    $letterSpacing,
2057                    $wordSpacing,
2058                    $baselineShift + $boxShift,
2059                    $childLineHeight,
2060                    $childVAlign,
2061                    $childHref,
2062                    $childBold,
2063                    $childItalic,
2064                    $childDeco,
2065                    $childColor,
2066                    $childBg,
2067                    $childTitle,
2068                    $childDecoColor,
2069                    $splitWsBoundaries,
2070                    $childExtendAbove,
2071                    $childExtendBelow,
2072                );
2073            }
2074            $trailInset = $this->inlineInlineAxisInset($box, 'right');
2075            if ($trailInset > 0.0) {
2076                $tokens[] = $this->inlineSpacerToken(
2077                    $trailInset,
2078                    $childCtx,
2079                    $baselineShift + $boxShift,
2080                    $childLineHeight,
2081                    $childVAlign,
2082                    $childHref,
2083                    $childBg,
2084                    $childTitle,
2085                    $childExtendAbove,
2086                    $childExtendBelow,
2087                );
2088            }
2089        }
2090    }
2091
2092    /**
2093     * The inline-axis inset an inline box contributes on one side: its
2094     * margin + border + padding (CSS 2.1 §8.4).
2095     */
2096    private function inlineInlineAxisInset(InlineBox $box, string $side): float
2097    {
2098        return self::atomicLength($box->style->get("margin-$side"))
2099            + self::atomicBorderWidth($box->style, $side)
2100            + self::atomicLength($box->style->get("padding-$side"));
2101    }
2102
2103    /**
2104     * A zero-glyph token that occupies `$width` of inline advance — used for
2105     * an inline box's own horizontal padding / border / margin.
2106     *
2107     * It carries the inline's background so the inset paints as part of the
2108     * box, and is marked `Mandatory`-free: `kind` stays `Allowed` only at the
2109     * OUTER edges of the run, never between the inset and its own text, which
2110     * is why the spacer itself reports no break opportunity of its own.
2111     *
2112     * @return array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind, trailingSpace: float, baselineShift: float, lineHeight: float, verticalAlign: string, href: ?string, backgroundColor: ?\Phpdftk\Css\Value\Color, linkTitle: ?string}
2113     */
2114    private function inlineSpacerToken(
2115        float $width,
2116        ShapingContext $ctx,
2117        float $baselineShift,
2118        float $lineHeight,
2119        string $verticalAlign,
2120        ?string $href,
2121        ?\Phpdftk\Css\Value\Color $background,
2122        ?string $linkTitle,
2123        float $bgExtendAbove = 0.0,
2124        float $bgExtendBelow = 0.0,
2125    ): array {
2126        return [
2127            'shapedRun' => new ShapedRun(
2128                $ctx->font,
2129                $ctx->fontSizePt,
2130                \Phpdftk\Text\ShapingDirection::Ltr,
2131                [],
2132                $width,
2133            ),
2134            'isWhitespace' => false,
2135            'kind' => LineBreakKind::Allowed,
2136            'trailingSpace' => 0.0,
2137            'noWrapBefore' => true,
2138            'baselineShift' => $baselineShift,
2139            'lineHeight' => $lineHeight,
2140            'verticalAlign' => $verticalAlign,
2141            'href' => $href,
2142            'backgroundColor' => $background,
2143            'bgExtendAbove' => $bgExtendAbove,
2144            'bgExtendBelow' => $bgExtendBelow,
2145            'linkTitle' => $linkTitle,
2146        ];
2147    }
2148
2149    /**
2150     * The alphabetic ascent of a fragment's shaping font in layout units:
2151     * `(font.ascent / unitsPerEm) × fontSizePt`. Mirrors the painter's own
2152     * ascent computation so the layout baseline and the painted baseline
2153     * agree.
2154     */
2155    private function fragmentAscent(InlineFragment $f): float
2156    {
2157        $font = $f->shapedRun->font;
2158        return ($font->ascent / max(1, $font->unitsPerEm)) * $f->shapedRun->fontSizePt;
2159    }
2160
2161    /**
2162     * The alphabetic descent of a fragment's shaping font in layout units:
2163     * `(|font.descent| / unitsPerEm) × fontSizePt`. Positive (distance below
2164     * the baseline), mirroring the painter's descent computation.
2165     */
2166    private function fragmentDescent(InlineFragment $f): float
2167    {
2168        $font = $f->shapedRun->font;
2169        return (abs($font->descent) / max(1, $font->unitsPerEm)) * $f->shapedRun->fontSizePt;
2170    }
2171
2172    /**
2173     * CSS2 §10.8 line-box sizing + `vertical-align` placement, in two passes.
2174     *
2175     * Each inline box (and the block's own strut) contributes a content box of
2176     * `ascent + descent` grown by its half-leading `(lineHeight − ascent −
2177     * descent) / 2`. A fragment's offset `o` from the line baseline (positive =
2178     * lower) drives both its placement and its extent (`above = expandedAscent
2179     * − o`, `below = expandedDescent + o`).
2180     *
2181     * Pass A — baseline (with composed `sub`/`super`/`<length>` already in
2182     * `baselineShift`) plus the strut-relative keywords `text-top`
2183     * (content-top → line text-top), `text-bottom` (content-bottom → line
2184     * text-bottom) and `middle` (box centre → baseline − x-height/2). The line
2185     * baseline sits at the greatest `above`; the height runs to the greatest
2186     * `below`. The strut always participates, so an empty line or a line of
2187     * only-smaller text still reserves the block's line height.
2188     *
2189     * Pass B — the line-box-relative keywords `top` (box top → line top, may
2190     * grow the line downward) and `bottom` (box bottom → line bottom), placed
2191     * against the Pass-A extent.
2192     *
2193     * Fragments whose computed offset differs from their stored `baselineShift`
2194     * are reconstructed so the painter (which draws at `line.baseline +
2195     * baselineShift`) places them correctly. A fragment whose `lineHeight` is
2196     * the `-1.0` sentinel falls back to no leading; an explicit `0.0` is
2197     * honoured as full negative leading.
2198     *
2199     * @param list<InlineFragment> $fragments
2200     * @return array{float, float, list<InlineFragment>} [height, baseline, fragments]
2201     */
2202    private function finalizeLine(
2203        array $fragments,
2204        float $strutAscent,
2205        float $strutDescent,
2206        float $strutLineHeight,
2207        float $strutXHeight,
2208    ): array {
2209        $strutLead = ($strutLineHeight - ($strutAscent + $strutDescent)) / 2.0;
2210        // CSS Writing Modes 4 §4.1 — a vertical writing mode's dominant
2211        // baseline is CENTRAL, not alphabetic: every item's extent is
2212        // centred on it rather than split into an ascent above and a
2213        // descent below. Modelling that as a symmetric half-extent makes
2214        // the line's cross size come out as the widest item's extent,
2215        // instead of that item's full extent PLUS the strut's descent.
2216        $vertical = $this->currentIsVertical;
2217        $above = $vertical
2218            ? ($strutAscent + $strutDescent) / 2.0 + $strutLead
2219            : $strutAscent + $strutLead;
2220        $below = $vertical
2221            ? ($strutAscent + $strutDescent) / 2.0 + $strutLead
2222            : $strutDescent + $strutLead;
2223        // Per-fragment offset below the line baseline; null defers a
2224        // top/bottom fragment to pass B.
2225        /** @var array<int, float|null> $offsets */
2226        $offsets = [];
2227        foreach ($fragments as $i => $f) {
2228            [$a, $d, $ea, $ed] = $this->fragmentExtents($f);
2229            if ($f->verticalAlign === 'top' || $f->verticalAlign === 'bottom') {
2230                $offsets[$i] = null;
2231                continue;
2232            }
2233            $o = match ($f->verticalAlign) {
2234                'text-top' => $a - $strutAscent,
2235                'text-bottom' => $strutDescent - $d,
2236                'middle' => ($a - $d) / 2.0 - $strutXHeight / 2.0,
2237                default => $f->baselineShift,
2238            };
2239            $offsets[$i] = $o;
2240            if ($vertical) {
2241                $half = ($ea + $ed) / 2.0;
2242                $above = max($above, $half - $o);
2243                $below = max($below, $half + $o);
2244                continue;
2245            }
2246            $above = max($above, $ea - $o);
2247            $below = max($below, $ed + $o);
2248        }
2249        $baseline = $above;
2250        $height = $above + $below;
2251        // Pass B — `top` / `bottom` align against the LINE BOX rather than the
2252        // baseline, so they can only be placed once the baseline-aligned
2253        // content has sized it. CSS 2.1 §10.8.1: the line box height spans the
2254        // uppermost box top to the lowermost box bottom, and top/bottom-aligned
2255        // boxes are included in that span — a box taller than the line grows
2256        // it. Which EDGE is pinned decides which way it grows: `top` holds the
2257        // box top against the line top so the line grows downward and the
2258        // baseline stays put, while `bottom` holds the box bottom against the
2259        // line bottom so the line grows upward and the baseline travels down
2260        // with it.
2261        //
2262        // Growth therefore has to finish before any offset is assigned;
2263        // computing them in one pass left every `bottom` box pinned to a line
2264        // that had not yet grown, so a 50px inline-block sat in a 19px line
2265        // and consecutive ones overlapped.
2266        $deferred = [];
2267        foreach ($fragments as $i => $f) {
2268            if ($offsets[$i] !== null) {
2269                continue;
2270            }
2271            [, , $ea, $ed] = $this->fragmentExtents($f);
2272            $deferred[$i] = [$f->verticalAlign, $ea, $ed];
2273        }
2274        foreach ($deferred as [$verticalAlign, $ea, $ed]) {
2275            $extent = $ea + $ed;
2276            if ($extent <= $height) {
2277                continue;
2278            }
2279            if ($verticalAlign !== 'top') {
2280                $baseline += $extent - $height;
2281            }
2282            $height = $extent;
2283        }
2284        foreach ($deferred as $i => [$verticalAlign, $ea, $ed]) {
2285            $offsets[$i] = $verticalAlign === 'top'
2286                ? $ea - $baseline
2287                : $height - $ed - $baseline;
2288        }
2289        $out = [];
2290        foreach ($fragments as $i => $f) {
2291            $o = $offsets[$i] ?? $f->baselineShift;
2292            $out[] = abs($o - $f->baselineShift) < 0.0001
2293                ? $f
2294                : $this->withBaselineShift($f, $o);
2295        }
2296        return [$height, $baseline, $out];
2297    }
2298
2299    /**
2300     * Resolve an atomic inline box's used BLOCK-axis extents, with the
2301     * same box-sizing semantics the inline-axis pass applies to its
2302     * widths.
2303     *
2304     * Returns `[contentHeight, outerHeight, padTop, padBottom,
2305     * borderTop, borderBottom]`, where `outerHeight` is the border box
2306     * (padding + border included, margins not).
2307     *
2308     * `$fallbackOuter` is the value an `auto` height with no intrinsic
2309     * source squares to — the box's own outer WIDTH, preserving the
2310     * historical "no height = square box" contract.
2311     *
2312     * @return array{float, float, float, float, float, float}
2313     */
2314    private function resolveAtomicHeights(
2315        AtomicInlineBox $box,
2316        bool $borderBox,
2317        float $fallbackOuter,
2318    ): array {
2319        $heightValue = $box->style->get('height');
2320        // CSS 2.1 §10.5 — a percentage block size resolves only against a
2321        // definite containing-block height; otherwise it stays auto (0
2322        // here) and the box squares to its width. An explicit length / `0`
2323        // is always definite.
2324        $declaredHeight = match (true) {
2325            $heightValue instanceof Length => $heightValue->value,
2326            $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value,
2327            $heightValue instanceof \Phpdftk\Css\Value\Percentage
2328                && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0
2329                => $this->currentCbHeight * ($heightValue->value / 100.0),
2330            default => 0.0,
2331        };
2332        $padTop = self::atomicLength($box->style->get('padding-top'));
2333        $padBottom = self::atomicLength($box->style->get('padding-bottom'));
2334        $borderTop = self::atomicBorderWidth($box->style, 'top');
2335        $borderBottom = self::atomicBorderWidth($box->style, 'bottom');
2336        $inset = $padTop + $padBottom + $borderTop + $borderBottom;
2337        // The pre-layout pass resolved the used insets and the used content
2338        // block size (CSS 2.1 §10.6.3 — `height: auto` on a block container
2339        // is the distance to its last line box's bottom). Prefer both over
2340        // the cascade re-read, which has no way to know how tall the
2341        // contents came out.
2342        if ($box->laidOutContentHeight !== null) {
2343            $padTop = $box->geometry->paddingTop;
2344            $padBottom = $box->geometry->paddingBottom;
2345            $borderTop = $box->geometry->borderTop;
2346            $borderBottom = $box->geometry->borderBottom;
2347            $inset = $padTop + $padBottom + $borderTop + $borderBottom;
2348            $content = $box->laidOutContentHeight;
2349
2350            return [$content, $content + $inset, $padTop, $padBottom, $borderTop, $borderBottom];
2351        }
2352        if ($declaredHeight > 0.0) {
2353            if ($borderBox) {
2354                return [
2355                    max(0.0, $declaredHeight - $inset),
2356                    $declaredHeight,
2357                    $padTop,
2358                    $padBottom,
2359                    $borderTop,
2360                    $borderBottom,
2361                ];
2362            }
2363
2364            return [
2365                $declaredHeight,
2366                $declaredHeight + $inset,
2367                $padTop,
2368                $padBottom,
2369                $borderTop,
2370                $borderBottom,
2371            ];
2372        }
2373
2374        return [
2375            max(0.0, $fallbackOuter - $inset),
2376            $fallbackOuter,
2377            $padTop,
2378            $padBottom,
2379            $borderTop,
2380            $borderBottom,
2381        ];
2382    }
2383
2384    /**
2385     * Re-commit each inline atomic / replaced fragment's block-axis position
2386     * against the FINALIZED line baseline. The atomic side-channel in the
2387     * token loop seeds `geometry->y` from the raw shaping-font ascent, but the
2388     * true baseline is only known once {@see finalizeLine} has sized the line
2389     * — a tall atomic grows the line and moves the baseline downward. CSS 2.1
2390     * §10.8.1: a replaced / inline-block box aligns its bottom margin edge with
2391     * the line baseline, as shifted by `vertical-align` (folded into the
2392     * fragment's resolved `baselineShift` by finalizeLine). This overwrites the
2393     * seed with the correct value; nothing reads the seed in between. Pure-text
2394     * lines carry no atomic fragments, so this is a no-op for them.
2395     *
2396     * @param list<InlineFragment> $fragments
2397     */
2398    private function commitAtomicFragmentY(Box $parent, float $lineTop, float $lineBaseline, array $fragments): void
2399    {
2400        foreach ($fragments as $f) {
2401            $atomic = $f->atomicBox;
2402            if ($atomic === null) {
2403                continue;
2404            }
2405            $g = $atomic->geometry;
2406            $baselineY = $parent->geometry->y + $lineTop + $lineBaseline + $f->baselineShift;
2407            // §10.8.1 — an `inline-block` with in-flow line boxes puts its
2408            // OWN last baseline on the line's baseline, so the content-box
2409            // top sits exactly that offset above it.
2410            if ($atomic->laidOutBaseline !== null) {
2411                $g->y = $baselineY - $atomic->laidOutBaseline;
2412                continue;
2413            }
2414            // Border-box height from the committed geometry (content +
2415            // padding + border); the margin box adds the vertical margins.
2416            $outerHeight = $g->borderTop + $g->paddingTop + $g->height + $g->paddingBottom + $g->borderBottom;
2417            // Margin-box bottom on the baseline (+ vertical-align shift); step
2418            // up past bottom margin and the border box, then back down into the
2419            // content box's top-left — mirrors the seed formula in the token
2420            // loop but with the finalized baseline instead of the font ascent.
2421            $g->y = $baselineY
2422                - $g->marginBottom - $outerHeight + $g->borderTop + $g->paddingTop;
2423        }
2424    }
2425
2426    /**
2427     * Re-commit each inline atomic / replaced fragment's inline-axis position
2428     * after `text-align` (and justify) have shifted the line's fragments. The
2429     * token loop seeded the atomic box's `geometry->x` from the pre-alignment
2430     * cursor, so a centred / right-aligned / justified line left the box
2431     * behind at the line's start while its text shifted. This mirrors the
2432     * seed formula in the token loop (content-box left = margin-box start +
2433     * left margin + border + padding) but with the fragment's now-shifted
2434     * `x`. Pure-text lines carry no atomic fragments, so this is a no-op.
2435     *
2436     * @param list<InlineFragment> $fragments
2437     */
2438    private function commitAtomicFragmentX(Box $parent, array $fragments): void
2439    {
2440        foreach ($fragments as $f) {
2441            $atomic = $f->atomicBox;
2442            if ($atomic === null) {
2443                continue;
2444            }
2445            $g = $atomic->geometry;
2446            $g->x = $parent->geometry->x + $f->x + $g->marginLeft + $g->borderLeft + $g->paddingLeft;
2447        }
2448    }
2449
2450    /**
2451     * CSS 2.1 §10.8.1 — the distance from an atomic inline box's MARGIN-box
2452     * top edge down to the baseline it aligns on the line with.
2453     *
2454     * `BlockLayout` records the offset from the content-box top while laying
2455     * the box's own formatting context out; this re-bases it onto the margin
2456     * box, which is the extent the line box actually reserves. `null` when
2457     * the box takes the §10.8.1 fallback (no in-flow line boxes, or
2458     * `overflow` other than `visible`) and so aligns its bottom margin edge.
2459     */
2460    private static function atomicBaselineFromMarginTop(AtomicInlineBox $box): ?float
2461    {
2462        if ($box->laidOutBaseline === null) {
2463            return null;
2464        }
2465        $g = $box->geometry;
2466
2467        return $g->marginTop + $g->borderTop + $g->paddingTop + $box->laidOutBaseline;
2468    }
2469
2470    /**
2471     * A fragment's ascent, descent, and their half-leading-expanded forms:
2472     * `[ascent, descent, expandedAscent, expandedDescent]`.
2473     *
2474     * @return array{float, float, float, float}
2475     */
2476    private function fragmentExtents(InlineFragment $f): array
2477    {
2478        // CSS 2.1 §10.8 — an inline atomic / replaced box contributes its
2479        // *margin-box height* to the line box, not the surrounding font's
2480        // ascent/descent (its glyph run is empty). The box's baseline is its
2481        // bottom margin edge (§10.8.1), so the whole margin box sits above the
2482        // baseline: ascent = margin-box height, descent = 0. No half-leading —
2483        // replaced boxes don't carry line-height leading. The geometry was
2484        // already resolved by the atomic side-channel in the token loop before
2485        // this line was finalized.
2486        if ($f->atomicBox !== null) {
2487            $g = $f->atomicBox->geometry;
2488            if ($this->currentIsVertical) {
2489                // CSS Writing Modes 4 §7.1 — the box keeps its physical
2490                // `width`/`height`, so in a vertical writing mode the extent
2491                // it contributes to the line's CROSS size is its margin-box
2492                // WIDTH (its height is the inline advance instead). The
2493                // §10.8.1 baseline split doesn't apply across the cross axis
2494                // here, so the whole margin box counts as ascent.
2495                $crossBox = $g->marginLeft + $g->borderLeft + $g->paddingLeft + $g->width
2496                    + $g->paddingRight + $g->borderRight + $g->marginRight;
2497
2498                return [$crossBox, 0.0, $crossBox, 0.0];
2499            }
2500            $marginBox = $g->marginTop + $g->borderTop + $g->paddingTop + $g->height
2501                + $g->paddingBottom + $g->borderBottom + $g->marginBottom;
2502            // §10.8.1 again: an `inline-block` WITH in-flow line boxes
2503            // aligns its own last baseline with the line's, so only the
2504            // part of its margin box above that baseline counts as
2505            // ascent — the rest hangs below like a descender.
2506            $ascent = self::atomicBaselineFromMarginTop($f->atomicBox);
2507            if ($ascent !== null) {
2508                $descent = max(0.0, $marginBox - $ascent);
2509
2510                return [$ascent, $descent, $ascent, $descent];
2511            }
2512
2513            return [$marginBox, 0.0, $marginBox, 0.0];
2514        }
2515        $a = $this->fragmentAscent($f);
2516        $d = $this->fragmentDescent($f);
2517        $lh = $f->lineHeight >= 0.0 ? $f->lineHeight : ($a + $d);
2518        $lead = ($lh - ($a + $d)) / 2.0;
2519        return [$a, $d, $a + $lead, $d + $lead];
2520    }
2521
2522    /**
2523     * Clone a fragment with a new `baselineShift` (its offset below the line
2524     * baseline). Used by {@see finalizeLine} to write back the resolved
2525     * `vertical-align` offset while preserving every other field.
2526     */
2527    private function withBaselineShift(InlineFragment $f, float $shift): InlineFragment
2528    {
2529        return new InlineFragment(
2530            $f->x,
2531            $f->width,
2532            $f->shapedRun,
2533            $shift,
2534            $f->href,
2535            $f->isBold,
2536            $f->isItalic,
2537            $f->decorationLines,
2538            $f->textColor,
2539            $f->backgroundColor,
2540            $f->linkTitle,
2541            $f->decorationColor,
2542            $f->isWhitespace,
2543            $f->lineHeight,
2544            $f->verticalAlign,
2545            $f->blockOffset,
2546            $f->atomicBox,
2547            $f->bgExtendAbove,
2548            $f->bgExtendBelow,
2549        );
2550    }
2551
2552    /**
2553     * The box's cascaded `font-size` in user-space units, or null when the
2554     * cascade didn't produce a `Length` (the cascade should always produce
2555     * one after `resolveLengths`; null is just a safety fallback).
2556     */
2557    private function boxFontSize(Box $box): ?float
2558    {
2559        $value = $box->style->get('font-size');
2560        return $value instanceof Length ? $value->value : null;
2561    }
2562
2563    /**
2564     * Resolve the cascaded `font-weight` to a numeric value in the CSS
2565     * Fonts 4 1–1000 range. Keywords map per spec: `normal` → 400,
2566     * `bold` / `bolder` → 700, `lighter` → 100.
2567     */
2568    private function resolveWeight(Box $box): int
2569    {
2570        $value = $box->style->get('font-weight');
2571        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2572            return match (strtolower($value->name)) {
2573                'bold', 'bolder' => 700,
2574                'lighter' => 100,
2575                default => 400,
2576            };
2577        }
2578        if ($value instanceof \Phpdftk\Css\Value\Integer
2579            || $value instanceof \Phpdftk\Css\Value\Number
2580        ) {
2581            return max(1, min(1000, (int) $value->value));
2582        }
2583        return 400;
2584    }
2585
2586    /**
2587     * Resolve the cascaded `font-style` to a lower-case keyword in the
2588     * `normal` | `italic` | `oblique` set. Unrecognised values fall back
2589     * to `normal`.
2590     */
2591    private function resolveStyle(Box $box): string
2592    {
2593        $value = $box->style->get('font-style');
2594        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2595            $lc = strtolower($value->name);
2596            if (in_array($lc, ['italic', 'oblique'], true)) {
2597                return $lc;
2598            }
2599        }
2600        return 'normal';
2601    }
2602
2603    /**
2604     * Resolve the cascaded `font-variant-*` family + `font-feature-
2605     * settings` into the OpenType feature-tag list the shaper
2606     * consumes. Implements the mappings in CSS Fonts 4 §6 from
2607     * each high-level value keyword to the underlying OpenType
2608     * GSUB / GPOS feature tags.
2609     *
2610     * Tags from font-variant-* are emitted as bare tag strings
2611     * (= "enable"); font-feature-settings entries with a non-1
2612     * integer are emitted as `tag=N` so the shaper can encode
2613     * the variant index. The default `kern liga` baseline is
2614     * always present.
2615     *
2616     * @return list<string>
2617     */
2618    private function resolveOpenTypeFeatures(Box $box): array
2619    {
2620        $tags = ['kern', 'liga'];
2621        $add = function (string $tag) use (&$tags): void {
2622            if (!in_array($tag, $tags, true)) {
2623                $tags[] = $tag;
2624            }
2625        };
2626        $disable = function (string $tag) use (&$tags): void {
2627            $tags = array_values(array_filter($tags, fn(string $t) => $t !== $tag));
2628            $tags[] = $tag . '=0';
2629        };
2630        $variantMap = [
2631            'font-variant-caps' => [
2632                'small-caps' => ['smcp'],
2633                'all-small-caps' => ['smcp', 'c2sc'],
2634                'petite-caps' => ['pcap'],
2635                'all-petite-caps' => ['pcap', 'c2pc'],
2636                'unicase' => ['unic'],
2637                'titling-caps' => ['titl'],
2638            ],
2639            'font-variant-numeric' => [
2640                'lining-nums' => ['lnum'],
2641                'oldstyle-nums' => ['onum'],
2642                'proportional-nums' => ['pnum'],
2643                'tabular-nums' => ['tnum'],
2644                'diagonal-fractions' => ['frac'],
2645                'stacked-fractions' => ['afrc'],
2646                'ordinal' => ['ordn'],
2647                'slashed-zero' => ['zero'],
2648            ],
2649            'font-variant-position' => [
2650                'sub' => ['subs'],
2651                'super' => ['sups'],
2652            ],
2653            'font-variant-east-asian' => [
2654                'jis78' => ['jp78'],
2655                'jis83' => ['jp83'],
2656                'jis90' => ['jp90'],
2657                'jis04' => ['jp04'],
2658                'simplified' => ['smpl'],
2659                'traditional' => ['trad'],
2660                'full-width' => ['fwid'],
2661                'proportional-width' => ['pwid'],
2662                'ruby' => ['ruby'],
2663            ],
2664        ];
2665        foreach ($variantMap as $prop => $kwMap) {
2666            $value = $box->style->get($prop);
2667            foreach ($this->iterateKeywords($value) as $kw) {
2668                foreach ($kwMap[$kw] ?? [] as $tag) {
2669                    $add($tag);
2670                }
2671            }
2672        }
2673        // font-variant-ligatures has both enable / disable forms.
2674        $ligValue = $box->style->get('font-variant-ligatures');
2675        foreach ($this->iterateKeywords($ligValue) as $kw) {
2676            match ($kw) {
2677                'common-ligatures' => $add('liga'),
2678                'no-common-ligatures' => $disable('liga'),
2679                'discretionary-ligatures' => $add('dlig'),
2680                'no-discretionary-ligatures' => $disable('dlig'),
2681                'historical-ligatures' => $add('hlig'),
2682                'no-historical-ligatures' => $disable('hlig'),
2683                'contextual' => $add('calt'),
2684                'no-contextual' => $disable('calt'),
2685                default => null,
2686            };
2687        }
2688        // font-feature-settings — typed values land as
2689        // FontFeatureSettings; pass each entry through.
2690        $fss = $box->style->get('font-feature-settings');
2691        if ($fss instanceof \Phpdftk\Css\Value\FontFeatureSettings) {
2692            foreach ($fss->features as $entry) {
2693                if ($entry->value === 1) {
2694                    $add($entry->tag);
2695                } elseif ($entry->value === 0) {
2696                    $disable($entry->tag);
2697                } else {
2698                    // Variant index — encode as tag=N.
2699                    $add($entry->tag . '=' . $entry->value);
2700                }
2701            }
2702        }
2703        return $tags;
2704    }
2705
2706    /**
2707     * Walk a cascaded value yielding each keyword name it
2708     * carries. Handles bare Keyword and Space-separated ValueList
2709     * forms (the two shapes font-variant-* values arrive in).
2710     *
2711     * @return iterable<string>
2712     */
2713    private function iterateKeywords(?\Phpdftk\Css\Value\Value $value): iterable
2714    {
2715        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2716            yield strtolower($value->name);
2717            return;
2718        }
2719        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
2720            foreach ($value->values as $v) {
2721                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
2722                    yield strtolower($v->name);
2723                }
2724            }
2725        }
2726    }
2727
2728    /**
2729     * Resolve the cascaded `font-stretch` to its percentage value on
2730     * the CSS Fonts 4 §3.4 axis (50..200). Accepts the named keyword
2731     * forms (`condensed`, `expanded`, ...) and bare percentages.
2732     */
2733    private function resolveStretch(Box $box): float
2734    {
2735        $value = $box->style->get('font-stretch');
2736        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2737            return match (strtolower($value->name)) {
2738                'ultra-condensed' => 50.0,
2739                'extra-condensed' => 62.5,
2740                'condensed' => 75.0,
2741                'semi-condensed' => 87.5,
2742                'semi-expanded' => 112.5,
2743                'expanded' => 125.0,
2744                'extra-expanded' => 150.0,
2745                'ultra-expanded' => 200.0,
2746                default => 100.0,
2747            };
2748        }
2749        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
2750            return max(50.0, min(200.0, (float) $value->value));
2751        }
2752        return 100.0;
2753    }
2754
2755    /**
2756     * Combined font/weight/style resolution for the box. Picks the
2757     * concrete `OpenTypeData` via {@see FontResolver::resolveMatch()} and
2758     * derives the post-match "still needs synthetic effect" flags by
2759     * comparing the matched face's axes against the requested cascade.
2760     *
2761     * @return array{font: ?\Phpdftk\FontParser\FontFaceData, isBold: bool, isItalic: bool}
2762     */
2763    private function resolveBoxFont(Box $box, ?\Phpdftk\FontParser\FontFaceData $fallback): array
2764    {
2765        $weight = $this->resolveWeight($box);
2766        $style = $this->resolveStyle($box);
2767        $stretch = $this->resolveStretch($box);
2768        $requestBold = $weight >= 600;
2769        $requestItalic = $style !== 'normal';
2770        $resolver = $this->currentFontResolver;
2771        $match = $resolver?->resolveMatch(
2772            $box->style->get('font-family'),
2773            $weight,
2774            $style,
2775            $stretch,
2776        );
2777        $font = $match?->face->data ?? $fallback;
2778        // CSS Fonts 4 §6.7 — `font-synthesis-weight` / `font-synthesis-style`
2779        // veto the fake-bold stroke / fake-italic skew the painter would
2780        // otherwise apply when the matched face has no real bold or italic.
2781        $isBold = $requestBold
2782            && ($match === null || !$match->matchesWeight)
2783            && self::allowsSynthesis($box, 'font-synthesis-weight');
2784        $isItalic = $requestItalic
2785            && ($match === null || !$match->matchesStyle)
2786            && self::allowsSynthesis($box, 'font-synthesis-style');
2787        return ['font' => $font, 'isBold' => $isBold, 'isItalic' => $isItalic];
2788    }
2789
2790    /**
2791     * Is the named `font-synthesis-*` longhand anything other than `none`?
2792     *
2793     * Both longhands are `auto | none` with an `auto` initial, so only an
2794     * explicit `none` suppresses the synthetic face.
2795     */
2796    private static function allowsSynthesis(Box $box, string $property): bool
2797    {
2798        $value = $box->style->get($property);
2799        return !($value instanceof \Phpdftk\Css\Value\Keyword)
2800            || strtolower($value->name) !== 'none';
2801    }
2802
2803    /**
2804     * Read the box's cascaded `text-decoration-line` and return the set of
2805     * line keywords it carries — empty list for `none` / unset.
2806     *
2807     * @return list<string>
2808     */
2809    private function decorationLines(Box $box): array
2810    {
2811        $value = $box->style->get('text-decoration-line');
2812        if ($value === null
2813            || ($value instanceof \Phpdftk\Css\Value\Keyword
2814                && strtolower($value->name) === 'none')
2815        ) {
2816            return [];
2817        }
2818        $names = [];
2819        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2820            $names[] = strtolower($value->name);
2821        } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) {
2822            foreach ($value->values as $v) {
2823                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
2824                    $kw = strtolower($v->name);
2825                    if ($kw !== 'none') {
2826                        $names[] = $kw;
2827                    }
2828                }
2829            }
2830        }
2831        return array_values(array_unique(array_filter(
2832            $names,
2833            static fn(string $n): bool => in_array($n, ['underline', 'overline', 'line-through'], true),
2834        )));
2835    }
2836
2837    /**
2838     * Combine outer + inner text-decoration line lists into a deduped list.
2839     *
2840     * @param list<string> $outer
2841     * @param list<string> $inner
2842     * @return list<string>
2843     */
2844    private function mergeDecorationLines(array $outer, array $inner): array
2845    {
2846        return array_values(array_unique(array_merge($outer, $inner)));
2847    }
2848
2849    /**
2850     * Read the box's cascaded `color`, or null when the cascade didn't
2851     * produce a `Color` value (e.g. unresolved keyword fallback).
2852     */
2853    private function resolveColor(Box $box): ?\Phpdftk\Css\Value\Color
2854    {
2855        $value = $box->style->get('color');
2856        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
2857    }
2858
2859    private function resolveBackground(Box $box): ?\Phpdftk\Css\Value\Color
2860    {
2861        $value = $box->style->get('background-color');
2862        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
2863    }
2864
2865    /**
2866     * Read the box's cascaded `text-decoration-color`, or null when the
2867     * property is unset / inherits to the default keyword. CSS Text
2868     * Decoration 4 §3: the property does *not* inherit through inlines,
2869     * so callers explicitly pick whichever closer ancestor set it.
2870     */
2871    private function resolveDecorationColor(Box $box): ?\Phpdftk\Css\Value\Color
2872    {
2873        $value = $box->style->get('text-decoration-color');
2874        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
2875    }
2876
2877    /**
2878     * CSS Text 3 §5 / §6 — soft-wrap opportunities exist between
2879     * every two codepoints under: `word-break: break-all`,
2880     * `overflow-wrap: anywhere`, and `line-break: anywhere`. All
2881     * three are checked here so the line-fitter splits at any
2882     * character regardless of which property the author used.
2883     */
2884    private function isBreakAll(Box $box): bool
2885    {
2886        $wb = $box->style->get('word-break');
2887        if ($wb instanceof \Phpdftk\Css\Value\Keyword && strtolower($wb->name) === 'break-all') {
2888            return true;
2889        }
2890        $ow = $box->style->get('overflow-wrap');
2891        if ($ow instanceof \Phpdftk\Css\Value\Keyword) {
2892            $name = strtolower($ow->name);
2893            // `anywhere` adds break opportunities AND contributes to
2894            // min-content intrinsic sizing. `break-word` (and its
2895            // legacy `word-wrap: break-word` alias, which the cascade
2896            // normalises into `overflow-wrap`) adds the same
2897            // codepoint-level opportunities for line-fitting but does
2898            // NOT change intrinsic sizing — see CSS Text 3 §5.5. For
2899            // the line-fitter both fall into the same "every code-
2900            // point is a break opportunity" path; the min/max-content
2901            // measurer gates on `anywhere` only via
2902            // `intrinsicBreaksAnywhere`.
2903            if ($name === 'anywhere' || $name === 'break-word') {
2904                return true;
2905            }
2906        }
2907        // Legacy `word-wrap: break-word` is the same property under a
2908        // different name. The shorthand expander normalises it into
2909        // `overflow-wrap`, but author CSS that sets `word-wrap`
2910        // directly still lands here — read both.
2911        $ww = $box->style->get('word-wrap');
2912        if ($ww instanceof \Phpdftk\Css\Value\Keyword
2913            && strtolower($ww->name) === 'break-word'
2914        ) {
2915            return true;
2916        }
2917        $lb = $box->style->get('line-break');
2918        if ($lb instanceof \Phpdftk\Css\Value\Keyword && strtolower($lb->name) === 'anywhere') {
2919            return true;
2920        }
2921        return false;
2922    }
2923
2924    /**
2925     * CSS Inline 3 §4.5 `vertical-align`: the `sub` and `super` keywords lift
2926     * / lower the fragment's baseline by a font-size-relative amount and
2927     * COMPOSE through nesting, so they're folded into the running
2928     * `baselineShift` during the tree walk. Browser defaults: `super` ≈
2929     * +0.5em lift, `sub` ≈ +0.2em drop. Returns the offset in layout-Y space
2930     * (negative lifts, positive drops). The line-relative keywords (top /
2931     * bottom / middle / text-top / text-bottom) do NOT compose and are
2932     * resolved later against the line box in `finalizeLine`, so they return 0
2933     * here — see {@see resolveVerticalAlignKeyword}.
2934     */
2935    private function resolveVerticalAlign(Box $box, float $fontSize): float
2936    {
2937        $value = $box->style->get('vertical-align');
2938        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
2939            return 0.0;
2940        }
2941        return match (strtolower($value->name)) {
2942            'super' => -$fontSize * 0.5,
2943            'sub' => $fontSize * 0.2,
2944            default => 0.0,
2945        };
2946    }
2947
2948    /**
2949     * The line-relative `vertical-align` keyword for a box — one of `top`,
2950     * `bottom`, `middle`, `text-top`, `text-bottom` — or `baseline` for
2951     * everything else (including `sub`/`super`/`<length>`, which compose via
2952     * {@see resolveVerticalAlign} into `baselineShift`). These keywords are
2953     * resolved against the line box / strut in `finalizeLine`.
2954     */
2955    private function resolveVerticalAlignKeyword(Box $box): string
2956    {
2957        $value = $box->style->get('vertical-align');
2958        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
2959            return 'baseline';
2960        }
2961        $name = strtolower($value->name);
2962        return match ($name) {
2963            'top', 'bottom', 'middle', 'text-top', 'text-bottom' => $name,
2964            default => 'baseline',
2965        };
2966    }
2967
2968    /**
2969     * Tokenise plain text at UAX #14 break opportunities, shaping each
2970     * resulting segment. Each segment is one token. Whitespace segments
2971     * are tagged so the line-fitter can collapse them at line edges. When
2972     * `$letterSpacing` is non-zero, every shaped glyph's advance is bumped
2973     * by that amount per CSS Text 3 §10 — the painter picks the difference
2974     * up automatically via its TJ-kerning path.
2975     *
2976     * @return list<array{
2977     *     shapedRun: ShapedRun,
2978     *     isWhitespace: bool,
2979     *     kind: LineBreakKind,
2980     *     trailingSpace: float,
2981     *     noWrapBefore?: bool,
2982     *     baselineShift?: float,
2983     *     lineHeight?: float,
2984     *     verticalAlign?: string,
2985     *     href?: string|null,
2986     *     isBold?: bool,
2987     *     isItalic?: bool,
2988     *     decorationLines?: list<string>,
2989     *     textColor?: \Phpdftk\Css\Value\Color|null,
2990     *     backgroundColor?: \Phpdftk\Css\Value\Color|null,
2991     *     linkTitle?: string|null,
2992     *     decorationColor?: \Phpdftk\Css\Value\Color|null,
2993     *     bgExtendAbove?: float,
2994     *     bgExtendBelow?: float,
2995     *     atomicBox?: AtomicInlineBox,
2996     *     atomicContentWidth?: float,
2997     *     atomicOuterWidth?: float,
2998     *     atomicPadLeft?: float,
2999     *     atomicPadRight?: float,
3000     *     atomicBorderLeft?: float,
3001     *     atomicBorderRight?: float,
3002     *     atomicMarginLeft?: float,
3003     *     atomicMarginRight?: float,
3004     *     atomicMarginTop?: float,
3005     *     atomicMarginBottom?: float,
3006     *     atomicBorderBox?: bool,
3007     * }>
3008     */
3009    private function tokeniseText(
3010        string $text,
3011        ShapingContext $shapingCtx,
3012        float $letterSpacing,
3013        float $wordSpacing,
3014        bool $breakAll = false,
3015        bool $splitWsBoundaries = false,
3016    ): array {
3017        if ($text === '') {
3018            return [];
3019        }
3020        if ($breakAll) {
3021            // CSS Text 3 §5 `word-break: break-all` — every codepoint is a
3022            // valid break point. Walk UTF-8 codepoints and emit one
3023            // segment per character. Whitespace runs still collapse into
3024            // their own segment so word/letter-spacing logic stays sane.
3025            $segments = [];
3026            $bytes = strlen($text);
3027            $i = 0;
3028            while ($i < $bytes) {
3029                $b = ord($text[$i]);
3030                $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4));
3031                $segments[] = [
3032                    'text' => substr($text, $i, $cpLen),
3033                    'kind' => LineBreakKind::Allowed,
3034                ];
3035                $i += $cpLen;
3036            }
3037        } else {
3038            $breaks = iterator_to_array($this->lineBreaker->breakOpportunities($text), false);
3039            $segments = [];
3040            $start = 0;
3041            foreach ($breaks as $opp) {
3042                if ($opp->offset > $start) {
3043                    $segments[] = ['text' => substr($text, $start, $opp->offset - $start), 'kind' => $opp->kind];
3044                    $start = $opp->offset;
3045                }
3046            }
3047            if ($start < strlen($text)) {
3048                $segments[] = ['text' => substr($text, $start), 'kind' => LineBreakKind::Allowed];
3049            }
3050            // CSS Text 3 §5.5 — for the hanging-trailing-whitespace
3051            // behaviour to take effect under `pre-wrap` / `break-spaces`,
3052            // each contiguous whitespace run must be its own token. UAX-14
3053            // bundles `XX<ws>` into one segment (the break opportunity
3054            // sits at the end of the whitespace), which prevents the
3055            // line-fitter from telling the trailing ws apart from the
3056            // leading word at wrap time. Refine bundled segments here:
3057            // split any segment that mixes ws and non-ws into alternating
3058            // runs. Only applied when the caller opts in — under `normal`
3059            // / `nowrap` the bundled segments are correct (whitespace
3060            // collapses to single spaces that contribute to line width).
3061            if ($splitWsBoundaries) {
3062                $refined = [];
3063                foreach ($segments as $seg) {
3064                    if (preg_match('/[ \t\n\r\f]/', $seg['text']) !== 1
3065                        || preg_match('/[^ \t\n\r\f]/', $seg['text']) !== 1
3066                    ) {
3067                        $refined[] = $seg;
3068                        continue;
3069                    }
3070                    if (preg_match_all('/[ \t\n\r\f]+|[^ \t\n\r\f]+/u', $seg['text'], $m) === false) {
3071                        $refined[] = $seg;
3072                        continue;
3073                    }
3074                    $lastIdx = count($m[0]) - 1;
3075                    foreach ($m[0] as $i => $part) {
3076                        $refined[] = [
3077                            'text' => $part,
3078                            // The original break opportunity sits at
3079                            // the end of the bundled segment — keep
3080                            // that on the last sub-segment; intermediate
3081                            // boundaries get a plain `Allowed`
3082                            // opportunity so the breaker can wrap there
3083                            // too.
3084                            'kind' => $i === $lastIdx ? $seg['kind'] : LineBreakKind::Allowed,
3085                        ];
3086                    }
3087                }
3088                $segments = $refined;
3089            }
3090        }
3091
3092        // CSS Writing Modes 3 §2 / Unicode UAX #9 — split each segment
3093        // at bidi-direction boundaries so a single text node like
3094        // "First שלום world" produces three separate shaping runs
3095        // (LTR-RTL-LTR) the layout can place individually. Without
3096        // this split, the run containing both LTR and RTL characters
3097        // shapes as a single block at one bidi level, miscoordinating
3098        // with browser-emitted swatch reference geometries.
3099        $segments = $this->splitBidiRuns($segments);
3100
3101        $out = [];
3102        foreach ($segments as $seg) {
3103            $isWs = preg_match('/^[ \t\n\r\f]+$/', $seg['text']) === 1;
3104            $runDirection = $seg['direction'] ?? null;
3105            $runCtx = $shapingCtx;
3106            if ($runDirection === 'rtl') {
3107                $runCtx = new ShapingContext(
3108                    $shapingCtx->font,
3109                    $shapingCtx->fontSizePt,
3110                    $shapingCtx->script,
3111                    $shapingCtx->language,
3112                    \Phpdftk\Text\ShapingDirection::Rtl,
3113                    $shapingCtx->features,
3114                );
3115            }
3116            // CSS Text 3 §4.1.1 — a *segment break* (U+000A, and the
3117            // U+000D that may precede it) is a control character: in the
3118            // preserving white-space modes it forces a line break, and it
3119            // is NEVER rendered. UAX-14 already handed us the `Mandatory`
3120            // break opportunity, so all that is left is to keep the
3121            // codepoint away from the shaper — otherwise the font has no
3122            // glyph for it and every preserved line ended in a `.notdef`
3123            // box. The break kind and the whitespace flag still come from
3124            // the original segment text.
3125            $shapeText = strtr($seg['text'], ["\r\n" => '', "\n" => '', "\r" => '']);
3126            $shaped = $this->shaper->shapeRun($shapeText, $runCtx);
3127            if ($letterSpacing !== 0.0 && $shaped->glyphs !== []) {
3128                $shaped = $this->applyLetterSpacing($shaped, $letterSpacing);
3129            }
3130            if ($wordSpacing !== 0.0 && $shaped->glyphs !== []) {
3131                // CSS Text 3 §9: `word-spacing` adds advance only at word-
3132                // separator glyphs (U+0020 / U+00A0 at MVP).
3133                $shaped = $this->applyWordSpacing($shaped, $shapeText, $wordSpacing);
3134            }
3135            $out[] = [
3136                'shapedRun' => $shaped,
3137                'isWhitespace' => $isWs,
3138                'kind' => $seg['kind'],
3139                'trailingSpace' => $this->trailingCollapsibleAdvance($shaped, $shapeText, $isWs),
3140            ];
3141        }
3142        return $out;
3143    }
3144
3145    /**
3146     * Shrink the last fragment by the collapsible whitespace it ends with,
3147     * so a line's reported width is its width AFTER CSS Text 3 §4.1.3 has
3148     * removed that space. The glyph stays in the run — a space paints
3149     * nothing — but it stops counting toward alignment and decoration.
3150     *
3151     * @param list<InlineFragment> $fragments
3152     * @return list<InlineFragment>
3153     */
3154    private function trimTrailingSpace(array $fragments, float $trailing): array
3155    {
3156        if ($trailing <= 0.0 || $fragments === []) {
3157            return $fragments;
3158        }
3159        $last = $fragments[count($fragments) - 1];
3160        $width = max(0.0, $last->width - $trailing);
3161        if ($width === $last->width) {
3162            return $fragments;
3163        }
3164        $fragments[count($fragments) - 1] = new InlineFragment(
3165            $last->x,
3166            $width,
3167            $last->shapedRun,
3168            $last->baselineShift,
3169            $last->href,
3170            $last->isBold,
3171            $last->isItalic,
3172            $last->decorationLines,
3173            $last->textColor,
3174            $last->backgroundColor,
3175            $last->linkTitle,
3176            $last->decorationColor,
3177            $last->isWhitespace,
3178            $last->lineHeight,
3179            $last->verticalAlign,
3180            blockOffset: $last->blockOffset,
3181            atomicBox: $last->atomicBox,
3182            bgExtendAbove: $last->bgExtendAbove,
3183            bgExtendBelow: $last->bgExtendBelow,
3184        );
3185        return $fragments;
3186    }
3187
3188    /**
3189     * The advance of the COLLAPSIBLE whitespace a token ends with — the run
3190     * that CSS Text 3 §4.1.3 removes when the token lands at a line end.
3191     *
3192     * U+00A0 and friends are deliberately excluded: a no-break space is not
3193     * collapsible, so it keeps its width and its power to push a line over.
3194     */
3195    private function trailingCollapsibleAdvance(ShapedRun $shaped, string $text, bool $isWhitespace): float
3196    {
3197        if ($shaped->glyphs === []) {
3198            return 0.0;
3199        }
3200        if ($isWhitespace) {
3201            return $shaped->totalAdvance;
3202        }
3203        if (preg_match('/[ \t\n\r\f]+$/', $text, $matches, PREG_OFFSET_CAPTURE) !== 1) {
3204            return 0.0;
3205        }
3206        $start = $matches[0][1];
3207        $advance = 0.0;
3208        foreach ($shaped->glyphs as $glyph) {
3209            if ($glyph->sourceOffset >= $start) {
3210                $advance += $glyph->advanceX;
3211            }
3212        }
3213        return $advance;
3214    }
3215
3216    /**
3217     * Unicode UAX #9 (Bidi) — minimal per-codepoint split. Walks each
3218     * segment, classifies codepoints into LTR / RTL / neutral via
3219     * `IntlChar::charDirection`, groups consecutive same-direction
3220     * characters into runs, and emits one segment per run. Neutrals
3221     * adopt the direction of their surrounding run (left-context
3222     * fallback). When `intl` is unavailable or the segment is pure
3223     * one-direction, segments pass through untouched.
3224     *
3225     * Each returned segment carries a `direction` field (`'ltr'` /
3226     * `'rtl'`) so the shaping pass can switch ShapingDirection per
3227     * run without re-classifying codepoints.
3228     *
3229     * @param list<array{text: string, kind: LineBreakKind}> $segments
3230     * @return list<array{text: string, kind: LineBreakKind, direction?: string}>
3231     */
3232    private function splitBidiRuns(array $segments): array
3233    {
3234        if (!class_exists(\IntlChar::class)) {
3235            return $segments;
3236        }
3237        $out = [];
3238        foreach ($segments as $seg) {
3239            $text = $seg['text'];
3240            // Quick exit: if no codepoint has explicit RTL direction,
3241            // the whole segment is LTR. Avoids the per-codepoint walk
3242            // for the common Latin case.
3243            if (preg_match('/[\x{0590}-\x{08FF}\x{FB1D}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text) !== 1) {
3244                $seg['direction'] = 'ltr';
3245                $out[] = $seg;
3246                continue;
3247            }
3248            $bytes = strlen($text);
3249            $i = 0;
3250            $currentRun = '';
3251            $currentDir = null;
3252            while ($i < $bytes) {
3253                $b = ord($text[$i]);
3254                $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4));
3255                $chunk = substr($text, $i, $cpLen);
3256                $cp = mb_ord($chunk, 'UTF-8');
3257                $i += $cpLen;
3258                if ($cp === false) {
3259                    $currentRun .= $chunk;
3260                    continue;
3261                }
3262                $bidiClass = \IntlChar::charDirection($cp);
3263                // L (LEFT_TO_RIGHT) → LTR; R / AL (RIGHT_TO_LEFT,
3264                // RIGHT_TO_LEFT_ARABIC) → RTL; everything else is a
3265                // neutral and inherits the surrounding run.
3266                $dirHere = match ($bidiClass) {
3267                    \IntlChar::CHAR_DIRECTION_LEFT_TO_RIGHT => 'ltr',
3268                    \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT,
3269                    \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT_ARABIC => 'rtl',
3270                    default => null,
3271                };
3272                if ($dirHere === null) {
3273                    $currentRun .= $chunk;
3274                    continue;
3275                }
3276                if ($currentDir === null) {
3277                    $currentDir = $dirHere;
3278                }
3279                if ($dirHere !== $currentDir) {
3280                    // Direction boundary — flush the current run.
3281                    if ($currentRun !== '') {
3282                        $out[] = [
3283                            'text' => $currentRun,
3284                            'kind' => LineBreakKind::Allowed,
3285                            'direction' => $currentDir,
3286                        ];
3287                    }
3288                    $currentRun = $chunk;
3289                    $currentDir = $dirHere;
3290                } else {
3291                    $currentRun .= $chunk;
3292                }
3293            }
3294            if ($currentRun !== '') {
3295                $out[] = [
3296                    'text' => $currentRun,
3297                    // The last sub-run keeps the parent segment's
3298                    // original break-kind (so a UAX-14 mandatory
3299                    // break at the source's end still fires).
3300                    'kind' => $seg['kind'],
3301                    'direction' => $currentDir ?? 'ltr',
3302                ];
3303            }
3304        }
3305        return $out;
3306    }
3307
3308    /**
3309     * Return a new `ShapedRun` with every glyph's `advanceX` bumped by
3310     * `$letterSpacing` and the `totalAdvance` summed accordingly.
3311     */
3312    private function applyLetterSpacing(ShapedRun $run, float $letterSpacing): ShapedRun
3313    {
3314        $glyphs = [];
3315        $total = 0.0;
3316        foreach ($run->glyphs as $g) {
3317            $newAdvance = $g->advanceX + $letterSpacing;
3318            $glyphs[] = new ShapedGlyph(
3319                $g->glyphId,
3320                $g->sourceOffset,
3321                $g->sourceLength,
3322                $newAdvance,
3323                $g->advanceY,
3324                $g->offsetX,
3325                $g->offsetY,
3326            );
3327            $total += $newAdvance;
3328        }
3329        return new ShapedRun(
3330            $run->font,
3331            $run->fontSizePt,
3332            $run->direction,
3333            $glyphs,
3334            $total,
3335        );
3336    }
3337
3338    /**
3339     * CSS Text 3 §10: `letter-spacing` keyword `normal` resolves to 0;
3340     * any `Length` (already in px after `Cascade::resolveLengths`) is the
3341     * extra advance applied to every glyph.
3342     */
3343    private function resolveLetterSpacing(Box $parent): float
3344    {
3345        $value = $parent->style->get('letter-spacing');
3346        if ($value instanceof Length) {
3347            return $value->value;
3348        }
3349        return 0.0;
3350    }
3351
3352    /**
3353     * CSS Text 3 §9: `word-spacing` adds advance only at word-separator
3354     * glyphs. `normal` → 0; any `Length` is the extra advance per separator.
3355     */
3356    private function resolveWordSpacing(Box $parent): float
3357    {
3358        $value = $parent->style->get('word-spacing');
3359        if ($value instanceof Length) {
3360            return $value->value;
3361        }
3362        return 0.0;
3363    }
3364
3365    /**
3366     * Bump the advance of every glyph whose source codepoint is a CSS
3367     * word separator (U+0020 SPACE or U+00A0 NO-BREAK SPACE). Builds and
3368     * returns a new `ShapedRun`.
3369     */
3370    private function applyWordSpacing(ShapedRun $run, string $sourceText, float $wordSpacing): ShapedRun
3371    {
3372        $glyphs = [];
3373        $total = 0.0;
3374        foreach ($run->glyphs as $g) {
3375            $bump = $this->isWordSeparatorAt($sourceText, $g->sourceOffset) ? $wordSpacing : 0.0;
3376            $newAdvance = $g->advanceX + $bump;
3377            $glyphs[] = new ShapedGlyph(
3378                $g->glyphId,
3379                $g->sourceOffset,
3380                $g->sourceLength,
3381                $newAdvance,
3382                $g->advanceY,
3383                $g->offsetX,
3384                $g->offsetY,
3385            );
3386            $total += $newAdvance;
3387        }
3388        return new ShapedRun(
3389            $run->font,
3390            $run->fontSizePt,
3391            $run->direction,
3392            $glyphs,
3393            $total,
3394        );
3395    }
3396
3397    private function isWordSeparatorAt(string $text, int $offset): bool
3398    {
3399        if ($offset < 0 || $offset >= strlen($text)) {
3400            return false;
3401        }
3402        $b = ord($text[$offset]);
3403        if ($b === 0x20) {
3404            return true;
3405        }
3406        // U+00A0 NO-BREAK SPACE → UTF-8 bytes 0xC2 0xA0.
3407        return $b === 0xC2 && ($offset + 1) < strlen($text) && ord($text[$offset + 1]) === 0xA0;
3408    }
3409
3410    /**
3411     * The dominant font-size for the inline run. Phase 1F.2 reads it from
3412     * the parent's cascaded `font-size`; mixed-size content is a Phase 2
3413     * follow-up alongside multi-font runs.
3414     */
3415    private function dominantFontSize(Box $parent, LayoutContext $context): float
3416    {
3417        $value = $parent->style->get('font-size');
3418        if ($value instanceof Length) {
3419            return $value->value;
3420        }
3421        return $context->lengthContext->currentFontSize;
3422    }
3423
3424    /**
3425     * Pull a Length value off a cascaded property in pixels. Atomic
3426     * inline boxes don't have an in-progress containing-block width
3427     * to resolve percentages against at token-collection time (the
3428     * containing block isn't passed to {@see collectTokens}), so
3429     * Percentages resolve to 0 here. That matches the older atomic
3430     * behaviour and is acceptable for the in-scope test surface
3431     * (no `<img padding-left="50%">` fixtures); percentage-padding
3432     * on replaced inlines is a future enhancement once the inline
3433     * layout owns its parent's content width directly.
3434     */
3435    private static function atomicLength(?\Phpdftk\Css\Value\Value $value): float
3436    {
3437        return $value instanceof Length
3438            ? \Phpdftk\Css\Cascade\LengthResolver::clampPx($value->value)
3439            : 0.0;
3440    }
3441
3442    /**
3443     * Side-specific border width for atomic inline boxes, accounting
3444     * for `border-<side>-style: none` (which collapses the width to
3445     * zero per CSS Backgrounds 3 §4.4) and the `thin`/`medium`/`thick`
3446     * keyword widths.
3447     */
3448    private static function atomicBorderWidth(\Phpdftk\Css\Cascade\CascadedValues $style, string $side): float
3449    {
3450        $styleValue = $style->get("border-$side-style");
3451        if ($styleValue instanceof \Phpdftk\Css\Value\Keyword
3452            && strtolower($styleValue->name) === 'none'
3453        ) {
3454            return 0.0;
3455        }
3456        $width = $style->get("border-$side-width");
3457        if ($width instanceof Length) {
3458            return \Phpdftk\Css\Cascade\LengthResolver::clampPx($width->value);
3459        }
3460        if ($width instanceof \Phpdftk\Css\Value\Keyword) {
3461            return match (strtolower($width->name)) {
3462                'thin' => 1.0,
3463                'medium' => 3.0,
3464                'thick' => 5.0,
3465                default => 0.0,
3466            };
3467        }
3468        return 0.0;
3469    }
3470
3471    /**
3472     * CSS Sizing 3 §6.2 — `true` when the cascaded `box-sizing` is
3473     * `border-box`, meaning declared width/height include the
3474     * padding + border edges.
3475     */
3476    private static function atomicIsBorderBoxSizing(\Phpdftk\Css\Cascade\CascadedValues $style): bool
3477    {
3478        $value = $style->get('box-sizing');
3479        return $value instanceof \Phpdftk\Css\Value\Keyword
3480            && strtolower($value->name) === 'border-box';
3481    }
3482
3483    /**
3484     * Resolve an atomic replaced box's DEFINITE content-box height, or
3485     * null when the height is auto / indefinite. An explicit length (or
3486     * `0`) is definite; a percentage is definite only against a definite
3487     * containing-block height (CSS 2.1 §10.5). Under `box-sizing:
3488     * border-box` the declared value includes the padding + border, so
3489     * the content height is the declared value minus the vertical inset.
3490     * Mirrors the height resolution in layoutAtomicOnly() so the shaped
3491     * path can transfer a definite height through the intrinsic ratio to
3492     * derive an auto width (§10.3.2).
3493     */
3494    private function atomicDefiniteContentHeight(AtomicInlineBox $box): ?float
3495    {
3496        $heightValue = $box->style->get('height');
3497        $declared = match (true) {
3498            $heightValue instanceof Length => $heightValue->value,
3499            $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value,
3500            $heightValue instanceof \Phpdftk\Css\Value\Percentage
3501                && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0
3502                => $this->currentCbHeight * ($heightValue->value / 100.0),
3503            default => null,
3504        };
3505        if ($declared === null) {
3506            return null;
3507        }
3508        $declared = max(0.0, $declared);
3509        if (!self::atomicIsBorderBoxSizing($box->style)) {
3510            return $declared;
3511        }
3512        $inset = self::atomicLength($box->style->get('padding-top'))
3513            + self::atomicLength($box->style->get('padding-bottom'))
3514            + self::atomicBorderWidth($box->style, 'top')
3515            + self::atomicBorderWidth($box->style, 'bottom');
3516        return max(0.0, $declared - $inset);
3517    }
3518
3519    /**
3520     * Intrinsic aspect ratio (width / height) of a replaced atomic box,
3521     * read from the `aspect-ratio` cascade value the box generator
3522     * exposes for `<img>` / `<canvas>`. Null when absent or malformed.
3523     */
3524    private static function atomicAspectRatio(\Phpdftk\Css\Cascade\CascadedValues $style): ?float
3525    {
3526        $value = $style->get('aspect-ratio');
3527        if ($value instanceof \Phpdftk\Css\Value\ValueList
3528            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
3529            && count($value->values) >= 2
3530        ) {
3531            $w = self::atomicNumeric($value->values[0]);
3532            $h = self::atomicNumeric($value->values[1]);
3533            if ($w !== null && $h !== null && $h > 0.0) {
3534                return $w / $h;
3535            }
3536        }
3537        $direct = self::atomicNumeric($value);
3538        return ($direct !== null && $direct > 0.0) ? $direct : null;
3539    }
3540
3541    private static function atomicNumeric(?\Phpdftk\Css\Value\Value $v): ?float
3542    {
3543        if ($v instanceof \Phpdftk\Css\Value\Integer) {
3544            return (float) $v->value;
3545        }
3546        if ($v instanceof \Phpdftk\Css\Value\Number) {
3547            return $v->value;
3548        }
3549        return null;
3550    }
3551
3552    /**
3553     * CSS Sizing 3 §5.2 + csswg issue 3973 — resolve a min/max-width or
3554     * min/max-height value for a replaced atomic box to pixels. Lengths
3555     * pass through; `min-content` / `max-content` / `fit-content`
3556     * transfer the box's definite cross size through the intrinsic
3557     * aspect ratio (a replaced element's content-based size in one axis
3558     * is the other axis times the ratio). Returns null when the
3559     * constraint does not apply (`none` / `auto` / `stretch`, a block-
3560     * axis percentage, or a keyword with no ratio to transfer through).
3561     */
3562    private function resolveAtomicMinMax(
3563        \Phpdftk\Css\Cascade\CascadedValues $style,
3564        string $property,
3565        float $crossContentSize,
3566        ?float $ratio,
3567        bool $isWidth,
3568    ): ?float {
3569        $v = $style->get($property);
3570        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
3571            $name = strtolower($v->name);
3572            if (in_array($name, ['min-content', 'max-content', 'fit-content'], true)) {
3573                if ($ratio === null || $ratio <= 0.0) {
3574                    return null;
3575                }
3576                return $isWidth ? $crossContentSize * $ratio : $crossContentSize / $ratio;
3577            }
3578            return null;
3579        }
3580        if ($v instanceof Length) {
3581            return \Phpdftk\Css\Cascade\LengthResolver::clampPx($v->value);
3582        }
3583        if ($v instanceof \Phpdftk\Css\Value\Percentage) {
3584            if ($isWidth) {
3585                return $this->currentAvailableWidth * ($v->value / 100.0);
3586            }
3587            // A percentage block-axis min/max resolves only against a
3588            // definite containing-block height (CSS 2.1 §10.5).
3589            if ($this->currentCbHeightDefinite && $this->currentCbHeight > 0.0) {
3590                return $this->currentCbHeight * ($v->value / 100.0);
3591            }
3592            return null;
3593        }
3594        return null;
3595    }
3596
3597    /**
3598     * Apply the replaced-element min/max-width / -height clamps to a
3599     * resolved (content-box) width / height pair. The width clamps
3600     * transfer the original height through the ratio (and vice versa)
3601     * so each keyword resolves against the definite cross size, not a
3602     * value another clamp on the same call already changed.
3603     *
3604     * @return array{0: float, 1: float} clamped [width, height]
3605     */
3606    private function clampAtomicReplaced(
3607        \Phpdftk\Css\Cascade\CascadedValues $style,
3608        float $width,
3609        float $height,
3610    ): array {
3611        $ratio = self::atomicAspectRatio($style);
3612        $origWidth = $width;
3613        $origHeight = $height;
3614        $hasRatio = $ratio !== null && $ratio > 0.0;
3615        $maxW = $this->resolveAtomicMinMax($style, 'max-width', $origHeight, $ratio, true);
3616        if ($maxW !== null && $width > $maxW) {
3617            $width = $maxW;
3618            // CSS 2.1 §10.4 — clamping one axis of a replaced element with
3619            // an intrinsic ratio transfers to the other axis to preserve
3620            // the ratio.
3621            if ($hasRatio) {
3622                $height = $width / $ratio;
3623            }
3624        }
3625        $minW = $this->resolveAtomicMinMax($style, 'min-width', $origHeight, $ratio, true);
3626        if ($minW !== null && $width < $minW) {
3627            $width = $minW;
3628            if ($hasRatio) {
3629                $height = $width / $ratio;
3630            }
3631        }
3632        $maxH = $this->resolveAtomicMinMax($style, 'max-height', $origWidth, $ratio, false);
3633        if ($maxH !== null && $height > $maxH) {
3634            $height = $maxH;
3635            if ($hasRatio) {
3636                $width = $height * $ratio;
3637            }
3638        }
3639        $minH = $this->resolveAtomicMinMax($style, 'min-height', $origWidth, $ratio, false);
3640        if ($minH !== null && $height < $minH) {
3641            $height = $minH;
3642            if ($hasRatio) {
3643                $width = $height * $ratio;
3644            }
3645        }
3646        return [$width, $height];
3647    }
3648}