Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.93% covered (warning)
89.93%
500 / 556
43.24% covered (danger)
43.24%
16 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
InlineLayout
89.93% covered (warning)
89.93%
500 / 556
43.24% covered (danger)
43.24%
16 / 37
225.50
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
98.08% covered (success)
98.08%
102 / 104
0.00% covered (danger)
0.00%
0 / 1
20
 resolveTabSize
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 lineBounds
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 applyTextOverflow
72.41% covered (warning)
72.41%
21 / 29
0.00% covered (danger)
0.00%
0 / 1
12.10
 applyTextAlign
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
16.10
 isTextJustifyNone
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 textAlignLastKeyword
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 textAlignKeyword
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 applyTextTransform
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 capitalizeWords
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 resolveLineHeight
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 collectTokens
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 walkInline
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
1 / 1
20
 lineHeightFor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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
 resolveBoxFont
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 decorationLines
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
12.00
 mergeDecorationLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveBackground
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveDecorationColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isBreakAll
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 resolveVerticalAlign
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 tokeniseText
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
15.04
 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
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7use Phpdftk\Css\Value\Length;
8use Phpdftk\HtmlToPdf\Box\AtomicInlineBox;
9use Phpdftk\HtmlToPdf\Box\Box;
10use Phpdftk\HtmlToPdf\Box\InlineBox;
11use Phpdftk\HtmlToPdf\Box\LineBreakBox;
12use Phpdftk\HtmlToPdf\Box\TextBox;
13use Phpdftk\Text\LineBreaker;
14use Phpdftk\Text\LineBreakKind;
15use Phpdftk\Text\Shaper;
16use Phpdftk\Text\ShapedGlyph;
17use Phpdftk\Text\ShapedRun;
18use Phpdftk\Text\ShapingContext;
19
20/**
21 * Inline formatting context layout — Phase 1F.2 (text shaping + greedy
22 * line wrapping).
23 *
24 * Walks a parent block's inline children (InlineBox / AtomicInlineBox /
25 * TextBox subtrees), shapes their text via `phpdftk/text`'s Shaper, finds
26 * line-break opportunities via UAX #14, and greedily fits the resulting
27 * fragments into line boxes that respect the parent's content width.
28 *
29 * Phase-1 simplifications:
30 *  - Single font per inline run (the layout context's `defaultFont`).
31 *    Font runs / fallback live alongside paragraph shaping in Phase 2.
32 *  - Bidi reorder is the bidi engine's job; this layout reads logical
33 *    order and lays runs left-to-right.
34 *  - Atomic inline boxes (replaced elements, inline-block) are treated as
35 *    fixed-size boxes; sizing comes from the box style (width / height).
36 *  - Line height defaults to `1.2 × font-size` per CSS Inline §3.
37 *
38 * When no font is available in the layout context, this layout falls back
39 * to producing zero-height (no-op) lines so block layout can still
40 * complete end-to-end on the test surface.
41 */
42final class InlineLayout
43{
44    /**
45     * Captured from the LayoutContext at the start of each `layout()`
46     * call so `walkInline` can re-resolve fonts for nested `<code>` etc.
47     * without threading the resolver through every recursive parameter.
48     */
49    private ?FontResolver $currentFontResolver = null;
50
51    public function __construct(
52        private readonly Shaper $shaper = new Shaper(),
53        private readonly LineBreaker $lineBreaker = new LineBreaker(),
54    ) {}
55
56    /**
57     * Lay out the inline children of `$parent` within `$availableWidth`,
58     * starting at Y = 0 in the parent's coordinate space. Returns the
59     * generated line boxes (positions relative to the parent's content
60     * area) and the total height consumed.
61     *
62     * @return array{list<LineBox>, float} (lines, totalHeight)
63     */
64    public function layout(Box $parent, float $availableWidth, LayoutContext $context): array
65    {
66        $this->currentFontResolver = $context->fontResolver;
67        // Resolve the shaping font + post-match synthetic-effect flags via
68        // CSS Fonts 4 §6 weight/style matching. When a real face matches
69        // the cascaded weight/style, `isBold`/`isItalic` come back false so
70        // the painter doesn't double up with fake-bold / fake-italic.
71        $parentMatch = $this->resolveBoxFont($parent, $context->defaultFont);
72        $font = $parentMatch['font'];
73        $fontSize = $this->dominantFontSize($parent, $context);
74        if ($font === null || $availableWidth <= 0.0) {
75            return [[], 0.0];
76        }
77        $shapingCtx = new ShapingContext($font, $fontSize);
78        $lineHeight = $this->resolveLineHeight($parent, $fontSize);
79        $whiteSpace = $this->whiteSpaceKeyword($parent);
80        $allowSoftWrap = $whiteSpace !== 'nowrap' && $whiteSpace !== 'pre';
81        // `pre` and `pre-wrap` preserve whitespace; only `normal` / `nowrap` /
82        // `pre-line` collapse leading whitespace at line starts (CSS Text 3 §4).
83        $collapseLeadingWhitespace = $whiteSpace !== 'pre' && $whiteSpace !== 'pre-wrap';
84        $collapseInternalWhitespace = $whiteSpace === 'normal' || $whiteSpace === 'nowrap';
85
86        $letterSpacing = $this->resolveLetterSpacing($parent);
87        $wordSpacing = $this->resolveWordSpacing($parent);
88        $tokens = $this->collectTokens(
89            $parent,
90            $shapingCtx,
91            $collapseInternalWhitespace,
92            $letterSpacing,
93            $wordSpacing,
94            baselineShift: 0.0,
95            href: null,
96            isBold: $parentMatch['isBold'],
97            isItalic: $parentMatch['isItalic'],
98            decorationLines: $this->decorationLines($parent),
99            textColor: $this->resolveColor($parent),
100            backgroundColor: null,
101            linkTitle: null,
102            decorationColor: $this->resolveDecorationColor($parent),
103        );
104        if ($tokens === []) {
105            return [[], 0.0];
106        }
107
108        // CSS Text 3 §3.1: `text-indent` shifts the first inline box of the
109        // first formatted line. Length resolves directly; Percentage resolves
110        // against the block's content width (our `$availableWidth`).
111        $textIndent = $this->resolveTextIndent($parent, $availableWidth);
112
113        // CSS 2.1 §9.5.3 — line boxes shorten on the side(s) where a
114        // float is currently active. Compute the per-line (left, right)
115        // bounds against the float context each time we start a new line.
116        $bounds = $this->lineBounds($parent, $availableWidth, $context, 0.0);
117        $lines = [];
118        $currentFragments = [];
119        $currentX = $bounds['left'] + $textIndent;
120        $lineMaxRight = $bounds['right'];
121        $atLineStart = true;
122        $y = 0.0;
123        foreach ($tokens as $token) {
124            $width = $token['shapedRun']->totalAdvance;
125            $isMandatory = $token['kind'] === LineBreakKind::Mandatory;
126
127            if ($collapseLeadingWhitespace && $token['isWhitespace'] && $atLineStart) {
128                // Leading whitespace at a line start is collapsed.
129                continue;
130            }
131            if ($allowSoftWrap
132                && $currentX + $width > $lineMaxRight
133                && $currentFragments !== []
134            ) {
135                // Wrap before placing this token.
136                $effective = $this->lineHeightFor($currentFragments, $lineHeight);
137                $lines[] = new LineBox($y, $effective, $currentFragments);
138                $y += $effective;
139                $currentFragments = [];
140                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y);
141                $currentX = $bounds['left'];
142                $lineMaxRight = $bounds['right'];
143                $atLineStart = true;
144                if ($collapseLeadingWhitespace && $token['isWhitespace']) {
145                    // Drop whitespace at start of next line.
146                    continue;
147                }
148            }
149            $currentFragments[] = new InlineFragment(
150                $currentX,
151                $width,
152                $token['shapedRun'],
153                $token['baselineShift'] ?? 0.0,
154                $token['href'] ?? null,
155                $token['isBold'] ?? false,
156                $token['isItalic'] ?? false,
157                $token['decorationLines'] ?? [],
158                $token['textColor'] ?? null,
159                $token['backgroundColor'] ?? null,
160                $token['linkTitle'] ?? null,
161                $token['decorationColor'] ?? null,
162            );
163            // Side-channel: AtomicInlineBox positions get committed back to
164            // the box's geometry so the painter can draw images / replaced
165            // content at the right spot. CSS Inline 3 §4.5: for the default
166            // `vertical-align: baseline`, the inline-block's baseline aligns
167            // with the parent line's baseline; for replaced elements like
168            // `<img>` the baseline is the bottom of the box. So position
169            // the box so its *bottom* sits at the line's baseline (line.y +
170            // ascent of the shaping font) — same convention the painter
171            // uses for text baselines.
172            $atomic = $token['atomicBox'] ?? null;
173            if ($atomic !== null) {
174                $heightValue = $atomic->style->get('height');
175                $atomicHeight = $heightValue instanceof Length
176                    ? $heightValue->value
177                    : $width;
178                $shapedRun = $token['shapedRun'];
179                $atomicFont = $shapedRun->font;
180                $atomicUpem = max(1, $atomicFont->unitsPerEm);
181                $atomicAscent = ($atomicFont->ascent / $atomicUpem) * $shapedRun->fontSizePt;
182                $atomic->geometry->x = $parent->geometry->x + $currentX;
183                $atomic->geometry->y = $parent->geometry->y + $y + $atomicAscent - $atomicHeight;
184                $atomic->geometry->width = $width;
185                $atomic->geometry->height = $atomicHeight;
186            }
187            $currentX += $width;
188            $atLineStart = false;
189            if ($isMandatory) {
190                $effective = $this->lineHeightFor($currentFragments, $lineHeight);
191                $lines[] = new LineBox($y, $effective, $currentFragments);
192                $y += $effective;
193                $currentFragments = [];
194                $bounds = $this->lineBounds($parent, $availableWidth, $context, $y);
195                $currentX = $bounds['left'];
196                $lineMaxRight = $bounds['right'];
197                $atLineStart = true;
198            }
199        }
200        if ($currentFragments !== []) {
201            $effective = $this->lineHeightFor($currentFragments, $lineHeight);
202            $lines[] = new LineBox($y, $effective, $currentFragments);
203            $y += $effective;
204        }
205
206        // CSS UI 3 §6.2: `text-overflow: ellipsis` truncates each line's
207        // tail when its content exceeds the available width. Runs before
208        // text-align so the alignment math operates on the truncated rect.
209        $lines = $this->applyTextOverflow($lines, $availableWidth, $parent, $shapingCtx, $letterSpacing);
210
211        $lines = $this->applyTextAlign($lines, $availableWidth, $parent);
212        return [$lines, $y];
213    }
214
215    /**
216     * Resolve CSS Text 3 §11.2 `tab-size` to an integer space count.
217     *
218     * - `<integer>` / `<number>`: direct space count.
219     * - `<length>`: divide by a glyph-space advance estimate
220     *   (0.25 × font-size — a sane default for sans-serif) and round
221     *   to the nearest integer ≥ 0. This is an approximation since
222     *   tab-stop alignment isn't implemented, but converts a
223     *   length-based author intent to the closest N-space expansion.
224     * - Anything else (`auto`, unknown keywords): the spec default 8.
225     */
226    private function resolveTabSize(Box $box): int
227    {
228        $value = $box->style->get('tab-size');
229        if ($value instanceof \Phpdftk\Css\Value\Integer) {
230            return max(0, $value->value);
231        }
232        if ($value instanceof \Phpdftk\Css\Value\Number) {
233            return max(0, (int) round($value->value));
234        }
235        if ($value instanceof \Phpdftk\Css\Value\Length) {
236            $fontSize = $this->dominantFontSize($box, new LayoutContext(
237                0.0,
238                0.0,
239                0.0,
240                0.0,
241                new \Phpdftk\Css\Cascade\LengthContext(),
242            ));
243            $spaceAdvance = max(0.1, $fontSize * 0.25);
244            return max(0, (int) round($value->value / $spaceAdvance));
245        }
246        return 8;
247    }
248
249    /**
250     * Resolve the left and right inset of a line at relative-Y `$y`
251     * against the active {@see FloatContext}. Returns offsets relative
252     * to the parent's content-edge X — so `left` is the line's start X
253     * within the parent's box, and `right` is the line's max-end X.
254     *
255     * Without floats this is just `[0, $availableWidth]`. With a left
256     * float overlapping the line, `left` rises; with a right float,
257     * `right` falls.
258     *
259     * Phase-1 simplification: samples at the line's top edge only.
260     * Browsers conceptually sample across the full line range and take
261     * the most-constrained bounds.
262     *
263     * @return array{left: float, right: float}
264     */
265    private function lineBounds(Box $parent, float $availableWidth, LayoutContext $context, float $relY): array
266    {
267        $floatCtx = $context->floatContext;
268        if ($floatCtx === null) {
269            return ['left' => 0.0, 'right' => $availableWidth];
270        }
271        $parentX = $parent->geometry->x;
272        $parentY = $parent->geometry->y;
273        $absY = $parentY + $relY;
274        $absLeft = $floatCtx->leftEdgeAt($absY, $parentX);
275        $absRight = $floatCtx->rightEdgeAt($absY, $parentX + $availableWidth);
276        return [
277            'left' => max(0.0, $absLeft - $parentX),
278            'right' => max(0.0, $absRight - $parentX),
279        ];
280    }
281
282    /**
283     * Drop fragments from each overflowing line until an ellipsis glyph
284     * fits at the end. Only applies when the parent's `text-overflow` is
285     * `ellipsis`; the default `clip` keyword silently lets the content
286     * overflow (matching the no-op CSS spec behaviour).
287     *
288     * @param list<LineBox> $lines
289     * @return list<LineBox>
290     */
291    private function applyTextOverflow(
292        array $lines,
293        float $availableWidth,
294        Box $parent,
295        ShapingContext $shapingCtx,
296        float $letterSpacing,
297    ): array {
298        $value = $parent->style->get('text-overflow');
299        if (!($value instanceof \Phpdftk\Css\Value\Keyword)
300            || strtolower($value->name) !== 'ellipsis'
301        ) {
302            return $lines;
303        }
304        $ellipsis = $this->shaper->shapeRun("\u{2026}", $shapingCtx);
305        if ($ellipsis->glyphs === []) {
306            return $lines;
307        }
308        if ($letterSpacing !== 0.0) {
309            $ellipsis = $this->applyLetterSpacing($ellipsis, $letterSpacing);
310        }
311        $ellipsisWidth = $ellipsis->totalAdvance;
312
313        $out = [];
314        foreach ($lines as $line) {
315            if ($line->totalWidth() <= $availableWidth) {
316                $out[] = $line;
317                continue;
318            }
319            // Drop fragments from the end until the remaining content +
320            // ellipsis fits. Phase-1 truncates at fragment boundaries —
321            // mid-fragment truncation lands with a per-glyph cut later.
322            $fragments = $line->fragments;
323            $cutoff = $availableWidth - $ellipsisWidth;
324            while ($fragments !== []) {
325                $last = $fragments[array_key_last($fragments)];
326                if ($last->x + $last->width <= $cutoff) {
327                    break;
328                }
329                array_pop($fragments);
330            }
331            if ($fragments === []) {
332                // Nothing fits before the ellipsis; emit just the ellipsis
333                // at x = 0 so the user sees something.
334                $fragments[] = new InlineFragment(0.0, $ellipsisWidth, $ellipsis);
335            } else {
336                $last = $fragments[array_key_last($fragments)];
337                $tail = $last->x + $last->width;
338                $fragments[] = new InlineFragment($tail, $ellipsisWidth, $ellipsis);
339            }
340            $out[] = new LineBox($line->y, $line->height, $fragments);
341        }
342        return $out;
343    }
344
345    /**
346     * Apply the parent's `text-align` to each line: `start` / `left` (default,
347     * no-op), `center`, `end` / `right`, or `justify`. Justify is approximated
348     * for the last line as left-aligned per CSS Text 3 §7.3 ("the last line
349     * of a block, and any line ending with a forced line break, is start-
350     * aligned"); inter-fragment justification on non-final lines distributes
351     * extra space evenly across the gaps between fragments.
352     *
353     * @param list<LineBox> $lines
354     * @return list<LineBox>
355     */
356    private function applyTextAlign(array $lines, float $availableWidth, Box $parent): array
357    {
358        $align = $this->textAlignKeyword($parent);
359        $alignLast = $this->textAlignLastKeyword($parent, $align);
360        // CSS Text 3 §7.5: `text-justify: none` disables justification.
361        // A `justify` text-align falls through to start-alignment.
362        if ($this->isTextJustifyNone($parent)) {
363            if ($align === 'justify') {
364                $align = 'start';
365            }
366            if ($alignLast === 'justify') {
367                $alignLast = 'start';
368            }
369        }
370        if ($align === 'left' || $align === 'start') {
371            if ($alignLast === 'left' || $alignLast === 'start' || $alignLast === 'auto') {
372                return $lines;
373            }
374        }
375        $count = count($lines);
376        $out = [];
377        foreach ($lines as $i => $line) {
378            $used = $line->totalWidth();
379            $slack = $availableWidth - $used;
380            if ($slack <= 0.0) {
381                $out[] = $line;
382                continue;
383            }
384            $isLast = $i === $count - 1;
385            $effective = $isLast ? $alignLast : $align;
386            $newFragments = match ($effective) {
387                'center' => $this->shiftFragments($line->fragments, $slack / 2.0),
388                'right', 'end' => $this->shiftFragments($line->fragments, $slack),
389                'justify' => $this->justifyFragments($line->fragments, $slack),
390                default => $line->fragments,
391            };
392            $out[] = new LineBox($line->y, $line->height, $newFragments);
393        }
394        return $out;
395    }
396
397    /**
398     * CSS Text 3 §7.5 — `true` when the parent declares
399     * `text-justify: none`, in which case the justify branches of
400     * `text-align` and `text-align-last` collapse to start-alignment.
401     */
402    private function isTextJustifyNone(Box $parent): bool
403    {
404        $value = $parent->style->get('text-justify');
405        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
406            return false;
407        }
408        return strtolower($value->name) === 'none';
409    }
410
411    /**
412     * Resolve CSS Text 3 §7.4 `text-align-last`. `auto` (initial)
413     * inherits the block-aligned behaviour: when text-align is
414     * `justify` the last line is start-aligned, otherwise it matches
415     * text-align. Explicit values override.
416     */
417    private function textAlignLastKeyword(Box $parent, string $align): string
418    {
419        $value = $parent->style->get('text-align-last');
420        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
421            return 'auto';
422        }
423        $lower = strtolower($value->name);
424        if ($lower === 'auto') {
425            // text-align: justify → last line is start-aligned per spec.
426            return $align === 'justify' ? 'start' : $align;
427        }
428        return $lower;
429    }
430
431    private function textAlignKeyword(Box $parent): string
432    {
433        $value = $parent->style->get('text-align');
434        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
435            return strtolower($value->name);
436        }
437        return 'start';
438    }
439
440    /**
441     * Apply CSS Text 3 §2 `text-transform` to a text run before shaping.
442     * `uppercase` / `lowercase` are full case mappings via `mb_strtoupper` /
443     * `mb_strtolower`; `capitalize` upper-cases the first grapheme of each
444     * whitespace-separated word; `full-width` / `full-size-kana` and other
445     * Phase-2 transforms fall through unchanged.
446     */
447    private function applyTextTransform(string $text, Box $box): string
448    {
449        $value = $box->style->get('text-transform');
450        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
451            return $text;
452        }
453        return match (strtolower($value->name)) {
454            'uppercase' => mb_strtoupper($text, 'UTF-8'),
455            'lowercase' => mb_strtolower($text, 'UTF-8'),
456            'capitalize' => $this->capitalizeWords($text),
457            default => $text,
458        };
459    }
460
461    private function capitalizeWords(string $text): string
462    {
463        // Split on whitespace runs, capitalize the first codepoint of each
464        // non-empty word, and rejoin with the original separators.
465        $parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
466        if ($parts === false) {
467            return $text;
468        }
469        $out = '';
470        foreach ($parts as $part) {
471            if ($part === '' || preg_match('/^\s+$/u', $part) === 1) {
472                $out .= $part;
473                continue;
474            }
475            $first = mb_substr($part, 0, 1, 'UTF-8');
476            $rest = mb_substr($part, 1, null, 'UTF-8');
477            $out .= mb_strtoupper($first, 'UTF-8') . $rest;
478        }
479        return $out;
480    }
481
482    /**
483     * Resolve CSS Inline 3 §3 `line-height`:
484     *  - `normal` → font-dependent multiplier (1.2 for Latin until proper
485     *    OS/2 typo-metrics-driven line-height ships).
486     *  - `<number>` → multiplier of font-size; value inherits as the number
487     *    so children re-resolve against their own size.
488     *  - `<length>` → absolute, already in px after `Cascade::resolveLengths`.
489     *  - `<percentage>` → percentage of the element's own font-size.
490     */
491    private function resolveLineHeight(Box $parent, float $fontSize): float
492    {
493        $value = $parent->style->get('line-height');
494        if ($value instanceof \Phpdftk\Css\Value\Keyword
495            && strtolower($value->name) === 'normal'
496        ) {
497            return $fontSize * 1.2;
498        }
499        if ($value instanceof \Phpdftk\Css\Value\Number
500            || $value instanceof \Phpdftk\Css\Value\Integer
501        ) {
502            return $fontSize * $value->value;
503        }
504        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
505            return $fontSize * ($value->value / 100.0);
506        }
507        if ($value instanceof Length) {
508            return $value->value;
509        }
510        return $fontSize * 1.2;
511    }
512
513    /**
514     * Resolve the parent's `text-indent` CSS value against the available
515     * width. Length resolves directly; Percentage resolves against the
516     * block's content width per CSS Text 3 §3.1; everything else falls to 0.
517     */
518    private function resolveTextIndent(Box $parent, float $availableWidth): float
519    {
520        $value = $parent->style->get('text-indent');
521        if ($value instanceof Length) {
522            return $value->value;
523        }
524        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
525            return $availableWidth * ($value->value / 100.0);
526        }
527        return 0.0;
528    }
529
530    private function whiteSpaceKeyword(Box $parent): string
531    {
532        $value = $parent->style->get('white-space');
533        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
534            return strtolower($value->name);
535        }
536        return 'normal';
537    }
538
539    /**
540     * @param list<InlineFragment> $fragments
541     * @return list<InlineFragment>
542     */
543    private function shiftFragments(array $fragments, float $dx): array
544    {
545        $out = [];
546        foreach ($fragments as $f) {
547            $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);
548        }
549        return $out;
550    }
551
552    /**
553     * Spread the slack across the gaps between fragments (CSS Text 3 §7.3
554     * `justify-content` approximation for inter-word distribution).
555     *
556     * @param list<InlineFragment> $fragments
557     * @return list<InlineFragment>
558     */
559    private function justifyFragments(array $fragments, float $slack): array
560    {
561        $gaps = max(0, count($fragments) - 1);
562        if ($gaps === 0) {
563            return $fragments;
564        }
565        $delta = $slack / $gaps;
566        $out = [];
567        foreach ($fragments as $i => $f) {
568            $out[] = new InlineFragment($f->x + $i * $delta, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor);
569        }
570        return $out;
571    }
572
573    /**
574     * Tokenise the inline subtree at line-break opportunities and shape
575     * each token. Each token records its width, its source kind
576     * (whitespace / non-whitespace), and whether it closes a mandatory
577     * break.
578     *
579     * @param list<string> $decorationLines
580     * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}>
581     */
582    private function collectTokens(
583        Box $parent,
584        ShapingContext $shapingCtx,
585        bool $collapseInternal,
586        float $letterSpacing,
587        float $wordSpacing,
588        float $baselineShift,
589        ?string $href,
590        bool $isBold,
591        bool $isItalic,
592        array $decorationLines,
593        ?\Phpdftk\Css\Value\Color $textColor,
594        ?\Phpdftk\Css\Value\Color $backgroundColor,
595        ?string $linkTitle,
596        ?\Phpdftk\Css\Value\Color $decorationColor,
597    ): array {
598        $out = [];
599        foreach ($parent->children as $child) {
600            $this->walkInline(
601                $child,
602                $shapingCtx,
603                $out,
604                $collapseInternal,
605                $letterSpacing,
606                $wordSpacing,
607                $baselineShift,
608                $href,
609                $isBold,
610                $isItalic,
611                $decorationLines,
612                $textColor,
613                $backgroundColor,
614                $linkTitle,
615                $decorationColor,
616            );
617        }
618        return $out;
619    }
620
621    /**
622     * @param list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}> $tokens
623     * @param list<string> $decorationLines
624     */
625    private function walkInline(
626        Box $box,
627        ShapingContext $shapingCtx,
628        array &$tokens,
629        bool $collapseInternal,
630        float $letterSpacing,
631        float $wordSpacing,
632        float $baselineShift,
633        ?string $href,
634        bool $isBold,
635        bool $isItalic,
636        array $decorationLines,
637        ?\Phpdftk\Css\Value\Color $textColor,
638        ?\Phpdftk\Css\Value\Color $backgroundColor,
639        ?string $linkTitle,
640        ?\Phpdftk\Css\Value\Color $decorationColor,
641    ): void {
642        if ($box instanceof TextBox) {
643            $text = $box->text;
644            if ($collapseInternal) {
645                // CSS Text 3 §4.1.1: in `normal` / `nowrap`, runs of
646                // whitespace collapse to a single space. Newlines collapse
647                // alongside spaces / tabs / form feeds.
648                $text = preg_replace('/[ \t\n\r\f]+/', ' ', $text) ?? $text;
649            } else {
650                // CSS Text 3 §11.2 — in white-space modes that preserve
651                // tabs (`pre`, `pre-wrap`), each U+0009 expands to N
652                // spaces. Phase-1 simplification: fixed expansion
653                // instead of tab-stop alignment (which would require
654                // tracking column position across mid-text breaks).
655                $tabSize = $this->resolveTabSize($box);
656                if ($tabSize > 0) {
657                    $text = str_replace("\t", str_repeat(' ', $tabSize), $text);
658                } else {
659                    $text = str_replace("\t", '', $text);
660                }
661            }
662            // CSS Text 3 §2: `text-transform` runs before shaping so the
663            // shaper sees the transformed codepoints.
664            $text = $this->applyTextTransform($text, $box);
665            $breakAll = $this->isBreakAll($box);
666            foreach ($this->tokeniseText($text, $shapingCtx, $letterSpacing, $wordSpacing, $breakAll) as $token) {
667                $token['baselineShift'] = $baselineShift;
668                $token['href'] = $href;
669                $token['isBold'] = $isBold;
670                $token['isItalic'] = $isItalic;
671                $token['decorationLines'] = $decorationLines;
672                $token['textColor'] = $textColor;
673                $token['backgroundColor'] = $backgroundColor;
674                $token['linkTitle'] = $linkTitle;
675                $token['decorationColor'] = $decorationColor;
676                $tokens[] = $token;
677            }
678            return;
679        }
680        if ($box instanceof LineBreakBox) {
681            // `<br>` — hard break that survives `white-space: normal`'s
682            // collapsing. Emit a zero-width mandatory-break token so the
683            // line-fitter closes the current line and starts a new one.
684            $tokens[] = [
685                'shapedRun' => new ShapedRun(
686                    $shapingCtx->font,
687                    $shapingCtx->fontSizePt,
688                    $shapingCtx->direction,
689                    [],
690                    0.0,
691                ),
692                'isWhitespace' => false,
693                'kind' => LineBreakKind::Mandatory,
694            ];
695            return;
696        }
697        if ($box instanceof AtomicInlineBox) {
698            // Treat as a single non-wrapping token with its computed width.
699            $widthValue = $box->style->get('width');
700            $width = $widthValue instanceof Length ? $widthValue->value : 0.0;
701            $tokens[] = [
702                'shapedRun' => new ShapedRun(
703                    $shapingCtx->font,
704                    $shapingCtx->fontSizePt,
705                    $shapingCtx->direction,
706                    [],
707                    $width,
708                ),
709                'isWhitespace' => false,
710                'kind' => LineBreakKind::Allowed,
711                'baselineShift' => $baselineShift,
712                'href' => $href,
713                'isBold' => $isBold,
714                'isItalic' => $isItalic,
715                'decorationLines' => $decorationLines,
716                'textColor' => $textColor,
717                'backgroundColor' => $backgroundColor,
718                'linkTitle' => $linkTitle,
719                'decorationColor' => $decorationColor,
720                'atomicBox' => $box,
721            ];
722            return;
723        }
724        if ($box instanceof InlineBox) {
725            // CSS Inline 3 §4.5 `vertical-align: sub` / `super` shifts the
726            // child fragments' baselines. Composes with any outer shift so
727            // nested `<sup><sub>x</sub></sup>` still has a sensible effect.
728            $boxShift = $this->resolveVerticalAlign($box, $shapingCtx->fontSizePt);
729            // HTML 4 / 5 `<a href="...">` — descendants inherit the href so
730            // the painter can emit a `/Link` annotation per fragment. The
731            // companion `<a title="...">` lands on the annotation's
732            // `/Contents` for hover tooltips.
733            $childHref = $href;
734            $childTitle = $linkTitle;
735            if ($box->element !== null
736                && strtolower($box->element->localName) === 'a'
737            ) {
738                $linkUrl = $box->element->getAttribute('href');
739                if ($linkUrl !== null && $linkUrl !== '') {
740                    $childHref = $linkUrl;
741                    $title = $box->element->getAttribute('title');
742                    $childTitle = $title === null || $title === '' ? null : $title;
743                }
744            }
745            // Inline-level emphasis: resolve this box's own weight/style
746            // request against the FontResolver. A real face match flips
747            // the per-fragment fake-bold / fake-italic flags off; an
748            // unmatched request OR no faceMap entry leaves the cascade's
749            // synthetic-effect flags on so the painter draws the fallback.
750            // OR with the inherited flags so `<strong><em>X</em></strong>`
751            // keeps both effects even when only one resolves to a real face.
752            $boxMatch = $this->resolveBoxFont($box, $shapingCtx->font);
753            $childBold = $boxMatch['isBold'] || $isBold;
754            $childItalic = $boxMatch['isItalic'] || $isItalic;
755            // CSS Text Decoration 4 §2 says decorations set on an inline
756            // apply to all in-flow descendant text. Union the box's
757            // decoration lines with whatever the enclosing context set.
758            $childDeco = $this->mergeDecorationLines($decorationLines, $this->decorationLines($box));
759            // §3: `text-decoration-color` doesn't inherit, but when an
760            // inline element sets a colour explicitly that colour applies
761            // to its descendant fragments' decorations. A child only
762            // overrides if it sets its own value — otherwise it keeps the
763            // closest ancestor's choice.
764            $childDecoColor = $this->resolveDecorationColor($box) ?? $decorationColor;
765            // The inline box's own cascaded `color` overrides the inherited
766            // one — `<a>` gets blue from the UA stylesheet even when its
767            // parent is black.
768            $childColor = $this->resolveColor($box) ?? $textColor;
769            // `background-color` is not inherited but propagates downward
770            // for inline rendering — every descendant fragment of a
771            // `<mark>` should carry the yellow rect.
772            $boxBg = $this->resolveBackground($box);
773            $childBg = $boxBg ?? $backgroundColor;
774            // Mixed-size inline runs: if this inline carries a different
775            // computed `font-size` than the active shaping context, build
776            // a per-subtree context so descendants shape at the right size.
777            // Same for `font-family` — when an inline names a font that's
778            // registered in the FontResolver, switch the shaping font.
779            $childCtx = $shapingCtx;
780            $boxFontSize = $this->boxFontSize($box) ?? $shapingCtx->fontSizePt;
781            $boxFont = $boxMatch['font'] ?? $shapingCtx->font;
782            $fontSizeChanged = abs($boxFontSize - $shapingCtx->fontSizePt) > 0.001;
783            $fontChanged = $boxFont !== $shapingCtx->font;
784            if ($fontSizeChanged || $fontChanged) {
785                $childCtx = new ShapingContext($boxFont, $boxFontSize);
786            }
787            foreach ($box->children as $c) {
788                $this->walkInline(
789                    $c,
790                    $childCtx,
791                    $tokens,
792                    $collapseInternal,
793                    $letterSpacing,
794                    $wordSpacing,
795                    $baselineShift + $boxShift,
796                    $childHref,
797                    $childBold,
798                    $childItalic,
799                    $childDeco,
800                    $childColor,
801                    $childBg,
802                    $childTitle,
803                    $childDecoColor,
804                );
805            }
806        }
807    }
808
809    /**
810     * Per CSS Inline 3 §3, the line box's used height is the maximum of the
811     * inline heights it contains. Use the parent's resolved line-height as
812     * the baseline, then grow if a fragment carries a larger font.
813     *
814     * @param list<InlineFragment> $fragments
815     */
816    private function lineHeightFor(array $fragments, float $parentLineHeight): float
817    {
818        $maxFontSize = 0.0;
819        foreach ($fragments as $f) {
820            if ($f->shapedRun->fontSizePt > $maxFontSize) {
821                $maxFontSize = $f->shapedRun->fontSizePt;
822            }
823        }
824        return max($parentLineHeight, $maxFontSize * 1.2);
825    }
826
827    /**
828     * The box's cascaded `font-size` in user-space units, or null when the
829     * cascade didn't produce a `Length` (the cascade should always produce
830     * one after `resolveLengths`; null is just a safety fallback).
831     */
832    private function boxFontSize(Box $box): ?float
833    {
834        $value = $box->style->get('font-size');
835        return $value instanceof Length ? $value->value : null;
836    }
837
838    /**
839     * Resolve the cascaded `font-weight` to a numeric value in the CSS
840     * Fonts 4 1–1000 range. Keywords map per spec: `normal` → 400,
841     * `bold` / `bolder` → 700, `lighter` → 100.
842     */
843    private function resolveWeight(Box $box): int
844    {
845        $value = $box->style->get('font-weight');
846        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
847            return match (strtolower($value->name)) {
848                'bold', 'bolder' => 700,
849                'lighter' => 100,
850                default => 400,
851            };
852        }
853        if ($value instanceof \Phpdftk\Css\Value\Integer
854            || $value instanceof \Phpdftk\Css\Value\Number
855        ) {
856            return max(1, min(1000, (int) $value->value));
857        }
858        return 400;
859    }
860
861    /**
862     * Resolve the cascaded `font-style` to a lower-case keyword in the
863     * `normal` | `italic` | `oblique` set. Unrecognised values fall back
864     * to `normal`.
865     */
866    private function resolveStyle(Box $box): string
867    {
868        $value = $box->style->get('font-style');
869        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
870            $lc = strtolower($value->name);
871            if (in_array($lc, ['italic', 'oblique'], true)) {
872                return $lc;
873            }
874        }
875        return 'normal';
876    }
877
878    /**
879     * Combined font/weight/style resolution for the box. Picks the
880     * concrete `OpenTypeData` via {@see FontResolver::resolveMatch()} and
881     * derives the post-match "still needs synthetic effect" flags by
882     * comparing the matched face's axes against the requested cascade.
883     *
884     * @return array{font: ?\Phpdftk\FontParser\OpenTypeData, isBold: bool, isItalic: bool}
885     */
886    private function resolveBoxFont(Box $box, ?\Phpdftk\FontParser\OpenTypeData $fallback): array
887    {
888        $weight = $this->resolveWeight($box);
889        $style = $this->resolveStyle($box);
890        $requestBold = $weight >= 600;
891        $requestItalic = $style !== 'normal';
892        $resolver = $this->currentFontResolver;
893        $match = $resolver?->resolveMatch(
894            $box->style->get('font-family'),
895            $weight,
896            $style,
897        );
898        $font = $match?->face->data ?? $fallback;
899        $isBold = $requestBold && ($match === null || !$match->matchesWeight);
900        $isItalic = $requestItalic && ($match === null || !$match->matchesStyle);
901        return ['font' => $font, 'isBold' => $isBold, 'isItalic' => $isItalic];
902    }
903
904    /**
905     * Read the box's cascaded `text-decoration-line` and return the set of
906     * line keywords it carries — empty list for `none` / unset.
907     *
908     * @return list<string>
909     */
910    private function decorationLines(Box $box): array
911    {
912        $value = $box->style->get('text-decoration-line');
913        if ($value === null
914            || ($value instanceof \Phpdftk\Css\Value\Keyword
915                && strtolower($value->name) === 'none')
916        ) {
917            return [];
918        }
919        $names = [];
920        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
921            $names[] = strtolower($value->name);
922        } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) {
923            foreach ($value->values as $v) {
924                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
925                    $kw = strtolower($v->name);
926                    if ($kw !== 'none') {
927                        $names[] = $kw;
928                    }
929                }
930            }
931        }
932        return array_values(array_unique(array_filter(
933            $names,
934            static fn(string $n): bool => in_array($n, ['underline', 'overline', 'line-through'], true),
935        )));
936    }
937
938    /**
939     * Combine outer + inner text-decoration line lists into a deduped list.
940     *
941     * @param list<string> $outer
942     * @param list<string> $inner
943     * @return list<string>
944     */
945    private function mergeDecorationLines(array $outer, array $inner): array
946    {
947        return array_values(array_unique(array_merge($outer, $inner)));
948    }
949
950    /**
951     * Read the box's cascaded `color`, or null when the cascade didn't
952     * produce a `Color` value (e.g. unresolved keyword fallback).
953     */
954    private function resolveColor(Box $box): ?\Phpdftk\Css\Value\Color
955    {
956        $value = $box->style->get('color');
957        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
958    }
959
960    private function resolveBackground(Box $box): ?\Phpdftk\Css\Value\Color
961    {
962        $value = $box->style->get('background-color');
963        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
964    }
965
966    /**
967     * Read the box's cascaded `text-decoration-color`, or null when the
968     * property is unset / inherits to the default keyword. CSS Text
969     * Decoration 4 §3: the property does *not* inherit through inlines,
970     * so callers explicitly pick whichever closer ancestor set it.
971     */
972    private function resolveDecorationColor(Box $box): ?\Phpdftk\Css\Value\Color
973    {
974        $value = $box->style->get('text-decoration-color');
975        return $value instanceof \Phpdftk\Css\Value\Color ? $value : null;
976    }
977
978    /**
979     * CSS Text 3 §5: `word-break: break-all` (and `overflow-wrap:
980     * anywhere`) allow line breaks between every two codepoints.
981     */
982    private function isBreakAll(Box $box): bool
983    {
984        $wb = $box->style->get('word-break');
985        if ($wb instanceof \Phpdftk\Css\Value\Keyword && strtolower($wb->name) === 'break-all') {
986            return true;
987        }
988        $ow = $box->style->get('overflow-wrap');
989        if ($ow instanceof \Phpdftk\Css\Value\Keyword && strtolower($ow->name) === 'anywhere') {
990            return true;
991        }
992        return false;
993    }
994
995    /**
996     * CSS Inline 3 §4.5 `vertical-align`: Phase-1 honours the `sub` and
997     * `super` keywords, lifting / lowering the fragment's baseline by a
998     * font-size-relative amount. Browser defaults: `super` ≈ +0.5em lift,
999     * `sub` ≈ +0.2em drop. Returns the offset in layout-Y space (negative
1000     * lifts, positive drops). All other values (baseline / Length /
1001     * Percentage / top / middle / bottom / text-top / text-bottom) fall
1002     * through to 0 for now — full vertical-align lands with mixed-size
1003     * inline runs.
1004     */
1005    private function resolveVerticalAlign(Box $box, float $fontSize): float
1006    {
1007        $value = $box->style->get('vertical-align');
1008        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
1009            return 0.0;
1010        }
1011        return match (strtolower($value->name)) {
1012            'super' => -$fontSize * 0.5,
1013            'sub' => $fontSize * 0.2,
1014            default => 0.0,
1015        };
1016    }
1017
1018    /**
1019     * Tokenise plain text at UAX #14 break opportunities, shaping each
1020     * resulting segment. Each segment is one token. Whitespace segments
1021     * are tagged so the line-fitter can collapse them at line edges. When
1022     * `$letterSpacing` is non-zero, every shaped glyph's advance is bumped
1023     * by that amount per CSS Text 3 §10 — the painter picks the difference
1024     * up automatically via its TJ-kerning path.
1025     *
1026     * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}>
1027     */
1028    private function tokeniseText(
1029        string $text,
1030        ShapingContext $shapingCtx,
1031        float $letterSpacing,
1032        float $wordSpacing,
1033        bool $breakAll = false,
1034    ): array {
1035        if ($text === '') {
1036            return [];
1037        }
1038        if ($breakAll) {
1039            // CSS Text 3 §5 `word-break: break-all` — every codepoint is a
1040            // valid break point. Walk UTF-8 codepoints and emit one
1041            // segment per character. Whitespace runs still collapse into
1042            // their own segment so word/letter-spacing logic stays sane.
1043            $segments = [];
1044            $bytes = strlen($text);
1045            $i = 0;
1046            while ($i < $bytes) {
1047                $b = ord($text[$i]);
1048                $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4));
1049                $segments[] = [
1050                    'text' => substr($text, $i, $cpLen),
1051                    'kind' => LineBreakKind::Allowed,
1052                ];
1053                $i += $cpLen;
1054            }
1055        } else {
1056            $breaks = iterator_to_array($this->lineBreaker->breakOpportunities($text), false);
1057            $segments = [];
1058            $start = 0;
1059            foreach ($breaks as $opp) {
1060                if ($opp->offset > $start) {
1061                    $segments[] = ['text' => substr($text, $start, $opp->offset - $start), 'kind' => $opp->kind];
1062                    $start = $opp->offset;
1063                }
1064            }
1065            if ($start < strlen($text)) {
1066                $segments[] = ['text' => substr($text, $start), 'kind' => LineBreakKind::Allowed];
1067            }
1068        }
1069
1070        $out = [];
1071        foreach ($segments as $seg) {
1072            $isWs = preg_match('/^[ \t\n\r\f]+$/', $seg['text']) === 1;
1073            $shaped = $this->shaper->shapeRun($seg['text'], $shapingCtx);
1074            if ($letterSpacing !== 0.0 && $shaped->glyphs !== []) {
1075                $shaped = $this->applyLetterSpacing($shaped, $letterSpacing);
1076            }
1077            if ($wordSpacing !== 0.0 && $shaped->glyphs !== []) {
1078                // CSS Text 3 §9: `word-spacing` adds advance only at word-
1079                // separator glyphs (U+0020 / U+00A0 at MVP).
1080                $shaped = $this->applyWordSpacing($shaped, $seg['text'], $wordSpacing);
1081            }
1082            $out[] = [
1083                'shapedRun' => $shaped,
1084                'isWhitespace' => $isWs,
1085                'kind' => $seg['kind'],
1086            ];
1087        }
1088        return $out;
1089    }
1090
1091    /**
1092     * Return a new `ShapedRun` with every glyph's `advanceX` bumped by
1093     * `$letterSpacing` and the `totalAdvance` summed accordingly.
1094     */
1095    private function applyLetterSpacing(ShapedRun $run, float $letterSpacing): ShapedRun
1096    {
1097        $glyphs = [];
1098        $total = 0.0;
1099        foreach ($run->glyphs as $g) {
1100            $newAdvance = $g->advanceX + $letterSpacing;
1101            $glyphs[] = new ShapedGlyph(
1102                $g->glyphId,
1103                $g->sourceOffset,
1104                $g->sourceLength,
1105                $newAdvance,
1106                $g->advanceY,
1107                $g->offsetX,
1108                $g->offsetY,
1109            );
1110            $total += $newAdvance;
1111        }
1112        return new ShapedRun(
1113            $run->font,
1114            $run->fontSizePt,
1115            $run->direction,
1116            $glyphs,
1117            $total,
1118        );
1119    }
1120
1121    /**
1122     * CSS Text 3 §10: `letter-spacing` keyword `normal` resolves to 0;
1123     * any `Length` (already in px after `Cascade::resolveLengths`) is the
1124     * extra advance applied to every glyph.
1125     */
1126    private function resolveLetterSpacing(Box $parent): float
1127    {
1128        $value = $parent->style->get('letter-spacing');
1129        if ($value instanceof Length) {
1130            return $value->value;
1131        }
1132        return 0.0;
1133    }
1134
1135    /**
1136     * CSS Text 3 §9: `word-spacing` adds advance only at word-separator
1137     * glyphs. `normal` → 0; any `Length` is the extra advance per separator.
1138     */
1139    private function resolveWordSpacing(Box $parent): float
1140    {
1141        $value = $parent->style->get('word-spacing');
1142        if ($value instanceof Length) {
1143            return $value->value;
1144        }
1145        return 0.0;
1146    }
1147
1148    /**
1149     * Bump the advance of every glyph whose source codepoint is a CSS
1150     * word separator (U+0020 SPACE or U+00A0 NO-BREAK SPACE). Builds and
1151     * returns a new `ShapedRun`.
1152     */
1153    private function applyWordSpacing(ShapedRun $run, string $sourceText, float $wordSpacing): ShapedRun
1154    {
1155        $glyphs = [];
1156        $total = 0.0;
1157        foreach ($run->glyphs as $g) {
1158            $bump = $this->isWordSeparatorAt($sourceText, $g->sourceOffset) ? $wordSpacing : 0.0;
1159            $newAdvance = $g->advanceX + $bump;
1160            $glyphs[] = new ShapedGlyph(
1161                $g->glyphId,
1162                $g->sourceOffset,
1163                $g->sourceLength,
1164                $newAdvance,
1165                $g->advanceY,
1166                $g->offsetX,
1167                $g->offsetY,
1168            );
1169            $total += $newAdvance;
1170        }
1171        return new ShapedRun(
1172            $run->font,
1173            $run->fontSizePt,
1174            $run->direction,
1175            $glyphs,
1176            $total,
1177        );
1178    }
1179
1180    private function isWordSeparatorAt(string $text, int $offset): bool
1181    {
1182        if ($offset < 0 || $offset >= strlen($text)) {
1183            return false;
1184        }
1185        $b = ord($text[$offset]);
1186        if ($b === 0x20) {
1187            return true;
1188        }
1189        // U+00A0 NO-BREAK SPACE → UTF-8 bytes 0xC2 0xA0.
1190        return $b === 0xC2 && ($offset + 1) < strlen($text) && ord($text[$offset + 1]) === 0xA0;
1191    }
1192
1193    /**
1194     * The dominant font-size for the inline run. Phase 1F.2 reads it from
1195     * the parent's cascaded `font-size`; mixed-size content is a Phase 2
1196     * follow-up alongside multi-font runs.
1197     */
1198    private function dominantFontSize(Box $parent, LayoutContext $context): float
1199    {
1200        $value = $parent->style->get('font-size');
1201        if ($value instanceof Length) {
1202            return $value->value;
1203        }
1204        return $context->lengthContext->currentFontSize;
1205    }
1206}