Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.84% covered (success)
91.84%
1148 / 1250
43.84% covered (danger)
43.84%
32 / 73
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockLayout
91.84% covered (success)
91.84%
1148 / 1250
43.84% covered (danger)
43.84%
32 / 73
638.92
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 layoutBox
87.88% covered (warning)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
8.11
 layoutTableRow
96.67% covered (success)
96.67%
58 / 60
0.00% covered (danger)
0.00%
0 / 1
17
 layoutBlock
99.31% covered (success)
99.31%
143 / 144
0.00% covered (danger)
0.00%
0 / 1
60
 floatSide
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 clearSide
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 layoutFloat
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 isOutOfFlow
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveAbsoluteOffsets
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 resolveRelativeOffsets
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 layoutFlexBox
89.16% covered (warning)
89.16%
222 / 249
0.00% covered (danger)
0.00%
0 / 1
95.43
 partitionFlexLines
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 sortFlexItemsByOrder
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 resolveFlexOrder
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveFlexBasis
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
13.04
 resolveFlexGrow
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveFlexShrink
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 flexKeyword
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 isBorderBoxSizing
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveExplicitHeightOrNull
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clampMinMax
50.00% covered (danger)
50.00%
8 / 16
0.00% covered (danger)
0.00%
0 / 1
34.12
 resolveLength
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 resolveBorderWidth
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 lengthContextFor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveAspectRatio
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 numericValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isAuto
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 shiftSubtree
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isMultiColumnContainer
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 layoutMultiColumn
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
7.02
 splitByColumnSpan
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 isColumnSpanAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 layoutColumnarRun
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
16.15
 stackChildren
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stackChildrenList
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
1 / 1
25
 resolveColumns
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
12.37
 resolveColumnGap
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 resolveFlexMainGap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 resolveFlexGapProperty
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 resolveColumnRuleWidth
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 resolveColumnRuleStyle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveColumnRuleColor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 allInlineLevel
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 layoutInlineChildren
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 avoidLineSplitsAcrossPages
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
13
 intStyle
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 ceilToPage
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 forcesPageBreakBefore
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 declaresNamedPage
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 forcesPageBreakAfter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 avoidsBreakInside
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 declaresBreakInsideAvoid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 declaresForcedBreak
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 forcesColumnBreakBefore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forcesColumnBreakAfter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 declaresForcedColumnBreak
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 collectColumnWidths
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
12.42
 applyColWidth
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 parseLegacyWidth
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveColumnWidthGrid
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
7.46
 precomputeTableCellGrid
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
9
 collectTableRows
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 maxColumnsFromGrid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveCellColumn
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 resolveRowIndex
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 resolveCellRowspan
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 finalizeRowspanHeights
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 reorderTableCaptions
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 isBorderCollapse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 collapseBorders
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 cellColspan
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 resolveInlineLengths
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7use Phpdftk\Css\Cascade\Cascade;
8use Phpdftk\Css\Cascade\CascadedValues;
9use Phpdftk\Css\Cascade\LengthContext;
10use Phpdftk\Css\Value\Keyword;
11use Phpdftk\Css\Value\Length;
12use Phpdftk\Css\Value\Percentage;
13use Phpdftk\HtmlToPdf\Box\Box;
14use Phpdftk\HtmlToPdf\Box\BlockBox;
15use Phpdftk\HtmlToPdf\Box\AnonymousBlockBox;
16use Phpdftk\HtmlToPdf\Box\AtomicInlineBox;
17use Phpdftk\HtmlToPdf\Box\InlineBox;
18use Phpdftk\HtmlToPdf\Box\TextBox;
19use Phpdftk\HtmlToPdf\Layout\MultiColumnLayout;
20
21/**
22 * Block formatting context layout â€” Phase 1F.1 (vertical block stacking).
23 *
24 * Walks the box tree depth-first and assigns each {@see BlockBox} a position
25 * + content-area dimensions. The containing block's width determines `auto`
26 * width resolution (`width = containingBlockWidth - margins - borders -
27 * padding`); height accumulates from children below.
28 *
29 * Implemented at this phase:
30 *  - Block-level boxes stacked vertically inside their containing block.
31 *  - `width`, `margin-*`, `padding-*`, `border-*-width` resolved from
32 *    the cascade (lengths and percentages).
33 *  - `auto` width fills the containing block (less margins / borders /
34 *    padding).
35 *  - `height: auto` sums the children's outer heights.
36 *
37 * **Not yet implemented (later sub-phases)**:
38 *  - Margin collapsing (CSS 2.1 Â§8.3.1) â€” adjacent sibling margins
39 *    currently sum rather than collapse.
40 *  - Float and absolute positioning.
41 *  - Inline / line-box layout â€” InlineBox / TextBox children get the
42 *    parent's content width but zero height for now; 1F.2 ships shaped
43 *    text with line-box construction.
44 *  - Table / flex / grid layout â€” handled by their own dedicated layouts.
45 */
46final class BlockLayout
47{
48    /**
49     * Active table's effective column count while laying out its rows.
50     * Set by `layoutBox`'s `TableBox` branch via `maxColumnsIn`, read by
51     * `layoutTableRow` so every row uses the same column grid. Null when
52     * no table is currently being laid out.
53     */
54    private ?int $currentTableColumns = null;
55
56    /**
57     * Per-column explicit widths declared via `<col>` / `<colgroup>`
58     * `width` attributes (HTML 5 Â§4.9.4). Each entry is the explicit
59     * pixel width for that column, or null when no `<col>` declared
60     * one â€” in which case the auto-distribution path fills it in.
61     *
62     * @var list<?float>|null
63     */
64    private ?array $currentColumnWidths = null;
65
66    /**
67     * Cell-occupancy grid for the active table â€” HTML 5 Â§4.9.11
68     * rowspan / colspan resolution. Keyed by `spl_object_id($cell)`;
69     * the value records the cell's resolved (row, col) plus declared
70     * (rowspan, colspan). Built once per TableBox by
71     * `precomputeTableCellGrid()`, consulted by `layoutTableRow()` so
72     * subsequent rows skip columns covered by prior rowspan cells.
73     *
74     * @var array<int, array{row: int, col: int, rowspan: int, colspan: int}>|null
75     */
76    private ?array $currentTableCellGrid = null;
77
78    /**
79     * Per-row height tally for the active table. Populated in
80     * `layoutTableRow` after each row's max-cell-height resolves, so
81     * the post-pass `finalizeRowspanHeights()` can sum heights for
82     * any cell that spans multiple rows.
83     *
84     * @var array<int, float>
85     */
86    private array $currentTableRowHeights = [];
87
88    public function __construct(
89        private readonly Cascade $cascade,
90        private readonly InlineLayout $inlineLayout = new InlineLayout(),
91    ) {}
92
93    /**
94     * Lay out `$root` at the origin from `$context`. Mutates the box tree
95     * in place; returns the root's accumulated outer height so callers can
96     * size the page box.
97     */
98    public function layout(Box $root, LayoutContext $context): float
99    {
100        // Ensure the root's style has lengths resolved against the context.
101        $this->cascade->resolveLengths($root->style, $context->lengthContext);
102        // Phase 1 simplification: a single FloatContext for the whole
103        // document tree (CSS 2.1 Â§9.5 floats stay inside their BFC;
104        // proper BFC scoping is a follow-up). Lazily attach when the
105        // caller didn't supply one so floats register against something.
106        if ($context->floatContext === null) {
107            $context = $context->withFloatContext(new FloatContext());
108        }
109        return $this->layoutBox($root, $context);
110    }
111
112    private function layoutBox(Box $box, LayoutContext $context): float
113    {
114        if ($box instanceof \Phpdftk\HtmlToPdf\Box\TableBox) {
115            // CSS 2.1 Â§17.4.1 â€” `caption-side: bottom` moves a
116            // `<caption>` child to render below the rows instead of
117            // above (the default). Reorder children once before the
118            // generic block stacker runs so layout sees the caption
119            // in the right slot.
120            $this->reorderTableCaptions($box);
121            // Pre-walk to find the table's max columns so every row uses
122            // the same column-width grid (CSS Tables 3 Â§4: columns are a
123            // table-level concept).
124            $prev = $this->currentTableColumns;
125            $prevWidths = $this->currentColumnWidths;
126            $prevGrid = $this->currentTableCellGrid;
127            $prevRowHeights = $this->currentTableRowHeights;
128            $prevCellRefs = $this->resolvedCellReferences;
129            $this->currentTableCellGrid = $this->precomputeTableCellGrid($box);
130            $this->currentTableRowHeights = [];
131            // The cell grid drives the effective column count â€” rows
132            // with rowspan-pulled-from-prior-row cells contribute to
133            // the grid's max width.
134            $this->currentTableColumns = max(1, $this->maxColumnsFromGrid($this->currentTableCellGrid));
135            // HTML 5 Â§4.9.4 â€” explicit column widths from `<col>` /
136            // `<colgroup width="N">`. `null` entries fall through to
137            // the auto-distribution path in `layoutTableRow`.
138            $this->currentColumnWidths = $this->collectColumnWidths($box, $this->currentTableColumns);
139            try {
140                $height = $this->layoutBlock($box, $context);
141                // CSS Tables 3 Â§11.1 â€” extend rowspan cells to cover
142                // every row they span. Done after rows are positioned
143                // so we know each row's height.
144                $this->finalizeRowspanHeights();
145                // CSS Tables 3 Â§11.2 `border-collapse: collapse` â€” Phase-1
146                // simplification: suppress every cell's right + bottom
147                // border edges except the last column / last row, so
148                // adjacent cells share their borders instead of doubling.
149                if ($this->isBorderCollapse($box)) {
150                    $this->collapseBorders($box);
151                }
152                return $height;
153            } finally {
154                $this->currentTableColumns = $prev;
155                $this->currentColumnWidths = $prevWidths;
156                $this->currentTableCellGrid = $prevGrid;
157                $this->currentTableRowHeights = $prevRowHeights;
158                $this->resolvedCellReferences = $prevCellRefs;
159            }
160        }
161        if ($box instanceof \Phpdftk\HtmlToPdf\Box\TableRowBox) {
162            return $this->layoutTableRow($box, $context);
163        }
164        if ($box instanceof \Phpdftk\HtmlToPdf\Box\FlexBox) {
165            return $this->layoutFlexBox($box, $context);
166        }
167        if ($box instanceof BlockBox
168            || $box instanceof AnonymousBlockBox
169            || $box instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox
170        ) {
171            return $this->layoutBlock($box, $context);
172        }
173        // Inline / atomic-inline children of a block context â€” for 1F.1
174        // their content height is treated as zero. 1F.2 will replace this
175        // with proper line-box construction once Shaper is integrated.
176        $box->geometry->x = $context->originX;
177        $box->geometry->y = $context->originY;
178        $box->geometry->width = $context->containingBlockWidth;
179        return 0.0;
180    }
181
182    /**
183     * Phase-1 table-row layout: position each TableCellBox child
184     * horizontally, sharing the parent's width equally. Row height is
185     * `max(cell.outerHeight)`. CSS Tables 3 automatic column-width
186     * algorithm (with min/max content widths) lands in a follow-up.
187     */
188    private function layoutTableRow(\Phpdftk\HtmlToPdf\Box\TableRowBox $row, LayoutContext $context): float
189    {
190        $geo = $row->geometry;
191        $geo->x = $context->originX;
192        $geo->y = $context->originY;
193        $geo->width = $context->containingBlockWidth;
194
195        $cells = array_values(array_filter(
196            $row->children,
197            static fn($c): bool => $c instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox,
198        ));
199        if ($cells === []) {
200            $geo->height = 0.0;
201            return 0.0;
202        }
203        // HTML 5 `<td colspan="N">` â€” each cell may span N columns. The
204        // total-column count for `colWidth` comes from the enclosing
205        // `<table>` (computed in `layoutBox` via `maxColumnsIn`) so all
206        // rows align on the same column grid. Falls back to the row's
207        // own colspan-sum when no table context is active (test fixtures
208        // that lay out a row in isolation).
209        $colspans = array_map(fn($c) => $this->cellColspan($c), $cells);
210        $totalColumns = $this->currentTableColumns ?? max(1, array_sum($colspans));
211        $totalColumns = max(1, $totalColumns);
212        // HTML 5 Â§4.9.4 â€” honour explicit `<col width>` declarations.
213        // Build a per-column width array: explicit widths from the col
214        // declarations are honoured directly; remaining slack divides
215        // evenly across the auto-width columns.
216        $columnWidths = $this->resolveColumnWidthGrid(
217            $totalColumns,
218            $geo->width,
219            $this->currentColumnWidths,
220        );
221        // Precomputed running prefix-sum so each cell's X = left edge +
222        // sum of widths of preceding columns.
223        $colOffsets = [0.0];
224        foreach ($columnWidths as $w) {
225            $colOffsets[] = $colOffsets[count($colOffsets) - 1] + $w;
226        }
227        $maxHeight = 0.0;
228        $rowIndex = $this->resolveRowIndex($row);
229        $cellCursorFallback = 0; // when no precomputed grid is present
230        foreach ($cells as $i => $cell) {
231            $span = $colspans[$i];
232            $col = $this->resolveCellColumn($cell, $cellCursorFallback);
233            $cellCursorFallback = max($cellCursorFallback, $col + $span);
234            $cellX = $geo->x + ($colOffsets[$col] ?? 0.0);
235            $cellWidth = 0.0;
236            for ($k = 0; $k < $span && $col + $k < $totalColumns; $k++) {
237                $cellWidth += $columnWidths[$col + $k];
238            }
239            $cellCtx = $context
240                ->withContainingBlock($cellWidth, $context->containingBlockHeight)
241                ->withOrigin($cellX, $geo->y);
242            // Resolve cell-level CSS lengths against the cell's containing
243            // block before recursing (mirrors `layoutBlock`'s pre-pass).
244            $this->cascade->resolveLengths($cell->style, $cellCtx->lengthContext);
245            $h = $this->layoutBlock($cell, $cellCtx);
246            // Cells that span multiple rows don't contribute their full
247            // height to *this* row's max â€” the rowspan post-pass extends
248            // them across the spanned rows after every row has resolved.
249            $rowspan = $this->resolveCellRowspan($cell);
250            if ($rowspan <= 1 && $h > $maxHeight) {
251                $maxHeight = $h;
252            }
253        }
254        $geo->height = $maxHeight;
255        if ($rowIndex >= 0) {
256            $this->currentTableRowHeights[$rowIndex] = $maxHeight;
257        }
258        // Cells smaller than the row get stretched to the row height so
259        // borders / backgrounds align flush at the bottom edge â€” CSS 2.1
260        // Â§17.5.3 "cell percentage" simplification. CSS Tables 3 Â§6.2
261        // `vertical-align: middle | bottom` shifts the cell's children
262        // down by half / all of the slack so the content sits centred /
263        // at the bottom of the row.
264        foreach ($cells as $cell) {
265            $contentHeight = $cell->geometry->height;
266            $slack = $maxHeight - $contentHeight;
267            if ($slack > 0.0) {
268                $valign = $cell->style->get('vertical-align');
269                $shift = 0.0;
270                if ($valign instanceof \Phpdftk\Css\Value\Keyword) {
271                    $shift = match (strtolower($valign->name)) {
272                        'middle' => $slack / 2.0,
273                        'bottom', 'baseline' => $slack,
274                        default => 0.0,
275                    };
276                }
277                if ($shift > 0.0) {
278                    foreach ($cell->children as $child) {
279                        $this->shiftSubtree($child, $shift);
280                    }
281                }
282                $cell->geometry->height = $maxHeight;
283            }
284        }
285        return $maxHeight;
286    }
287
288    private function layoutBlock(Box $box, LayoutContext $context): float
289    {
290        $style = $box->style;
291        $cbWidth = $context->containingBlockWidth;
292        $geo = $box->geometry;
293
294        // Resolve margin / padding / border edges from the cascade. Margin
295        // is allowed to be `auto`; we record that separately and resolve to
296        // 0 here, then redistribute slack into auto sides after width is
297        // computed (CSS 2.1 Â§10.3.3).
298        $marginTopValue = $style->get('margin-top');
299        $marginRightValue = $style->get('margin-right');
300        $marginBottomValue = $style->get('margin-bottom');
301        $marginLeftValue = $style->get('margin-left');
302        $marginLeftAuto = $this->isAuto($marginLeftValue);
303        $marginRightAuto = $this->isAuto($marginRightValue);
304        $geo->marginTop = $this->isAuto($marginTopValue) ? 0.0 : $this->resolveLength($marginTopValue, $cbWidth);
305        $geo->marginRight = $marginRightAuto ? 0.0 : $this->resolveLength($marginRightValue, $cbWidth);
306        $geo->marginBottom = $this->isAuto($marginBottomValue) ? 0.0 : $this->resolveLength($marginBottomValue, $cbWidth);
307        $geo->marginLeft = $marginLeftAuto ? 0.0 : $this->resolveLength($marginLeftValue, $cbWidth);
308        $geo->paddingTop = $this->resolveLength($style->get('padding-top'), $cbWidth);
309        $geo->paddingRight = $this->resolveLength($style->get('padding-right'), $cbWidth);
310        $geo->paddingBottom = $this->resolveLength($style->get('padding-bottom'), $cbWidth);
311        $geo->paddingLeft = $this->resolveLength($style->get('padding-left'), $cbWidth);
312        $geo->borderTop = $this->resolveBorderWidth($style, 'top');
313        $geo->borderRight = $this->resolveBorderWidth($style, 'right');
314        $geo->borderBottom = $this->resolveBorderWidth($style, 'bottom');
315        $geo->borderLeft = $this->resolveBorderWidth($style, 'left');
316
317        // Resolve content width: `auto` fills the containing block minus
318        // margins / borders / padding. CSS Sizing 3 Â§6.2: with
319        // `box-sizing: border-box`, the declared `width` includes
320        // border + padding, so subtract them to get the content width.
321        $widthValue = $style->get('width');
322        $widthAuto = $this->isAuto($widthValue);
323        $borderBox = $this->isBorderBoxSizing($style);
324        if ($widthAuto) {
325            $contentWidth = max(
326                0.0,
327                $cbWidth - $geo->marginLeft - $geo->marginRight
328                    - $geo->borderLeft - $geo->borderRight
329                    - $geo->paddingLeft - $geo->paddingRight,
330            );
331        } else {
332            $contentWidth = $this->resolveLength($widthValue, $cbWidth);
333            if ($borderBox) {
334                $contentWidth = max(
335                    0.0,
336                    $contentWidth
337                        - $geo->borderLeft - $geo->borderRight
338                        - $geo->paddingLeft - $geo->paddingRight,
339                );
340            }
341        }
342        // CSS 2.1 Â§10.4 â€” clamp to [min-width, max-width]. min-width wins
343        // when min > max (so `min: 100px; max: 50px` resolves to 100px).
344        // `max-width: none` keyword leaves the upper bound unbounded;
345        // numeric `auto` on min-width resolves to 0. Under
346        // `box-sizing: border-box` the min/max values include border +
347        // padding too, so we subtract them to compare against the
348        // content-box width.
349        $horizontalInset = $borderBox
350            ? $geo->borderLeft + $geo->borderRight + $geo->paddingLeft + $geo->paddingRight
351            : 0.0;
352        $maxWidthValue = $style->get('max-width');
353        if (!($maxWidthValue instanceof Keyword && strtolower($maxWidthValue->name) === 'none')) {
354            $maxWidth = max(0.0, $this->resolveLength($maxWidthValue, $cbWidth) - $horizontalInset);
355            if ($maxWidth > 0.0 && $contentWidth > $maxWidth) {
356                $contentWidth = $maxWidth;
357                // Width fell out of `auto` territory â€” treat it like an
358                // explicit length from here on so auto-margin slack
359                // distribution kicks in below.
360                $widthAuto = false;
361            }
362        }
363        $minWidthValue = $style->get('min-width');
364        $minWidth = max(0.0, $this->resolveLength($minWidthValue, $cbWidth) - $horizontalInset);
365        if ($minWidth > 0.0 && $contentWidth < $minWidth) {
366            $contentWidth = $minWidth;
367            $widthAuto = false;
368        }
369        $geo->width = $contentWidth;
370
371        // CSS 2.1 Â§10.3.3 â€” `auto` margin redistribution. Only applies when
372        // width is an explicit length (auto width already greedily fills
373        // the available space). The remaining slack is split between the
374        // auto-margin sides; `margin: 0 auto` centers a fixed-width box.
375        if (!$widthAuto && ($marginLeftAuto || $marginRightAuto)) {
376            $slack = $cbWidth
377                - $geo->width
378                - $geo->borderLeft - $geo->borderRight
379                - $geo->paddingLeft - $geo->paddingRight;
380            $slack -= ($marginLeftAuto ? 0.0 : $geo->marginLeft);
381            $slack -= ($marginRightAuto ? 0.0 : $geo->marginRight);
382            if ($slack > 0.0) {
383                if ($marginLeftAuto && $marginRightAuto) {
384                    $geo->marginLeft = $slack / 2.0;
385                    $geo->marginRight = $slack / 2.0;
386                } elseif ($marginLeftAuto) {
387                    $geo->marginLeft = $slack;
388                } else {
389                    $geo->marginRight = $slack;
390                }
391            }
392        }
393
394        // Place this box: top-left of content area is at originX + left
395        // margin/border/padding, originY + top margin/border/padding.
396        $geo->x = $context->originX + $geo->marginLeft + $geo->borderLeft + $geo->paddingLeft;
397        $geo->y = $context->originY + $geo->marginTop + $geo->borderTop + $geo->paddingTop;
398
399        // Lay out children. If all children are inline-level, the box hosts
400        // an inline formatting context â€” defer to InlineLayout for line-box
401        // construction. Mixed children get the anonymous-block treatment
402        // from BoxGenerator, so we only see homogeneous block-or-inline
403        // child sets at this point.
404        $childContext = $context
405            ->withContainingBlock($geo->width, $context->containingBlockHeight)
406            ->withOrigin($geo->x, $geo->y)
407            ->withLengthContext($this->lengthContextFor($style, $context->lengthContext));
408        $childTotal = 0.0;
409        if ($box->children !== [] && $this->isMultiColumnContainer($box)) {
410            $childTotal = $this->layoutMultiColumn($box, $childContext);
411        } elseif ($box->children !== [] && $this->allInlineLevel($box->children)) {
412            $childTotal = $this->layoutInlineChildren($box, $childContext);
413        } else {
414            // Defer to the shared list-iterator so out-of-flow children
415            // (`position: absolute` / `fixed`), page-break logic, margin
416            // collapse, and break-inside avoidance all run through one
417            // codepath â€” same one `layoutMultiColumn` reuses per segment.
418            $childTotal = $this->stackChildrenList($box->children, $childContext, $geo->x, $geo->y);
419        }
420
421        // Parent-child margin collapsing (CSS 2.1 Â§8.3.1).
422        //
423        // Top: if the parent has no top border, no top padding, and the
424        // first in-flow child is a block, the child's top margin collapses
425        // through into the parent. The parent's effective margin-top
426        // becomes `max(parent.marginTop, firstChild.marginTop)`, and the
427        // child's content shifts up to sit at the parent's content edge.
428        //
429        // Bottom: symmetric, but only when the parent's height is auto
430        // (otherwise the parent's bottom margin is fixed at its border
431        // edge regardless of the child).
432        // Skip parent-child collapse-through at the document root: there's
433        // no ancestor above `<html>` to absorb the propagated margin, so
434        // the standard `shiftSubtree(child, -childTopMargin)` would push
435        // the body's content above the page top (e.g. a `<p>` with
436        // margin-top: 16px ends up at y=-16). Browsers absorb the root's
437        // marginTop into the viewport initial containing block; we just
438        // drop it on the floor here so the first child stays on-page.
439        $isRoot = $box->element !== null
440            && strtolower($box->element->localName) === 'html';
441        if (!$isRoot
442            && $box->children !== []
443            && $geo->paddingTop === 0.0
444            && $geo->borderTop === 0.0
445        ) {
446            $first = $box->children[0];
447            if ($first instanceof BlockBox && $first->geometry->marginTop > 0.0) {
448                $childTopMargin = $first->geometry->marginTop;
449                $this->shiftSubtree($first, -$childTopMargin);
450                // Cascade the shift across all siblings so spacing between
451                // siblings remains unchanged.
452                for ($i = 1, $n = count($box->children); $i < $n; $i++) {
453                    $this->shiftSubtree($box->children[$i], -$childTopMargin);
454                }
455                $childTotal -= $childTopMargin;
456                $extra = max(0.0, $childTopMargin - $geo->marginTop);
457                if ($extra > 0.0) {
458                    $geo->marginTop += $extra;
459                    $geo->y -= 0.0; // content area already at the right spot
460                }
461                $first->geometry->marginTop = 0.0;
462            }
463        }
464
465        // Resolve content height: explicit, percentage of containing
466        // block, or auto = children. With `box-sizing: border-box`,
467        // the declared height includes border + padding, so subtract
468        // them to get the content height.
469        $heightValue = $style->get('height');
470        $heightIsAuto = $this->isAuto($heightValue);
471        if ($heightIsAuto) {
472            $geo->height = $childTotal;
473        } else {
474            $geo->height = $this->resolveLength($heightValue, $context->containingBlockHeight);
475            if ($borderBox) {
476                $geo->height = max(
477                    0.0,
478                    $geo->height
479                        - $geo->borderTop - $geo->borderBottom
480                        - $geo->paddingTop - $geo->paddingBottom,
481                );
482            }
483        }
484        // CSS Sizing 4 Â§4.2 â€” `aspect-ratio` constrains height (or
485        // width) when the other dimension is determined. Phase-1:
486        // when height was auto AND a numeric ratio is set, override
487        // the children-derived height with `width / ratio`. This
488        // covers the common case (image / video wrapper sized by
489        // explicit width with the ratio dictating the height).
490        $ratio = $this->resolveAspectRatio($style);
491        if ($ratio !== null && $heightIsAuto && $geo->width > 0.0 && $ratio > 0.0) {
492            $geo->height = $geo->width / $ratio;
493        }
494        // CSS 2.1 Â§10.7 â€” clamp to [min-height, max-height]. Symmetric
495        // with the width clamps above; `max-height: none` leaves the
496        // upper bound unbounded. border-box-sized min/max include
497        // padding + border, so subtract them off to compare against
498        // the content-box height.
499        $verticalInset = $borderBox
500            ? $geo->borderTop + $geo->borderBottom + $geo->paddingTop + $geo->paddingBottom
501            : 0.0;
502        $maxHeightValue = $style->get('max-height');
503        if (!($maxHeightValue instanceof Keyword && strtolower($maxHeightValue->name) === 'none')) {
504            $maxHeight = max(0.0, $this->resolveLength($maxHeightValue, $context->containingBlockHeight) - $verticalInset);
505            if ($maxHeight > 0.0 && $geo->height > $maxHeight) {
506                $geo->height = $maxHeight;
507            }
508        }
509        $minHeight = max(0.0, $this->resolveLength($style->get('min-height'), $context->containingBlockHeight) - $verticalInset);
510        if ($minHeight > 0.0 && $geo->height < $minHeight) {
511            $geo->height = $minHeight;
512        }
513
514        if ($heightIsAuto
515            && $box->children !== []
516            && $geo->paddingBottom === 0.0
517            && $geo->borderBottom === 0.0
518        ) {
519            $last = $box->children[count($box->children) - 1];
520            if ($last instanceof BlockBox && $last->geometry->marginBottom > 0.0) {
521                $childBottomMargin = $last->geometry->marginBottom;
522                $extra = max(0.0, $childBottomMargin - $geo->marginBottom);
523                if ($extra > 0.0) {
524                    $geo->marginBottom += $extra;
525                }
526                $last->geometry->marginBottom = 0.0;
527                $geo->height -= $childBottomMargin;
528            }
529        }
530
531        // CSS 2.1 Â§9.4.3 â€” `position: relative`. The box and its
532        // descendants paint at their original layout position plus the
533        // resolved offsets; siblings continue to flow against the
534        // original position (this function returns `outerHeight()` from
535        // the pre-shift geometry, which is what stackChildren uses to
536        // advance its cursor â€” so siblings stay put).
537        $positionValue = $style->get('position');
538        if ($positionValue instanceof Keyword && strtolower($positionValue->name) === 'relative') {
539            $relativeOuterHeight = $geo->outerHeight();
540            [$dx, $dy] = $this->resolveRelativeOffsets($style, $context);
541            if ($dx !== 0.0 || $dy !== 0.0) {
542                $this->shiftSubtree($box, $dy, $dx);
543            }
544            return $relativeOuterHeight;
545        }
546
547        return $geo->outerHeight();
548    }
549
550    /**
551     * CSS 2.1 Â§9.4.3 â€” resolve `top` / `right` / `bottom` / `left` to
552     * the (dx, dy) shift for a relative-positioned box.
553     *
554     *  - If both `top` and `bottom` are set, `top` wins (positive dy
555     *    moves the box down).
556     *  - If both `left` and `right` are set, `left` wins (positive dx
557     *    moves the box right).
558     *  - `right`/`bottom` produce a negative shift (move up / left).
559     *  - Percentages resolve against the containing block's width
560     *    (horizontal axis) or height (vertical axis).
561     *
562     * @return array{0:float, 1:float} `[dx, dy]`
563     */
564    /**
565     * Return `'left'` / `'right'` per CSS 2.1 Â§9.5 `float`, or `null`
566     * when the box is not floated.
567     */
568    private function floatSide(Box $box): ?string
569    {
570        $value = $box->style->get('float');
571        if (!($value instanceof Keyword)) {
572            return null;
573        }
574        $lower = strtolower($value->name);
575        return $lower === 'left' || $lower === 'right' ? $lower : null;
576    }
577
578    /**
579     * Return `'left'` / `'right'` / `'both'` per CSS 2.1 Â§9.5.2 `clear`,
580     * or `null` for `none` / `inherit` / unrecognised.
581     */
582    private function clearSide(Box $box): ?string
583    {
584        $value = $box->style->get('clear');
585        if (!($value instanceof Keyword)) {
586            return null;
587        }
588        $lower = strtolower($value->name);
589        return in_array($lower, ['left', 'right', 'both'], true) ? $lower : null;
590    }
591
592    /**
593     * Lay out a float child, register it in the containing block's
594     * FloatContext, and leave the parent's cursor untouched. The float
595     * gets its content width from the cascade (auto width becomes
596     * shrink-to-fit by reading the cascaded `width` value; Phase-1
597     * defaults to the full containing-block width when `width: auto`,
598     * which doesn't match the spec's shrink-to-fit but matches the
599     * common author pattern of `<img width=...>` or explicit width).
600     */
601    private function layoutFloat(
602        Box $child,
603        string $side,
604        LayoutContext $childContext,
605        float $originX,
606        float $cursorY,
607    ): void {
608        // Lay the float out in a virtual slot at `originX, cursorY` so
609        // its inner geometry resolves (width / margins / borders /
610        // padding / child block heights).
611        $floatCtx = $childContext->floatContext;
612        $virtual = $childContext->withOrigin($originX, $cursorY);
613        $this->layoutBox($child, $virtual);
614
615        // If no FloatContext exists yet, lazily attach one to the
616        // childContext so subsequent siblings see this float.
617        if ($floatCtx === null) {
618            // Caller's $childContext is readonly; the lazy creation
619            // happens at `layoutBlock` level â€” see `establishFloatContext`.
620            // Fallback: skip registration when no context is established.
621            return;
622        }
623
624        $cbLeft = $originX;
625        $cbRight = $originX + $childContext->containingBlockWidth;
626        $floatWidth = $child->geometry->outerWidth();
627        $floatHeight = $child->geometry->outerHeight();
628        $placement = $side === 'left'
629            ? $floatCtx->placeLeft($cursorY, $cbLeft, $cbRight, $floatWidth)
630            : $floatCtx->placeRight($cursorY, $cbLeft, $cbRight, $floatWidth);
631        $targetX = $placement['x'];
632        $targetY = $placement['y'];
633        // Shift the float's whole subtree from its virtual position
634        // (originX, cursorY) to its registered position.
635        $dx = $targetX - ($virtual->originX);
636        $dy = $targetY - $cursorY;
637        if ($dx !== 0.0 || $dy !== 0.0) {
638            $this->shiftSubtree($child, $dy, $dx);
639        }
640        if ($side === 'left') {
641            $floatCtx->addLeft($targetX, $targetY, $floatWidth, $floatHeight);
642        } else {
643            $floatCtx->addRight($targetX, $targetY, $floatWidth, $floatHeight);
644        }
645    }
646
647    /**
648     * `position: absolute` or `fixed` removes a box from normal flow.
649     * `fixed` is treated the same as `absolute` in the print context â€”
650     * there's no scrolling viewport so the difference vanishes.
651     */
652    private function isOutOfFlow(Box $box): bool
653    {
654        $value = $box->style->get('position');
655        if (!($value instanceof Keyword)) {
656            return false;
657        }
658        $lower = strtolower($value->name);
659        return $lower === 'absolute' || $lower === 'fixed';
660    }
661
662    /**
663     * Resolve the (dx, dy) shift needed to move a freshly-laid-out
664     * absolutely-positioned `$child` from its in-flow `cursorY` position
665     * to its absolute target position per CSS 2.1 Â§9.6.
666     *
667     *  - Phase-1: containing block = the immediate parent's content box
668     *    (proper spec: nearest positioned ancestor's padding box). For
669     *    most author usage this matches because positioned ancestors
670     *    are common.
671     *  - `top` wins over `bottom`; `left` wins over `right`.
672     *  - With both opposing sides `auto` (the default), the box keeps
673     *    its static in-flow position â€” no shift.
674     *  - `right` / `bottom` resolve to "containing block edge âˆ’ offset
675     *    âˆ’ box outer width / height" so the corresponding margin edge
676     *    sits the given distance from the right / bottom edge.
677     *
678     * @return array{0:float, 1:float} `[dx, dy]`
679     */
680    private function resolveAbsoluteOffsets(
681        Box $child,
682        LayoutContext $childContext,
683        float $originX,
684        float $originY,
685        float $cursorY,
686    ): array {
687        $style = $child->style;
688        $cbWidth = $childContext->containingBlockWidth;
689        $cbHeight = $childContext->containingBlockHeight;
690        $top = $style->get('top');
691        $bottom = $style->get('bottom');
692        $left = $style->get('left');
693        $right = $style->get('right');
694
695        $dy = 0.0;
696        if (!$this->isAuto($top)) {
697            $topOffset = $this->resolveLength($top, $cbHeight);
698            $dy = $originY + $topOffset - $cursorY;
699        } elseif (!$this->isAuto($bottom)) {
700            $bottomOffset = $this->resolveLength($bottom, $cbHeight);
701            $dy = $originY + $cbHeight - $bottomOffset
702                - $cursorY - $child->geometry->outerHeight();
703        }
704
705        $dx = 0.0;
706        if (!$this->isAuto($left)) {
707            $dx = $this->resolveLength($left, $cbWidth);
708        } elseif (!$this->isAuto($right)) {
709            $rightOffset = $this->resolveLength($right, $cbWidth);
710            $dx = $cbWidth - $rightOffset - $child->geometry->outerWidth();
711        }
712        return [$dx, $dy];
713    }
714
715    /** @return array{0:float, 1:float} `[dx, dy]` */
716    private function resolveRelativeOffsets(CascadedValues $style, LayoutContext $context): array
717    {
718        $cbW = $context->containingBlockWidth;
719        $cbH = $context->containingBlockHeight;
720        $top = $style->get('top');
721        $bottom = $style->get('bottom');
722        $left = $style->get('left');
723        $right = $style->get('right');
724        $dy = 0.0;
725        if (!$this->isAuto($top)) {
726            $dy = $this->resolveLength($top, $cbH);
727        } elseif (!$this->isAuto($bottom)) {
728            $dy = -$this->resolveLength($bottom, $cbH);
729        }
730        $dx = 0.0;
731        if (!$this->isAuto($left)) {
732            $dx = $this->resolveLength($left, $cbW);
733        } elseif (!$this->isAuto($right)) {
734            $dx = -$this->resolveLength($right, $cbW);
735        }
736        return [$dx, $dy];
737    }
738
739    /**
740     * Lay out a `display: flex` container per CSS Flexible Box
741     * Layout 1. Phase-1 subset:
742     *
743     *  - `flex-direction: row` only (column / reverse â†’ Phase 2).
744     *  - Single-line only (`flex-wrap: nowrap`; wrap â†’ Phase 2).
745     *  - Items keep their declared `width` (no `flex-grow` /
746     *    `flex-shrink` slack distribution yet).
747     *  - `justify-content`: `flex-start` (default), `flex-end`,
748     *    `center`, `space-between`, `space-around`, `space-evenly`.
749     *  - `align-items`: `stretch` (default), `flex-start`,
750     *    `flex-end`, `center`.
751     *  - `column-gap` (via the existing `column-gap` longhand)
752     *    inserts gaps between items.
753     *
754     * Returns the container's outer height.
755     */
756    private function layoutFlexBox(\Phpdftk\HtmlToPdf\Box\FlexBox $box, LayoutContext $context): float
757    {
758        $style = $box->style;
759        $cbWidth = $context->containingBlockWidth;
760        $cbHeight = $context->containingBlockHeight;
761        $geo = $box->geometry;
762
763        // Resolve container's margins / padding / border / width
764        // exactly the same way layoutBlock does (we don't need the
765        // auto-margin centring math because flex containers
766        // typically have explicit dimensions).
767        $geo->marginTop = $this->resolveLength($style->get('margin-top'), $cbWidth);
768        $geo->marginRight = $this->resolveLength($style->get('margin-right'), $cbWidth);
769        $geo->marginBottom = $this->resolveLength($style->get('margin-bottom'), $cbWidth);
770        $geo->marginLeft = $this->resolveLength($style->get('margin-left'), $cbWidth);
771        $geo->paddingTop = $this->resolveLength($style->get('padding-top'), $cbWidth);
772        $geo->paddingRight = $this->resolveLength($style->get('padding-right'), $cbWidth);
773        $geo->paddingBottom = $this->resolveLength($style->get('padding-bottom'), $cbWidth);
774        $geo->paddingLeft = $this->resolveLength($style->get('padding-left'), $cbWidth);
775        $geo->borderTop = $this->resolveBorderWidth($style, 'top');
776        $geo->borderRight = $this->resolveBorderWidth($style, 'right');
777        $geo->borderBottom = $this->resolveBorderWidth($style, 'bottom');
778        $geo->borderLeft = $this->resolveBorderWidth($style, 'left');
779
780        $widthValue = $style->get('width');
781        $widthAuto = $this->isAuto($widthValue);
782        $geo->width = $widthAuto
783            ? max(
784                0.0,
785                $cbWidth - $geo->marginLeft - $geo->marginRight
786                    - $geo->borderLeft - $geo->borderRight
787                    - $geo->paddingLeft - $geo->paddingRight,
788            )
789            : $this->resolveLength($widthValue, $cbWidth);
790
791        $geo->x = $context->originX + $geo->marginLeft + $geo->borderLeft + $geo->paddingLeft;
792        $geo->y = $context->originY + $geo->marginTop + $geo->borderTop + $geo->paddingTop;
793
794        // CSS Flexbox 1 Â§5.1: `flex-direction` picks the main axis.
795        // `row` / `row-reverse` use the inline axis (x); `column` /
796        // `column-reverse` use the block axis (y). The `*-reverse`
797        // variants flip main-start â†” main-end.
798        $direction = $this->flexKeyword($style, 'flex-direction', 'row');
799        $isColumn = $direction === 'column' || $direction === 'column-reverse';
800        $reverseDirection = $direction === 'row-reverse' || $direction === 'column-reverse';
801
802        $declaredHeight = $this->resolveExplicitHeightOrNull($style, $cbHeight);
803
804        $children = $box->children;
805        if ($children === []) {
806            $geo->height = $declaredHeight ?? 0.0;
807            return $geo->outerHeight();
808        }
809
810        // Container main / cross box dimensions. `$crossDefinite`
811        // tracks whether the cross size is author-given (so a single
812        // line can grow to match per CSS Flexbox 1 Â§9.6).
813        $containerMain = $isColumn ? ($declaredHeight ?? -1.0) : $geo->width;
814        $containerCross = $isColumn ? $geo->width : ($declaredHeight ?? -1.0);
815        $crossDefinite = $isColumn ? true : ($declaredHeight !== null);
816
817        // CSS Flexbox 1 Â§5.4: `order` reorders items for layout. Sort
818        // is stable on document order for equal `order` values. After
819        // the sort, `*-reverse` reverses the array so items appear in
820        // reverse layout order; the justify-content swap below mirrors
821        // `flex-start` â†” `flex-end` so packing at main-start still
822        // hugs the (now end) edge.
823        $children = $this->sortFlexItemsByOrder($children);
824        if ($reverseDirection) {
825            $children = array_reverse($children);
826        }
827
828        // First pass: lay each item out at the container's origin
829        // with its declared (or content-derived) size. We use the
830        // existing layoutBlock so block-style sizing (margins /
831        // padding / borders) works inside items.
832        $itemCtx = $context
833            ->withContainingBlock($geo->width, $cbHeight)
834            ->withOrigin($geo->x, $geo->y)
835            ->withLengthContext($this->lengthContextFor($style, $context->lengthContext));
836        $itemMains = [];
837        $itemCrosses = [];
838        $basisCbMain = $isColumn ? $cbHeight : $geo->width;
839        foreach ($children as $child) {
840            $this->cascade->resolveLengths($child->style, $itemCtx->lengthContext);
841            $this->layoutBox($child, $itemCtx);
842            // CSS Flexbox 1 Â§7.2: `flex-basis` overrides the item's
843            // hypothetical main size (width for row, height for
844            // column). `auto` / `content` keep the layoutBox value;
845            // explicit lengths / percentages / unitless 0 replace it.
846            $basis = $this->resolveFlexBasis($child->style, $basisCbMain);
847            if ($basis !== null) {
848                if ($isColumn) {
849                    $child->geometry->height = $basis;
850                } else {
851                    $child->geometry->width = $basis;
852                }
853            }
854            $itemMains[] = $isColumn ? $child->geometry->outerHeight() : $child->geometry->outerWidth();
855            $itemCrosses[] = $isColumn ? $child->geometry->outerWidth() : $child->geometry->outerHeight();
856        }
857
858        // CSS Box Alignment 3 Â§8.1: `normal` resolves to `0px` for
859        // flex layout. The main-axis gap reads `column-gap` for row
860        // direction, `row-gap` for column direction; the cross-axis
861        // gap (between flex lines under `flex-wrap: wrap`) reads the
862        // opposite.
863        $gap = $this->resolveFlexMainGap($style, $itemCtx->lengthContext, $isColumn);
864        $crossGap = $this->resolveFlexGapProperty($style, $isColumn ? 'column-gap' : 'row-gap');
865
866        // CSS Flexbox 1 Â§6.3: `flex-wrap` controls multi-line flow.
867        // `nowrap` (default) keeps everything on one line. `wrap` and
868        // `wrap-reverse` partition into multiple lines when items
869        // would overflow. Per Â§9.3 step 5, wrap requires a definite
870        // main size â€” auto main (column with no declared height)
871        // falls back to single-line.
872        $wrap = $this->flexKeyword($style, 'flex-wrap', 'nowrap');
873        $canWrap = ($wrap === 'wrap' || $wrap === 'wrap-reverse') && $containerMain >= 0.0;
874
875        if ($canWrap) {
876            $lines = $this->partitionFlexLines($itemMains, $gap, $containerMain);
877        } else {
878            $lines = [array_keys($itemMains)];
879        }
880
881        // If the container's main size is still auto (single-line
882        // column with no declared height), shrink-to-fit around items.
883        if ($containerMain < 0.0) {
884            $singleLineMain = array_sum($itemMains) + $gap * max(0, count($children) - 1);
885            $containerMain = $singleLineMain;
886        }
887
888        $alignItems = $this->flexKeyword($style, 'align-items', 'stretch');
889        $justify = $this->flexKeyword($style, 'justify-content', 'flex-start');
890        if ($reverseDirection) {
891            $justify = match ($justify) {
892                'flex-start', 'start' => 'flex-end',
893                'flex-end', 'end' => 'flex-start',
894                default => $justify,
895            };
896        }
897
898        // For each line, run the per-line slack/shrink/grow algorithm.
899        $lineSlacks = [];
900        foreach ($lines as $lineIdx => $indices) {
901            $lineUsed = 0.0;
902            foreach ($indices as $i) {
903                $lineUsed += $itemMains[$i];
904            }
905            $lineUsed += $gap * max(0, count($indices) - 1);
906
907            // Shrink overflowing line.
908            $negSlack = $lineUsed - $containerMain;
909            if ($negSlack > 0.0) {
910                $totalShrink = 0.0;
911                $shrinkVals = [];
912                foreach ($indices as $i) {
913                    $s = $this->resolveFlexShrink($children[$i]->style);
914                    $shrinkVals[$i] = $s;
915                    $totalShrink += $s;
916                }
917                if ($totalShrink > 0.0) {
918                    $reduced = 0.0;
919                    foreach ($indices as $i) {
920                        if ($shrinkVals[$i] <= 0.0) {
921                            continue;
922                        }
923                        $reduction = $negSlack * ($shrinkVals[$i] / $totalShrink);
924                        $cur = $isColumn ? $children[$i]->geometry->height : $children[$i]->geometry->width;
925                        $new = max(0.0, $cur - $reduction);
926                        $actual = $cur - $new;
927                        if ($isColumn) {
928                            $children[$i]->geometry->height = $new;
929                        } else {
930                            $children[$i]->geometry->width = $new;
931                        }
932                        $reduced += $actual;
933                        $itemMains[$i] = $isColumn ? $children[$i]->geometry->outerHeight() : $children[$i]->geometry->outerWidth();
934                    }
935                    $lineUsed -= $reduced;
936                }
937            }
938
939            $lineSlack = max(0.0, $containerMain - $lineUsed);
940
941            // Grow into positive slack.
942            if ($lineSlack > 0.0) {
943                $totalGrow = 0.0;
944                $growVals = [];
945                foreach ($indices as $i) {
946                    $g = $this->resolveFlexGrow($children[$i]->style);
947                    $growVals[$i] = $g;
948                    $totalGrow += $g;
949                }
950                if ($totalGrow > 0.0) {
951                    $consumed = 0.0;
952                    foreach ($indices as $i) {
953                        if ($growVals[$i] <= 0.0) {
954                            continue;
955                        }
956                        $extra = $lineSlack * ($growVals[$i] / $totalGrow);
957                        if ($isColumn) {
958                            $children[$i]->geometry->height += $extra;
959                        } else {
960                            $children[$i]->geometry->width += $extra;
961                        }
962                        $consumed += $extra;
963                        $itemMains[$i] = $isColumn ? $children[$i]->geometry->outerHeight() : $children[$i]->geometry->outerWidth();
964                    }
965                    $lineUsed += $consumed;
966                    $lineSlack = max(0.0, $containerMain - $lineUsed);
967                }
968            }
969
970            $lineSlacks[$lineIdx] = $lineSlack;
971        }
972
973        // Per-line cross extents (max of item cross sizes within line).
974        $lineCrosses = [];
975        foreach ($lines as $lineIdx => $indices) {
976            $maxCross = 0.0;
977            foreach ($indices as $i) {
978                $maxCross = max($maxCross, $itemCrosses[$i]);
979            }
980            $lineCrosses[$lineIdx] = $maxCross;
981        }
982
983        // Container cross size: declared if available; otherwise sum
984        // of line cross extents plus inter-line cross gaps.
985        $totalLineCross = array_sum($lineCrosses) + $crossGap * max(0, count($lines) - 1);
986        if (!$crossDefinite) {
987            $containerCross = $totalLineCross;
988        }
989
990        // CSS Flexbox 1 Â§9.6: a container with a definite cross size
991        // and a single flex line grows that line to fill the container's
992        // cross extent (so align-items on the only line aligns against
993        // the full container, not just the items' natural extent).
994        if (count($lines) === 1 && $crossDefinite && $containerCross > $lineCrosses[0]) {
995            $lineCrosses[0] = $containerCross;
996        }
997
998        // `wrap-reverse` reverses the cross-axis order of lines.
999        if ($wrap === 'wrap-reverse') {
1000            $lines = array_reverse($lines, true);
1001            $lineCrosses = array_reverse($lineCrosses, true);
1002            $lineSlacks = array_reverse($lineSlacks, true);
1003        }
1004
1005        // CSS Flexbox 1 Â§8.3: `align-content` distributes cross-axis
1006        // slack across multiple flex lines. Single-line containers
1007        // ignore it (§8.3 explicit). `stretch` (initial) grows each
1008        // line's cross extent to consume the slack; positional values
1009        // shift the leading/inter-line cross spacing.
1010        $alignContent = $this->flexKeyword($style, 'align-content', 'stretch');
1011        $lineCount = count($lines);
1012        $crossSlackTotal = max(0.0, $containerCross - $totalLineCross);
1013        $leadingCrossSpace = 0.0;
1014        $interLineCrossSpace = $crossGap;
1015        if ($lineCount > 1 && $crossSlackTotal > 0.0) {
1016            switch ($alignContent) {
1017                case 'stretch':
1018                    // Distribute the slack evenly to each line's
1019                    // cross extent. Items inside still align within
1020                    // their (now larger) line via align-items.
1021                    $bonus = $crossSlackTotal / $lineCount;
1022                    foreach ($lineCrosses as $idx => $cross) {
1023                        $lineCrosses[$idx] = $cross + $bonus;
1024                    }
1025                    break;
1026                case 'center':
1027                    $leadingCrossSpace = $crossSlackTotal / 2.0;
1028                    break;
1029                case 'flex-end':
1030                case 'end':
1031                    $leadingCrossSpace = $crossSlackTotal;
1032                    break;
1033                case 'space-between':
1034                    // Outer guard already proved $lineCount >= 2.
1035                    $interLineCrossSpace = $crossGap + $crossSlackTotal / ($lineCount - 1);
1036                    break;
1037                case 'space-around':
1038                    $interLineCrossSpace = $crossGap + $crossSlackTotal / $lineCount;
1039                    $leadingCrossSpace = $interLineCrossSpace / 2.0 - $crossGap / 2.0;
1040                    break;
1041                case 'space-evenly':
1042                    $interLineCrossSpace = $crossGap + $crossSlackTotal / ($lineCount + 1);
1043                    $leadingCrossSpace = $interLineCrossSpace - $crossGap;
1044                    break;
1045                    // 'flex-start' / 'start' / 'normal' â†’ no shift.
1046            }
1047        }
1048
1049        // Place items: per line, run justify-content on main, place
1050        // at line's cross offset + per-item alignment within line.
1051        $mainOrigin = $isColumn ? $geo->y : $geo->x;
1052        $crossOrigin = $isColumn ? $geo->x : $geo->y;
1053        $lineCrossOffset = $leadingCrossSpace;
1054        foreach ($lines as $lineIdx => $indices) {
1055            $lineSlack = $lineSlacks[$lineIdx];
1056            $lineCross = $lineCrosses[$lineIdx];
1057            $count = count($indices);
1058
1059            $leadingSpace = 0.0;
1060            $itemSpace = $gap;
1061            switch ($justify) {
1062                case 'center':
1063                    $leadingSpace = $lineSlack / 2.0;
1064                    break;
1065                case 'flex-end':
1066                case 'end':
1067                case 'right':
1068                    $leadingSpace = $lineSlack;
1069                    break;
1070                case 'space-between':
1071                    if ($count > 1) {
1072                        $itemSpace = $gap + $lineSlack / ($count - 1);
1073                    }
1074                    break;
1075                case 'space-around':
1076                    if ($count > 0) {
1077                        $itemSpace = $gap + $lineSlack / $count;
1078                        $leadingSpace = $itemSpace / 2.0 - $gap / 2.0;
1079                    }
1080                    break;
1081                case 'space-evenly':
1082                    if ($count > 0) {
1083                        $itemSpace = $gap + $lineSlack / ($count + 1);
1084                        $leadingSpace = $itemSpace - $gap;
1085                    }
1086                    break;
1087                    // 'flex-start' / 'start' / 'left' â†’ no leading space.
1088            }
1089
1090            $cursor = $mainOrigin + $leadingSpace;
1091            foreach ($indices as $i) {
1092                $child = $children[$i];
1093                $childGeo = $child->geometry;
1094                $itemMain = $itemMains[$i];
1095                $itemCross = $itemCrosses[$i];
1096
1097                // Current main / cross edges (the layoutBox call placed
1098                // the item at the container's content-edge origin).
1099                if ($isColumn) {
1100                    $currentMainEdge = $childGeo->y - $childGeo->paddingTop - $childGeo->borderTop - $childGeo->marginTop;
1101                    $currentCrossEdge = $childGeo->x - $childGeo->paddingLeft - $childGeo->borderLeft - $childGeo->marginLeft;
1102                } else {
1103                    $currentMainEdge = $childGeo->x - $childGeo->paddingLeft - $childGeo->borderLeft - $childGeo->marginLeft;
1104                    $currentCrossEdge = $childGeo->y - $childGeo->paddingTop - $childGeo->borderTop - $childGeo->marginTop;
1105                }
1106                $mainShift = $cursor - $currentMainEdge;
1107
1108                // align-items / align-self cross placement *within
1109                // this line* (not the whole container).
1110                $alignSelf = $this->flexKeyword($child->style, 'align-self', 'auto');
1111                $effectiveAlign = $alignSelf === 'auto' ? $alignItems : $alignSelf;
1112                $crossSlack = $lineCross - $itemCross;
1113                $alignedCrossInLine = 0.0;
1114                switch ($effectiveAlign) {
1115                    case 'center':
1116                        $alignedCrossInLine = $crossSlack / 2.0;
1117                        break;
1118                    case 'flex-end':
1119                    case 'end':
1120                        $alignedCrossInLine = $crossSlack;
1121                        break;
1122                    case 'stretch':
1123                        // Stretch to fill the *line's* cross extent
1124                        // when the item's cross dimension is auto.
1125                        $crossProp = $isColumn ? 'width' : 'height';
1126                        if ($this->isAuto($child->style->get($crossProp)) && $crossSlack > 0.0) {
1127                            if ($isColumn) {
1128                                $childGeo->width = $lineCross - $childGeo->marginLeft - $childGeo->marginRight
1129                                    - $childGeo->borderLeft - $childGeo->borderRight
1130                                    - $childGeo->paddingLeft - $childGeo->paddingRight;
1131                            } else {
1132                                $childGeo->height = $lineCross - $childGeo->marginTop - $childGeo->marginBottom
1133                                    - $childGeo->borderTop - $childGeo->borderBottom
1134                                    - $childGeo->paddingTop - $childGeo->paddingBottom;
1135                            }
1136                        }
1137                        break;
1138                        // 'flex-start' / 'start' â†’ no in-line shift.
1139                }
1140
1141                $targetCrossEdge = $crossOrigin + $lineCrossOffset + $alignedCrossInLine;
1142                $crossShift = $targetCrossEdge - $currentCrossEdge;
1143
1144                // shiftSubtree takes (dy, dx). Map main/cross â†’ y/x
1145                // per direction.
1146                if ($mainShift !== 0.0 || $crossShift !== 0.0) {
1147                    if ($isColumn) {
1148                        $this->shiftSubtree($child, $mainShift, $crossShift);
1149                    } else {
1150                        $this->shiftSubtree($child, $crossShift, $mainShift);
1151                    }
1152                }
1153                $cursor += $itemMain + $itemSpace;
1154            }
1155
1156            $lineCrossOffset += $lineCross + $interLineCrossSpace;
1157        }
1158
1159        // Container final dimensions. The main-axis size is the
1160        // declared value (or shrink-to-fit); the cross-axis size is
1161        // the declared value (or sum of line cross extents).
1162        if ($isColumn) {
1163            $geo->height = $declaredHeight ?? $containerMain;
1164            // Container width was already set above.
1165        } else {
1166            $geo->height = $declaredHeight ?? $totalLineCross;
1167        }
1168
1169        // Min/max-width and -height clamping (same as layoutBlock).
1170        $this->clampMinMax($style, $geo, $cbWidth, $cbHeight);
1171
1172        return $geo->outerHeight();
1173    }
1174
1175    /**
1176     * Partition flex items into lines per CSS Flexbox 1 Â§9.3 step 5:
1177     * each line packs as many consecutive items as fit in the
1178     * container's main size; if a single item alone doesn't fit it
1179     * still lives on its own line (so `flex-shrink` can handle it).
1180     *
1181     * @param list<float> $itemMains  item main-axis outer sizes.
1182     * @return list<list<int>>        per-line lists of item indices.
1183     */
1184    private function partitionFlexLines(array $itemMains, float $gap, float $containerMain): array
1185    {
1186        $lines = [];
1187        $current = [];
1188        $currentUsed = 0.0;
1189        foreach ($itemMains as $i => $main) {
1190            $needed = $main + ($current === [] ? 0.0 : $gap);
1191            if ($current !== [] && $currentUsed + $needed > $containerMain) {
1192                $lines[] = $current;
1193                $current = [];
1194                $currentUsed = 0.0;
1195                $needed = $main;
1196            }
1197            $current[] = $i;
1198            $currentUsed += $needed;
1199        }
1200        if ($current !== []) {
1201            $lines[] = $current;
1202        }
1203        return $lines;
1204    }
1205
1206    /**
1207     * Stable sort of flex items by their `order` value (CSS Flexbox 1
1208     * Â§5.4). PHP's `usort` isn't guaranteed stable, so we attach the
1209     * document index as a tie-breaker.
1210     *
1211     * @param list<\Phpdftk\HtmlToPdf\Box\Box> $children
1212     * @return list<\Phpdftk\HtmlToPdf\Box\Box>
1213     */
1214    private function sortFlexItemsByOrder(array $children): array
1215    {
1216        $indexed = [];
1217        foreach ($children as $i => $child) {
1218            $indexed[] = [$this->resolveFlexOrder($child->style), $i, $child];
1219        }
1220        $needsSort = false;
1221        foreach ($indexed as $entry) {
1222            if ($entry[0] !== 0) {
1223                $needsSort = true;
1224                break;
1225            }
1226        }
1227        if (!$needsSort) {
1228            return $children;
1229        }
1230        usort($indexed, static function (array $a, array $b): int {
1231            if ($a[0] !== $b[0]) {
1232                return $a[0] <=> $b[0];
1233            }
1234            return $a[1] <=> $b[1];
1235        });
1236        $sorted = [];
1237        foreach ($indexed as $entry) {
1238            $sorted[] = $entry[2];
1239        }
1240        return $sorted;
1241    }
1242
1243    private function resolveFlexOrder(CascadedValues $style): int
1244    {
1245        $value = $style->get('order');
1246        if ($value instanceof \Phpdftk\Css\Value\Integer) {
1247            return $value->value;
1248        }
1249        if ($value instanceof \Phpdftk\Css\Value\Number) {
1250            return (int) $value->value;
1251        }
1252        return 0;
1253    }
1254
1255    /**
1256     * Resolve `flex-basis` per CSS Flexbox 1 Â§7.2. Returns `null` for
1257     * `auto` / `content` / unrecognised values (the caller keeps the
1258     * layoutBox-derived width); a non-negative float for explicit
1259     * lengths, percentages (against `$cbWidth`), and unitless zero.
1260     */
1261    private function resolveFlexBasis(CascadedValues $style, float $cbWidth): ?float
1262    {
1263        $value = $style->get('flex-basis');
1264        if ($value === null) {
1265            return null;
1266        }
1267        if ($value instanceof Keyword) {
1268            return null;
1269        }
1270        if ($value instanceof Length) {
1271            return max(0.0, $value->value);
1272        }
1273        if ($value instanceof Percentage) {
1274            return max(0.0, $value->value / 100.0 * $cbWidth);
1275        }
1276        if ($value instanceof \Phpdftk\Css\Value\Integer
1277            || $value instanceof \Phpdftk\Css\Value\Number
1278        ) {
1279            // Unitless zero per CSS Values 4 Â§6.2.
1280            if ((float) $value->value === 0.0) {
1281                return 0.0;
1282            }
1283        }
1284        return null;
1285    }
1286
1287    /**
1288     * Resolve `flex-grow` per CSS Flexbox 1 Â§7.1: a non-negative
1289     * `<number>`. Negative values are invalid and treated as 0.
1290     */
1291    private function resolveFlexGrow(CascadedValues $style): float
1292    {
1293        $value = $style->get('flex-grow');
1294        if ($value instanceof \Phpdftk\Css\Value\Number) {
1295            return max(0.0, (float) $value->value);
1296        }
1297        if ($value instanceof \Phpdftk\Css\Value\Integer) {
1298            return max(0.0, (float) $value->value);
1299        }
1300        return 0.0;
1301    }
1302
1303    /**
1304     * Resolve `flex-shrink` per CSS Flexbox 1 Â§7.1: a non-negative
1305     * `<number>` (initial value `1`). Negative values are invalid
1306     * and treated as 0 (no shrink).
1307     */
1308    private function resolveFlexShrink(CascadedValues $style): float
1309    {
1310        $value = $style->get('flex-shrink');
1311        if ($value instanceof \Phpdftk\Css\Value\Number) {
1312            return max(0.0, (float) $value->value);
1313        }
1314        if ($value instanceof \Phpdftk\Css\Value\Integer) {
1315            return max(0.0, (float) $value->value);
1316        }
1317        return 1.0;
1318    }
1319
1320    private function flexKeyword(CascadedValues $style, string $prop, string $default): string
1321    {
1322        $value = $style->get($prop);
1323        if ($value instanceof Keyword) {
1324            return strtolower($value->name);
1325        }
1326        return $default;
1327    }
1328
1329    /**
1330     * CSS Sizing 3 Â§6.2 â€” `true` when `box-sizing` is `border-box`.
1331     * Under border-box, the declared `width` / `height` (and the
1332     * min/max variants) include the border + padding so the caller
1333     * subtracts them to get the content-box dimension.
1334     */
1335    private function isBorderBoxSizing(CascadedValues $style): bool
1336    {
1337        $value = $style->get('box-sizing');
1338        return $value instanceof \Phpdftk\Css\Value\Keyword
1339            && strtolower($value->name) === 'border-box';
1340    }
1341
1342    /**
1343     * Resolve the `height` property. Returns `null` for `auto` so
1344     * callers can distinguish "explicitly sized" from "size-to-fit".
1345     */
1346    private function resolveExplicitHeightOrNull(CascadedValues $style, float $cbHeight): ?float
1347    {
1348        $value = $style->get('height');
1349        if ($this->isAuto($value)) {
1350            return null;
1351        }
1352        return $this->resolveLength($value, $cbHeight);
1353    }
1354
1355    /**
1356     * Apply min/max-width and min/max-height clamping to `$geo`
1357     * mirroring `layoutBlock`'s clamp pass â€” extracted so flex
1358     * containers honour the same constraints.
1359     */
1360    private function clampMinMax(CascadedValues $style, \Phpdftk\HtmlToPdf\Layout\BoxGeometry $geo, float $cbWidth, float $cbHeight): void
1361    {
1362        $maxWidthValue = $style->get('max-width');
1363        if (!($maxWidthValue instanceof Keyword && strtolower($maxWidthValue->name) === 'none')) {
1364            $maxWidth = $this->resolveLength($maxWidthValue, $cbWidth);
1365            if ($maxWidth > 0.0 && $geo->width > $maxWidth) {
1366                $geo->width = $maxWidth;
1367            }
1368        }
1369        $minWidth = $this->resolveLength($style->get('min-width'), $cbWidth);
1370        if ($minWidth > 0.0 && $geo->width < $minWidth) {
1371            $geo->width = $minWidth;
1372        }
1373        $maxHeightValue = $style->get('max-height');
1374        if (!($maxHeightValue instanceof Keyword && strtolower($maxHeightValue->name) === 'none')) {
1375            $maxHeight = $this->resolveLength($maxHeightValue, $cbHeight);
1376            if ($maxHeight > 0.0 && $geo->height > $maxHeight) {
1377                $geo->height = $maxHeight;
1378            }
1379        }
1380        $minHeight = $this->resolveLength($style->get('min-height'), $cbHeight);
1381        if ($minHeight > 0.0 && $geo->height < $minHeight) {
1382            $geo->height = $minHeight;
1383        }
1384    }
1385
1386    private function resolveLength(?\Phpdftk\Css\Value\Value $value, float $percentageBasis): float
1387    {
1388        if ($value === null) {
1389            return 0.0;
1390        }
1391        if ($value instanceof Length) {
1392            return $value->value;
1393        }
1394        if ($value instanceof Percentage) {
1395            return $value->value / 100.0 * $percentageBasis;
1396        }
1397        return 0.0;
1398    }
1399
1400    private function resolveBorderWidth(CascadedValues $style, string $side): float
1401    {
1402        $styleValue = $style->get("border-$side-style");
1403        if ($styleValue instanceof Keyword && strtolower($styleValue->name) === 'none') {
1404            return 0.0;
1405        }
1406        $width = $style->get("border-$side-width");
1407        if ($width instanceof Length) {
1408            return $width->value;
1409        }
1410        return 0.0;
1411    }
1412
1413    private function lengthContextFor(CascadedValues $style, LengthContext $parent): LengthContext
1414    {
1415        $fontSize = $style->get('font-size');
1416        if ($fontSize instanceof Length) {
1417            return $parent->withCurrentFontSize($fontSize->value);
1418        }
1419        return $parent;
1420    }
1421
1422    /**
1423     * Resolve CSS Sizing 4 Â§4.2 `aspect-ratio` to a width/height
1424     * ratio. Accepts:
1425     *  - `<number>` (e.g. `1.5`) â†’ ratio.
1426     *  - `<number> / <number>` (e.g. `16/9`) â†’ first divided by
1427     *    second (parses as ValueList with Slash separator).
1428     * Returns null on `auto` / unknown / zero denominator.
1429     */
1430    private function resolveAspectRatio(CascadedValues $style): ?float
1431    {
1432        $value = $style->get('aspect-ratio');
1433        if ($value instanceof \Phpdftk\Css\Value\ValueList
1434            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
1435            && count($value->values) >= 2
1436        ) {
1437            $w = $this->numericValue($value->values[0]);
1438            $h = $this->numericValue($value->values[1]);
1439            if ($w !== null && $h !== null && $h > 0.0) {
1440                return $w / $h;
1441            }
1442        }
1443        $direct = $this->numericValue($value);
1444        if ($direct !== null && $direct > 0.0) {
1445            return $direct;
1446        }
1447        return null;
1448    }
1449
1450    private function numericValue(?\Phpdftk\Css\Value\Value $v): ?float
1451    {
1452        if ($v instanceof \Phpdftk\Css\Value\Integer) {
1453            return (float) $v->value;
1454        }
1455        if ($v instanceof \Phpdftk\Css\Value\Number) {
1456            return $v->value;
1457        }
1458        return null;
1459    }
1460
1461    private function isAuto(?\Phpdftk\Css\Value\Value $value): bool
1462    {
1463        return $value instanceof Keyword && strtolower($value->name) === 'auto';
1464    }
1465
1466    /**
1467     * Shift `$box` and every descendant's geometry by `$dy` along Y, and
1468     * optionally `$dx` along X. Used by margin collapsing (Y only) and
1469     * by multi-column re-distribution (both axes).
1470     *
1471     * Line boxes and inline fragments are positioned relative to their
1472     * containing block's geometry, so they ride along automatically when
1473     * the parent's geometry shifts â€” no extra walk required.
1474     */
1475    private function shiftSubtree(Box $box, float $dy, float $dx = 0.0): void
1476    {
1477        $box->geometry->y += $dy;
1478        $box->geometry->x += $dx;
1479        foreach ($box->children as $child) {
1480            $this->shiftSubtree($child, $dy, $dx);
1481        }
1482    }
1483
1484    /**
1485     * `column-count` or `column-width` (or both) non-`auto` â†’ this box
1486     * establishes a multi-column formatting context (CSS Multi-column 1 Â§2).
1487     * Honoured only on block-container parents whose children are all
1488     * block-level: tables, inline-only blocks, and replaced elements are
1489     * skipped at Phase 1.
1490     */
1491    private function isMultiColumnContainer(Box $box): bool
1492    {
1493        if ($box instanceof \Phpdftk\HtmlToPdf\Box\TableBox
1494            || $box instanceof \Phpdftk\HtmlToPdf\Box\TableRowBox
1495            || $box instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox
1496        ) {
1497            return false;
1498        }
1499        if ($this->allInlineLevel($box->children)) {
1500            return false;
1501        }
1502        $count = $box->style->get('column-count');
1503        $width = $box->style->get('column-width');
1504        $countSet = !$this->isAuto($count);
1505        $widthSet = $width instanceof Length;
1506        return $countSet || $widthSet;
1507    }
1508
1509    /**
1510     * Resolve the used column-count + column-width per CSS Multi-column 1
1511     * Â§7.1, then stack children into N fragmentainers side-by-side.
1512     *
1513     * Phase-1 simplifications:
1514     *  - `column-fill: balance` only (initial value). Heights are equalised
1515     *    using `ceil(totalChildOuterHeight / N)`; the true browser
1516     *    algorithm iterates to minimise stragglers but the ceil
1517     *    approximation matches within one line for typical content.
1518     *  - No mid-child fragmentation. A child taller than the balanced
1519     *    height stays whole in its column; the column simply overflows.
1520     *    Mid-child splitting lands with 1I.2.
1521     *  - `column-span: all` carves the container into sequential
1522     *    columnar segments separated by full-width spanners; the
1523     *    column rule still paints across the full container height
1524     *    (drawing through spanner backgrounds â€” a Phase-2 follow-up
1525     *    will clip it per-segment).
1526     */
1527    private function layoutMultiColumn(Box $box, LayoutContext $childContext): float
1528    {
1529        $geo = $box->geometry;
1530        $available = $geo->width;
1531        if ($available <= 0.0) {
1532            $box->multiColumn = null;
1533            return 0.0;
1534        }
1535
1536        $gap = $this->resolveColumnGap($box->style, $childContext->lengthContext);
1537        [$count, $columnWidth] = $this->resolveColumns($box->style, $available, $gap);
1538        if ($count < 1) {
1539            $count = 1;
1540        }
1541        $box->multiColumn = new MultiColumnLayout(
1542            columnCount: $count,
1543            columnWidth: $columnWidth,
1544            columnGap: $gap,
1545            ruleWidth: $this->resolveColumnRuleWidth($box->style),
1546            ruleStyle: $this->resolveColumnRuleStyle($box->style),
1547            ruleColor: $this->resolveColumnRuleColor($box->style),
1548        );
1549
1550        // Single-column degenerate case â€” fall back to normal block stacking.
1551        if ($count === 1) {
1552            return $this->stackChildren($box, $childContext, $childContext->originX, $childContext->originY);
1553        }
1554
1555        // CSS Multi-column 1 Â§6.2 â€” `column-span: all` carves the
1556        // container into vertical segments: columnar runs above and
1557        // below a span-all child, with the spanner taking the full
1558        // container width between them. Walk the children once and
1559        // dispatch each segment to the appropriate layout path.
1560        $segments = $this->splitByColumnSpan($box->children);
1561        if (count($segments) > 1) {
1562            $cursorY = $geo->y;
1563            foreach ($segments as $segment) {
1564                if ($segment['span']) {
1565                    // Single column-span: all child â€” lay out full-width.
1566                    $spanChild = $segment['children'][0];
1567                    $spanCtx = $childContext
1568                        ->withContainingBlock($geo->width, $childContext->containingBlockHeight)
1569                        ->withOrigin($geo->x, $cursorY);
1570                    $h = $this->layoutBox($spanChild, $spanCtx);
1571                    $cursorY += $h;
1572                } else {
1573                    $cursorY += $this->layoutColumnarRun(
1574                        $box,
1575                        $segment['children'],
1576                        $childContext,
1577                        $columnWidth,
1578                        $gap,
1579                        $count,
1580                        $cursorY,
1581                    );
1582                }
1583            }
1584            return $cursorY - $geo->y;
1585        }
1586
1587        // No span-all children â€” fall through to the original two-pass
1588        // codepath that operates on `$box->children` directly.
1589        return $this->layoutColumnarRun($box, $box->children, $childContext, $columnWidth, $gap, $count, $geo->y);
1590    }
1591
1592    /**
1593     * Split a child list into vertical segments at `column-span: all`
1594     * boundaries. Each segment is either a columnar run of regular
1595     * children OR a single span-all child. Order is preserved.
1596     *
1597     * @param list<Box> $children
1598     * @return list<array{span: bool, children: list<Box>}>
1599     */
1600    private function splitByColumnSpan(array $children): array
1601    {
1602        /** @var list<array{span: bool, children: list<Box>}> $segments */
1603        $segments = [];
1604        /** @var list<Box> $run */
1605        $run = [];
1606        foreach ($children as $child) {
1607            if ($this->isColumnSpanAll($child)) {
1608                if ($run !== []) {
1609                    $segments[] = ['span' => false, 'children' => $run];
1610                    $run = [];
1611                }
1612                $segments[] = ['span' => true, 'children' => [$child]];
1613                continue;
1614            }
1615            $run[] = $child;
1616        }
1617        if ($run !== []) {
1618            $segments[] = ['span' => false, 'children' => $run];
1619        }
1620        return $segments;
1621    }
1622
1623    private function isColumnSpanAll(Box $child): bool
1624    {
1625        $value = $child->style->get('column-span');
1626        return $value instanceof Keyword && strtolower($value->name) === 'all';
1627    }
1628
1629    /**
1630     * Two-pass columnar layout over a subset of `$box`'s children
1631     * starting at `$originY`. Returns the total vertical space consumed
1632     * (max of the column heights) so the caller can advance its cursor
1633     * past the run. Pulled out of `layoutMultiColumn` so segmented runs
1634     * (around `column-span: all` spanners) can reuse it.
1635     *
1636     * @param list<Box> $children
1637     */
1638    private function layoutColumnarRun(
1639        Box $box,
1640        array $children,
1641        LayoutContext $childContext,
1642        float $columnWidth,
1643        float $gap,
1644        int $count,
1645        float $originY,
1646    ): float {
1647        $geo = $box->geometry;
1648        if ($children === []) {
1649            return 0.0;
1650        }
1651        $columnCtx = $childContext->withContainingBlock(
1652            $columnWidth,
1653            $childContext->containingBlockHeight,
1654        );
1655        $childTotal = $this->stackChildrenList(
1656            $children,
1657            $columnCtx,
1658            $geo->x,
1659            $originY,
1660        );
1661
1662        $balanced = $count > 0 ? ceil($childTotal / $count) : $childTotal;
1663
1664        $currentCol = 0;
1665        $colY = 0.0;
1666        $columnHeights = array_fill(0, $count, 0.0);
1667        $prevChild = null;
1668        foreach ($children as $child) {
1669            $h = $child->geometry->outerHeight();
1670            // CSS Multi-column 1 Â§6.1 â€” author-controlled column breaks.
1671            // `break-before: column` on this child or `break-after: column`
1672            // on the previous child forces a new column. Bounded at the
1673            // last column: once we've consumed N-1 columns, additional
1674            // forced breaks fall through to the existing overflow
1675            // semantics on the final column.
1676            $forceNewColumn = $colY > 0.0 && $currentCol < $count - 1 && (
1677                $this->forcesColumnBreakBefore($child)
1678                || ($prevChild !== null && $this->forcesColumnBreakAfter($prevChild))
1679            );
1680            if ($forceNewColumn) {
1681                $currentCol++;
1682                $colY = 0.0;
1683            } elseif ($colY > 0.0
1684                && $currentCol < $count - 1
1685                && $colY + $h > $balanced + 0.001
1686            ) {
1687                $currentCol++;
1688                $colY = 0.0;
1689            }
1690            $targetX = $geo->x + $currentCol * ($columnWidth + $gap);
1691            $targetY = $originY + $colY;
1692            // Reset the child's top margin so the first child in each
1693            // column doesn't carry collapsed-margin leftovers from the
1694            // first-pass stacking.
1695            if ($colY === 0.0) {
1696                $existingMargin = $child->geometry->marginTop;
1697                if ($existingMargin !== 0.0) {
1698                    // Pull the child up by its margin so it sits flush at
1699                    // the column's top edge.
1700                    $child->geometry->marginTop = 0.0;
1701                    $targetY -= $existingMargin;
1702                    $h = $child->geometry->outerHeight();
1703                }
1704            }
1705            $dx = $targetX - $child->geometry->x;
1706            $dy = $targetY - $child->geometry->y;
1707            if ($dx !== 0.0 || $dy !== 0.0) {
1708                $this->shiftSubtree($child, $dy, $dx);
1709            }
1710            $colY += $h;
1711            $columnHeights[$currentCol] = $colY;
1712            $prevChild = $child;
1713        }
1714        return max($columnHeights);
1715    }
1716
1717    /**
1718     * Standard block-stacking pass extracted so `layoutMultiColumn`'s first
1719     * pass can reuse it. Lays out every child at the supplied origin in
1720     * `$context`'s containing block, applying margin collapsing and the
1721     * existing page-break logic.
1722     */
1723    private function stackChildren(Box $box, LayoutContext $childContext, float $originX, float $originY): float
1724    {
1725        return $this->stackChildrenList($box->children, $childContext, $originX, $originY);
1726    }
1727
1728    /**
1729     * Same algorithm as `stackChildren` but iterating an explicit child
1730     * list instead of `$box->children`. Used by `layoutColumnarRun` so
1731     * a column-span: all segment can lay out just the columnar slice of
1732     * a multi-column container's children.
1733     *
1734     * @param list<Box> $children
1735     */
1736    private function stackChildrenList(array $children, LayoutContext $childContext, float $originX, float $originY): float
1737    {
1738        $cursorY = $originY;
1739        $prevBottomMargin = 0.0;
1740        $hasPrev = false;
1741        $pageHeight = $childContext->containingBlockHeight;
1742        $total = 0.0;
1743        foreach ($children as $child) {
1744            $this->cascade->resolveLengths($child->style, $childContext->lengthContext);
1745            // CSS 2.1 Â§9.6 â€” `position: absolute` (and `fixed`, which
1746            // behaves identically in a print context with no scrolling)
1747            // removes the box from normal flow. Lay it out at the
1748            // parent's content origin + (left, top) offsets, then skip
1749            // the cursor advancement so siblings stack as if it weren't
1750            // here.
1751            if ($this->isOutOfFlow($child)) {
1752                $absCtx = $childContext->withOrigin($originX, $cursorY);
1753                $this->layoutBox($child, $absCtx);
1754                [$dx, $dy] = $this->resolveAbsoluteOffsets(
1755                    $child,
1756                    $childContext,
1757                    $originX,
1758                    $originY,
1759                    $cursorY,
1760                );
1761                if ($dx !== 0.0 || $dy !== 0.0) {
1762                    $this->shiftSubtree($child, $dy, $dx);
1763                }
1764                $prevBottomMargin = 0.0;
1765                continue;
1766            }
1767            // CSS 2.1 Â§9.5.2 â€” `clear` shifts an in-flow block past
1768            // floats on the specified side(s). Apply before laying the
1769            // child out so its geometry reflects the cleared cursor.
1770            $clearSide = $this->clearSide($child);
1771            if ($clearSide !== null && $childContext->floatContext !== null) {
1772                $cleared = $childContext->floatContext->clearTo($clearSide, $cursorY);
1773                if ($cleared > $cursorY) {
1774                    $delta = $cleared - $cursorY;
1775                    $cursorY = $cleared;
1776                    $total += $delta;
1777                }
1778            }
1779            // CSS 2.1 Â§9.5.1 â€” floats are taken out of normal flow. Lay
1780            // the float at the L/R edge of the containing block at the
1781            // current cursor Y (or below, when prior floats consume the
1782            // available width), register in FloatContext, then continue
1783            // without advancing the cursor.
1784            $floatSide = $this->floatSide($child);
1785            if ($floatSide !== null) {
1786                $this->layoutFloat(
1787                    $child,
1788                    $floatSide,
1789                    $childContext,
1790                    $originX,
1791                    $cursorY,
1792                );
1793                continue;
1794            }
1795            if ($pageHeight > 0.0
1796                && $this->forcesPageBreakBefore($child)
1797                && ($hasPrev || $cursorY > 0.001)
1798            ) {
1799                $aligned = $this->ceilToPage($cursorY, $pageHeight);
1800                if ($aligned > $cursorY) {
1801                    $delta = $aligned - $cursorY;
1802                    $cursorY = $aligned;
1803                    $total += $delta;
1804                }
1805            }
1806            $childOuterHeight = $this->layoutBox($child, $childContext->withOrigin($originX, $cursorY));
1807            if ($hasPrev) {
1808                $collapse = min($prevBottomMargin, $child->geometry->marginTop);
1809                if ($collapse > 0.0) {
1810                    $this->shiftSubtree($child, -$collapse);
1811                    $cursorY -= $collapse;
1812                    $total -= $collapse;
1813                }
1814            }
1815            if ($pageHeight > 0.0
1816                && $childOuterHeight > 0.0
1817                && $childOuterHeight <= $pageHeight
1818                && $this->avoidsBreakInside($child)
1819            ) {
1820                $childTop = $child->geometry->y;
1821                $childBottom = $childTop + $childOuterHeight;
1822                $startPage = (int) floor($childTop / $pageHeight);
1823                $endPage = (int) floor(($childBottom - 0.001) / $pageHeight);
1824                if ($endPage > $startPage) {
1825                    $aligned = ($startPage + 1) * $pageHeight;
1826                    $shift = $aligned - $childTop;
1827                    if ($shift > 0.0) {
1828                        $this->shiftSubtree($child, $shift);
1829                        $cursorY += $shift;
1830                        $total += $shift;
1831                    }
1832                }
1833            }
1834            $cursorY += $childOuterHeight;
1835            $total += $childOuterHeight;
1836            if ($pageHeight > 0.0 && $this->forcesPageBreakAfter($child)) {
1837                $aligned = $this->ceilToPage($cursorY, $pageHeight);
1838                if ($aligned > $cursorY) {
1839                    $delta = $aligned - $cursorY;
1840                    $cursorY = $aligned;
1841                    $total += $delta;
1842                }
1843            }
1844            $prevBottomMargin = $child->geometry->marginBottom;
1845            $hasPrev = true;
1846        }
1847        return $total;
1848    }
1849
1850    /**
1851     * @return array{0:int, 1:float} `[columnCount, columnWidth]`
1852     */
1853    private function resolveColumns(CascadedValues $style, float $available, float $gap): array
1854    {
1855        $countValue = $style->get('column-count');
1856        $widthValue = $style->get('column-width');
1857        $countSet = !$this->isAuto($countValue);
1858        $widthSet = $widthValue instanceof Length;
1859
1860        // Pull the integer count from `Integer` / `Number` (cascade may store
1861        // either). Clamp â‰¥ 1 â€” `column-count: 0` is invalid per spec.
1862        $count = 1;
1863        if ($countSet) {
1864            if ($countValue instanceof \Phpdftk\Css\Value\Integer) {
1865                $count = max(1, $countValue->value);
1866            } elseif ($countValue instanceof \Phpdftk\Css\Value\Number) {
1867                $count = max(1, (int) round($countValue->value));
1868            }
1869        }
1870        $width = $widthSet ? max(0.0, $widthValue->value) : 0.0;
1871
1872        if ($countSet && !$widthSet) {
1873            $usedWidth = max(0.0, ($available - ($count - 1) * $gap) / $count);
1874            return [$count, $usedWidth];
1875        }
1876        if (!$countSet && $widthSet && $width > 0.0) {
1877            // CSS Multi-column 1 Â§7.1 step 4: N = max(1, floor((available + gap) / (width + gap))).
1878            $usedCount = max(1, (int) floor(($available + $gap) / ($width + $gap)));
1879            $usedWidth = max(0.0, ($available - ($usedCount - 1) * $gap) / $usedCount);
1880            return [$usedCount, $usedWidth];
1881        }
1882        if ($countSet && $widthSet) {
1883            // Both set: column-count wins; column-width becomes a minimum
1884            // (Phase 1 just uses count's distribution).
1885            $usedWidth = max(0.0, ($available - ($count - 1) * $gap) / $count);
1886            return [$count, $usedWidth];
1887        }
1888        return [1, $available];
1889    }
1890
1891    /**
1892     * `column-gap: normal` resolves to `1em` per CSS Multi-column 1 Â§3.1.
1893     * CSS Values 4 Â§6.2 lets `0` appear as a unitless length, so accept
1894     * `Integer` / `Number` whose value is zero too.
1895     */
1896    private function resolveColumnGap(CascadedValues $style, LengthContext $lc): float
1897    {
1898        $value = $style->get('column-gap');
1899        if ($value instanceof Length) {
1900            return max(0.0, $value->value);
1901        }
1902        if ($value instanceof Percentage) {
1903            return 0.0; // Phase 1: no percentage basis defined.
1904        }
1905        if ($value instanceof \Phpdftk\Css\Value\Integer
1906            || $value instanceof \Phpdftk\Css\Value\Number
1907        ) {
1908            return max(0.0, (float) $value->value);
1909        }
1910        return max(0.0, $lc->currentFontSize);
1911    }
1912
1913    /**
1914     * Resolve the main-axis gap for a flex container: `column-gap`
1915     * for row direction, `row-gap` for column direction. Both fall
1916     * back to `0px` for flex per CSS Box Alignment 3 Â§8.1 (only
1917     * multi-column resolves `normal` to `1em`).
1918     */
1919    private function resolveFlexMainGap(CascadedValues $style, LengthContext $lc, bool $isColumn): float
1920    {
1921        return $this->resolveFlexGapProperty($style, $isColumn ? 'row-gap' : 'column-gap');
1922    }
1923
1924    private function resolveFlexGapProperty(CascadedValues $style, string $prop): float
1925    {
1926        $value = $style->get($prop);
1927        if ($value instanceof Length) {
1928            return max(0.0, $value->value);
1929        }
1930        if ($value instanceof Percentage) {
1931            return 0.0;
1932        }
1933        if ($value instanceof \Phpdftk\Css\Value\Integer
1934            || $value instanceof \Phpdftk\Css\Value\Number
1935        ) {
1936            return max(0.0, (float) $value->value);
1937        }
1938        return 0.0;
1939    }
1940
1941    private function resolveColumnRuleWidth(CascadedValues $style): float
1942    {
1943        $styleValue = $style->get('column-rule-style');
1944        if ($styleValue instanceof Keyword
1945            && in_array(strtolower($styleValue->name), ['none', 'hidden'], true)
1946        ) {
1947            return 0.0;
1948        }
1949        $width = $style->get('column-rule-width');
1950        if ($width instanceof Length) {
1951            return max(0.0, $width->value);
1952        }
1953        return 0.0;
1954    }
1955
1956    private function resolveColumnRuleStyle(CascadedValues $style): string
1957    {
1958        $value = $style->get('column-rule-style');
1959        if ($value instanceof Keyword) {
1960            return strtolower($value->name);
1961        }
1962        return 'none';
1963    }
1964
1965    private function resolveColumnRuleColor(CascadedValues $style): ?\Phpdftk\Css\Value\Color
1966    {
1967        $value = $style->get('column-rule-color');
1968        if ($value instanceof \Phpdftk\Css\Value\Color) {
1969            return $value;
1970        }
1971        // `currentcolor` resolves against the cascaded `color` per CSS Color 3.
1972        if ($value instanceof Keyword && strtolower($value->name) === 'currentcolor') {
1973            $color = $style->get('color');
1974            return $color instanceof \Phpdftk\Css\Value\Color ? $color : null;
1975        }
1976        return null;
1977    }
1978
1979    /** @param list<Box> $children */
1980    private function allInlineLevel(array $children): bool
1981    {
1982        foreach ($children as $c) {
1983            if (!(
1984                $c instanceof InlineBox
1985                || $c instanceof TextBox
1986                || $c instanceof AtomicInlineBox
1987                || $c instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox
1988            )) {
1989                return false;
1990            }
1991        }
1992        return true;
1993    }
1994
1995    /**
1996     * Hand the inline children to {@see InlineLayout}, record the
1997     * resulting line boxes on the parent, and return the total height
1998     * consumed.
1999     *
2000     * After laying the lines out, run a fragmentation pass that shifts
2001     * any line straddling a page boundary down to the next page â€”
2002     * orphans/widows aware. Without this, viewers would render the
2003     * straddling line cut horizontally through the glyph mid-stroke.
2004     */
2005    private function layoutInlineChildren(Box $parent, LayoutContext $childContext): float
2006    {
2007        // Resolve every inline descendant's cascaded lengths so `font-size:
2008        // 0.83em` on `<sup>` / `<small>` etc. shapes at the right px size.
2009        // Block descendants already had this done in `layoutBlock`.
2010        $this->resolveInlineLengths($parent, $childContext->lengthContext);
2011        [$lines, $height] = $this->inlineLayout->layout(
2012            $parent,
2013            $parent->geometry->width,
2014            $childContext,
2015        );
2016        $height = $this->avoidLineSplitsAcrossPages(
2017            $lines,
2018            $parent,
2019            $childContext->containingBlockHeight,
2020            $height,
2021        );
2022        $parent->lineBoxes = $lines;
2023        return $height;
2024    }
2025
2026    /**
2027     * CSS Fragmentation 4 Â§4.1 â€” line boxes must not be split between
2028     * pages. When a line's vertical extent crosses a page boundary, push
2029     * the line down to start exactly at the next boundary; subsequent
2030     * lines shift by the same amount so they keep their relative spacing.
2031     *
2032     * Also honours `orphans` / `widows` (default 2 each): when a paragraph
2033     * splits across pages, hold back enough trailing lines to fill the
2034     * widow count, and pull enough leading lines forward to satisfy the
2035     * orphan count. If the paragraph is too short to honour both (e.g.
2036     * 3 lines with orphans=2 widows=2), shift the whole paragraph to the
2037     * next page so it stays together.
2038     *
2039     * The `$lines` array is mutated in place; the parent's content height
2040     * is returned so the caller can record the new (possibly larger) box
2041     * height accounting for the inserted gaps.
2042     *
2043     * @param list<LineBox> $lines
2044     */
2045    private function avoidLineSplitsAcrossPages(
2046        array $lines,
2047        Box $parent,
2048        float $pageHeight,
2049        float $initialHeight,
2050    ): float {
2051        if ($pageHeight <= 0.0 || $lines === []) {
2052            return $initialHeight;
2053        }
2054        $orphans = max(1, $this->intStyle($parent->style, 'orphans', 2));
2055        $widows = max(1, $this->intStyle($parent->style, 'widows', 2));
2056        $parentTop = $parent->geometry->y;
2057        $count = count($lines);
2058        // Walk the lines in document order. The fragment boundary is the
2059        // first line whose page index differs from the previous line's, OR
2060        // a line that straddles the page boundary mid-glyph. At either
2061        // event, decide an actual `breakAt` index honouring orphans /
2062        // widows, then shift `[$breakAt..$count)` down by enough to start
2063        // the chosen line at the next page boundary.
2064        $prevStartPage = -1;
2065        for ($i = 0; $i < $count; $i++) {
2066            $line = $lines[$i];
2067            $absTop = $parentTop + $line->y;
2068            $absBot = $absTop + $line->height;
2069            $startPage = (int) floor($absTop / $pageHeight);
2070            $endPage = (int) floor(($absBot - 0.001) / $pageHeight);
2071            $straddles = $endPage > $startPage;
2072            $crossesBoundary = $prevStartPage >= 0 && $startPage > $prevStartPage;
2073            if (!$straddles && !$crossesBoundary) {
2074                $prevStartPage = $startPage;
2075                continue;
2076            }
2077            // Tentative break: at this line.
2078            $breakAt = $i;
2079            // Widows: hold back enough trailing lines so $count - $breakAt
2080            // â‰¥ $widows. Pull earlier lines forward into the next page.
2081            $remaining = $count - $breakAt;
2082            if ($remaining < $widows) {
2083                $breakAt = max(0, $count - $widows);
2084            }
2085            // Orphans: ensure at least $orphans lines stay on the previous
2086            // page (i.e. $breakAt â‰¥ $orphans). If we can't, push the
2087            // entire paragraph onto the next page.
2088            if ($breakAt > 0 && $breakAt < $orphans) {
2089                $breakAt = 0;
2090            }
2091            // The target page for $lines[$breakAt] is the page that the
2092            // *current* line should land on. For a straddling line, that's
2093            // (startPage + 1); for a clean cross, it's startPage. Compute
2094            // the boundary at the top of that page and shift breakAt to
2095            // sit on it. Lines pulled back by widows / orphans already
2096            // sit on an earlier page and get shifted onto the new page;
2097            // breakAt == i lines already on the new page get delta=0.
2098            $targetPage = $straddles ? $startPage + 1 : $startPage;
2099            $targetBoundary = $targetPage * $pageHeight;
2100            $absBreakTop = $parentTop + $lines[$breakAt]->y;
2101            $delta = $targetBoundary - $absBreakTop;
2102            if ($delta <= 0.0) {
2103                $prevStartPage = $startPage;
2104                continue;
2105            }
2106            for ($j = $breakAt; $j < $count; $j++) {
2107                $lines[$j]->y += $delta;
2108            }
2109            // Re-read $line's post-shift page so the next iteration
2110            // sees the correct previous-page state.
2111            $absTopShifted = $parentTop + $lines[$i]->y;
2112            $prevStartPage = (int) floor($absTopShifted / $pageHeight);
2113        }
2114        $last = $lines[$count - 1];
2115        return $last->y + $last->height;
2116    }
2117
2118    private function intStyle(CascadedValues $style, string $name, int $default): int
2119    {
2120        $v = $style->get($name);
2121        if ($v instanceof \Phpdftk\Css\Value\Integer) {
2122            return $v->value;
2123        }
2124        if ($v instanceof \Phpdftk\Css\Value\Number) {
2125            return (int) round($v->value);
2126        }
2127        return $default;
2128    }
2129
2130    /**
2131     * Advance `$y` to the next page boundary. CSS Fragmentation 4 Â§3.1
2132     * requires forced breaks to advance to the next fragmentainer even
2133     * when the box already sits at a page start; the caller is
2134     * responsible for not invoking this on the very first element of
2135     * the document (where it'd produce an empty leading page).
2136     */
2137    private function ceilToPage(float $y, float $pageHeight): float
2138    {
2139        if ($pageHeight <= 0.0) {
2140            return $y;
2141        }
2142        $page = (int) floor($y / $pageHeight);
2143        $start = $page * $pageHeight;
2144        if (abs($y - $start) < 0.001) {
2145            return $y + $pageHeight;
2146        }
2147        return ($page + 1) * $pageHeight;
2148    }
2149
2150    private function forcesPageBreakBefore(Box $box): bool
2151    {
2152        return $this->declaresForcedBreak($box->style->get('break-before'))
2153            || $this->declaresForcedBreak($box->style->get('page-break-before'))
2154            || $this->declaresNamedPage($box->style->get('page'));
2155    }
2156
2157    /**
2158     * CSS Paged Media 3 Â§3.4: when `page` is a non-auto identifier,
2159     * it implicitly forces a page break before the box so the
2160     * declared page type can apply to a fresh page.
2161     */
2162    private function declaresNamedPage(?\Phpdftk\Css\Value\Value $value): bool
2163    {
2164        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
2165            return false;
2166        }
2167        return strtolower($value->name) !== 'auto';
2168    }
2169
2170    private function forcesPageBreakAfter(Box $box): bool
2171    {
2172        return $this->declaresForcedBreak($box->style->get('break-after'))
2173            || $this->declaresForcedBreak($box->style->get('page-break-after'));
2174    }
2175
2176    /**
2177     * `break-before: page` / `always` / `left` / `right` / `recto` / `verso`
2178     * all force a page break in CSS Fragmentation 4 Â§3.1. The legacy
2179     * `page-break-*` aliases accept `always` (mapped to `page`) and the
2180     * left/right variants.
2181     */
2182    private function avoidsBreakInside(Box $box): bool
2183    {
2184        return $this->declaresBreakInsideAvoid($box->style->get('break-inside'))
2185            || $this->declaresBreakInsideAvoid($box->style->get('page-break-inside'));
2186    }
2187
2188    private function declaresBreakInsideAvoid(?\Phpdftk\Css\Value\Value $value): bool
2189    {
2190        return $value instanceof \Phpdftk\Css\Value\Keyword
2191            && in_array(strtolower($value->name), ['avoid', 'avoid-page', 'avoid-column'], true);
2192    }
2193
2194    private function declaresForcedBreak(?\Phpdftk\Css\Value\Value $value): bool
2195    {
2196        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
2197            return false;
2198        }
2199        return in_array(strtolower($value->name), ['page', 'always', 'all', 'left', 'right', 'recto', 'verso'], true);
2200    }
2201
2202    private function forcesColumnBreakBefore(Box $box): bool
2203    {
2204        return $this->declaresForcedColumnBreak($box->style->get('break-before'));
2205    }
2206
2207    private function forcesColumnBreakAfter(Box $box): bool
2208    {
2209        return $this->declaresForcedColumnBreak($box->style->get('break-after'));
2210    }
2211
2212    /**
2213     * CSS Fragmentation 4 Â§3.1 â€” `break-before/after: column` forces a
2214     * column break. `always` / `all` force a break of any type, so they
2215     * count too. There is no legacy `page-break-*` alias for `column`;
2216     * authors use the modern `break-*` properties exclusively.
2217     */
2218    private function declaresForcedColumnBreak(?\Phpdftk\Css\Value\Value $value): bool
2219    {
2220        if (!($value instanceof \Phpdftk\Css\Value\Keyword)) {
2221            return false;
2222        }
2223        return in_array(strtolower($value->name), ['column', 'always', 'all'], true);
2224    }
2225
2226    /**
2227     * Walk a `<table>`'s `<col>` and `<colgroup>` DOM children to
2228     * extract explicit per-column widths (HTML 5 Â§4.9.4). The legacy
2229     * `width="N"` attribute is honoured as pixel widths; CSS `width:`
2230     * on `<col>` (Phase 2) and percentage / `*` proportional widths
2231     * (Phase 2) are not yet supported.
2232     *
2233     * Returns a fixed-size array of length `$totalColumns` with `null`
2234     * entries for columns that didn't get an explicit width.
2235     *
2236     * @return list<?float>
2237     */
2238    private function collectColumnWidths(\Phpdftk\HtmlToPdf\Box\TableBox $table, int $totalColumns): array
2239    {
2240        /** @var list<?float> $widths */
2241        $widths = array_fill(0, $totalColumns, null);
2242        if ($table->element === null || $totalColumns === 0) {
2243            return $widths;
2244        }
2245        $col = 0;
2246        foreach ($table->element->children() as $child) {
2247            if ($col >= $totalColumns) {
2248                break;
2249            }
2250            $tag = strtolower($child->localName);
2251            if ($tag === 'col') {
2252                $col = $this->applyColWidth($child, $widths, $col);
2253            } elseif ($tag === 'colgroup') {
2254                $inner = $child->children();
2255                $hasNested = false;
2256                foreach ($inner as $sub) {
2257                    if (strtolower($sub->localName) === 'col') {
2258                        $hasNested = true;
2259                        $col = $this->applyColWidth($sub, $widths, $col);
2260                        if ($col >= $totalColumns) {
2261                            break;
2262                        }
2263                    }
2264                }
2265                if (!$hasNested) {
2266                    // Group with no nested `<col>` applies its own
2267                    // span (HTML 5 Â§4.9.3) â€” its width attribute (if
2268                    // any) flows to each spanned column.
2269                    $col = $this->applyColWidth($child, $widths, $col);
2270                }
2271            }
2272        }
2273        return $widths;
2274    }
2275
2276    /**
2277     * Apply one `<col>` / `<colgroup>` element's `width` and `span`
2278     * attributes to the column-width array, starting at `$startCol`.
2279     * Returns the new cursor position (= startCol + span, clamped).
2280     *
2281     * @param list<?float> $widths
2282     */
2283    private function applyColWidth(\Phpdftk\Html\Dom\Element $col, array &$widths, int $startCol): int
2284    {
2285        $spanAttr = $col->getAttribute('span');
2286        $span = 1;
2287        if ($spanAttr !== null && preg_match('/^\d+$/', trim($spanAttr)) === 1) {
2288            $span = max(1, (int) trim($spanAttr));
2289        }
2290        $width = $this->parseLegacyWidth($col->getAttribute('width'));
2291        $end = min($startCol + $span, count($widths));
2292        for ($i = $startCol; $i < $end; $i++) {
2293            if ($widths[$i] === null) {
2294                $widths[$i] = $width;
2295            }
2296        }
2297        return $end;
2298    }
2299
2300    /**
2301     * HTML 5 legacy `width="N"` attribute parsing: plain integer
2302     * means pixels; trailing `%` (percentage) and `*` (proportional)
2303     * forms are Phase 2.
2304     */
2305    private function parseLegacyWidth(?string $raw): ?float
2306    {
2307        if ($raw === null) {
2308            return null;
2309        }
2310        $trim = trim($raw);
2311        if (preg_match('/^(\d+)$/', $trim, $m) === 1) {
2312            return (float) $m[1];
2313        }
2314        return null;
2315    }
2316
2317    /**
2318     * Build the per-column width array used by `layoutTableRow`. When
2319     * `$explicit` is null (no `<col>` info) every column gets an equal
2320     * share of the row width. Otherwise: explicit widths come through
2321     * directly; the remaining slack divides evenly across the auto
2322     * columns. When the explicit widths overflow the row width the
2323     * auto columns get 0 and the overflow is absorbed (no negative
2324     * widths).
2325     *
2326     * @param ?list<?float> $explicit
2327     * @return list<float>
2328     */
2329    private function resolveColumnWidthGrid(int $totalColumns, float $rowWidth, ?array $explicit): array
2330    {
2331        if ($totalColumns <= 0) {
2332            return [];
2333        }
2334        if ($explicit === null) {
2335            $share = $rowWidth / $totalColumns;
2336            return array_fill(0, $totalColumns, $share);
2337        }
2338        $explicitSum = 0.0;
2339        $autoCount = 0;
2340        foreach ($explicit as $w) {
2341            if ($w === null) {
2342                $autoCount++;
2343                continue;
2344            }
2345            $explicitSum += $w;
2346        }
2347        $autoShare = $autoCount > 0
2348            ? max(0.0, ($rowWidth - $explicitSum) / $autoCount)
2349            : 0.0;
2350        $out = [];
2351        foreach ($explicit as $w) {
2352            $out[] = $w ?? $autoShare;
2353        }
2354        return $out;
2355    }
2356
2357    /**
2358     * Pre-walk every `<tr>` descendant of a `<table>` to assign each
2359     * cell its resolved (row, col) coordinate and (colspan, rowspan)
2360     * extent, taking prior rows' rowspan-occupied columns into
2361     * account. HTML 5 Â§11.1.4 has the full algorithm; this is a
2362     * straightforward implementation of "growing a grid" â€” for each
2363     * row, walk cells in order, find the first free column from the
2364     * current cursor, claim the cells at (col..col+colspan-1) Ã—
2365     * (row..row+rowspan-1).
2366     *
2367     * @return array<int, array{row: int, col: int, rowspan: int, colspan: int}>
2368     */
2369    private function precomputeTableCellGrid(\Phpdftk\HtmlToPdf\Box\TableBox $table): array
2370    {
2371        /** @var array<int, array{row: int, col: int, rowspan: int, colspan: int}> $grid */
2372        $grid = [];
2373        $this->resolvedCellReferences = [];
2374        /** @var list<array<int, bool>> $occupancy occupancy[row][col] */
2375        $occupancy = [];
2376        $rowIndex = 0;
2377        $rows = $this->collectTableRows($table);
2378        foreach ($rows as $row) {
2379            if (!isset($occupancy[$rowIndex])) {
2380                $occupancy[$rowIndex] = [];
2381            }
2382            $col = 0;
2383            foreach ($row->children as $cell) {
2384                if (!($cell instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox)) {
2385                    continue;
2386                }
2387                $colspan = max(1, $this->cellColspan($cell));
2388                $rowspan = max(1, $this->resolveCellRowspan($cell));
2389                // Advance cursor past any column already claimed by a
2390                // prior row's rowspan.
2391                while (!empty($occupancy[$rowIndex][$col])) {
2392                    $col++;
2393                }
2394                $cellId = spl_object_id($cell);
2395                $grid[$cellId] = [
2396                    'row' => $rowIndex,
2397                    'col' => $col,
2398                    'rowspan' => $rowspan,
2399                    'colspan' => $colspan,
2400                ];
2401                $this->resolvedCellReferences[$cellId] = $cell;
2402                // Mark every covered (row, col) as occupied.
2403                for ($r = 0; $r < $rowspan; $r++) {
2404                    $absRow = $rowIndex + $r;
2405                    if (!isset($occupancy[$absRow])) {
2406                        $occupancy[$absRow] = [];
2407                    }
2408                    for ($c = 0; $c < $colspan; $c++) {
2409                        $occupancy[$absRow][$col + $c] = true;
2410                    }
2411                }
2412                $col += $colspan;
2413            }
2414            $rowIndex++;
2415        }
2416        return $grid;
2417    }
2418
2419    /**
2420     * Collect every TableRowBox descendant of `$table` in document
2421     * order (handles implicit `<tbody>` / explicit `<thead>` /
2422     * `<tfoot>` wrappers).
2423     *
2424     * @return list<\Phpdftk\HtmlToPdf\Box\TableRowBox>
2425     */
2426    private function collectTableRows(Box $table): array
2427    {
2428        $rows = [];
2429        $stack = [$table];
2430        $seenTable = false;
2431        while ($stack !== []) {
2432            $node = array_shift($stack);
2433            if ($node instanceof \Phpdftk\HtmlToPdf\Box\TableRowBox) {
2434                $rows[] = $node;
2435                continue;
2436            }
2437            if ($seenTable && $node instanceof \Phpdftk\HtmlToPdf\Box\TableBox) {
2438                // Skip nested tables â€” their rows belong to themselves.
2439                continue;
2440            }
2441            if ($node instanceof \Phpdftk\HtmlToPdf\Box\TableBox) {
2442                $seenTable = true;
2443            }
2444            $children = $node->children;
2445            foreach (array_reverse($children) as $c) {
2446                array_unshift($stack, $c);
2447            }
2448        }
2449        return $rows;
2450    }
2451
2452    /** @param array<int, array{row: int, col: int, rowspan: int, colspan: int}> $grid */
2453    private function maxColumnsFromGrid(array $grid): int
2454    {
2455        $max = 0;
2456        foreach ($grid as $entry) {
2457            $end = $entry['col'] + $entry['colspan'];
2458            if ($end > $max) {
2459                $max = $end;
2460            }
2461        }
2462        return $max;
2463    }
2464
2465    /**
2466     * Look up a cell's resolved column index from the precomputed
2467     * cell grid; falls back to `$cursorFallback` when no grid is
2468     * available (test fixtures laying out a row in isolation).
2469     */
2470    private function resolveCellColumn(\Phpdftk\HtmlToPdf\Box\TableCellBox $cell, int $cursorFallback): int
2471    {
2472        $grid = $this->currentTableCellGrid;
2473        if ($grid === null) {
2474            return $cursorFallback;
2475        }
2476        $id = spl_object_id($cell);
2477        return $grid[$id]['col'] ?? $cursorFallback;
2478    }
2479
2480    private function resolveRowIndex(\Phpdftk\HtmlToPdf\Box\TableRowBox $row): int
2481    {
2482        $grid = $this->currentTableCellGrid;
2483        if ($grid === null) {
2484            return -1;
2485        }
2486        // Find the row index from any of the row's cells.
2487        foreach ($row->children as $c) {
2488            if ($c instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox) {
2489                $entry = $grid[spl_object_id($c)] ?? null;
2490                if ($entry !== null) {
2491                    return $entry['row'];
2492                }
2493            }
2494        }
2495        return -1;
2496    }
2497
2498    /**
2499     * HTML 5 `<td rowspan="N">` / `<th rowspan="N">` â€” defaults to 1,
2500     * clamps to â‰¥ 1 even when the attribute is missing / non-numeric.
2501     */
2502    private function resolveCellRowspan(\Phpdftk\HtmlToPdf\Box\TableCellBox $cell): int
2503    {
2504        if ($cell->element === null) {
2505            return 1;
2506        }
2507        $raw = $cell->element->getAttribute('rowspan');
2508        if ($raw === null || preg_match('/^\d+$/', trim($raw)) !== 1) {
2509            return 1;
2510        }
2511        return max(1, (int) trim($raw));
2512    }
2513
2514    /**
2515     * Post-pass after every row in a table has been laid out â€” extend
2516     * each rowspan-spanning cell's `geometry->height` to cover every
2517     * row it spans, using `$currentTableRowHeights` populated in
2518     * `layoutTableRow`. Subsequent rows have already positioned in
2519     * their natural Y slots so the spanning cell just visually
2520     * stretches downward.
2521     */
2522    private function finalizeRowspanHeights(): void
2523    {
2524        $grid = $this->currentTableCellGrid;
2525        if ($grid === null) {
2526            return;
2527        }
2528        // We need the cell object to mutate its geometry â€” keep a
2529        // map of cellId â†’ cell while walking via spl_object_id.
2530        $byId = [];
2531        $stack = [];
2532        // Walk via the row-collection helper would require a table
2533        // ref â€” instead iterate the grid + look up cells from a fresh
2534        // walk. Simpler: walk the heights and mutate cells we have a
2535        // reference to (we already have them through the table's
2536        // child traversal).
2537        // The grid only stored metadata; mutate by finding cells via
2538        // the captured table reference. To avoid threading the table
2539        // through, store cell references in the grid entries too â€”
2540        // see below. For now the grid carries the metadata and we
2541        // expect the caller to have refreshed currentTableCellGrid
2542        // with cell references via storeCellReferences.
2543        foreach ($this->resolvedCellReferences as $id => $cell) {
2544            if (!isset($grid[$id])) {
2545                continue;
2546            }
2547            $entry = $grid[$id];
2548            if ($entry['rowspan'] <= 1) {
2549                continue;
2550            }
2551            $sum = 0.0;
2552            for ($r = 0; $r < $entry['rowspan']; $r++) {
2553                $sum += $this->currentTableRowHeights[$entry['row'] + $r] ?? 0.0;
2554            }
2555            // Only extend; never shrink below content height.
2556            if ($sum > $cell->geometry->height) {
2557                $cell->geometry->height = $sum;
2558            }
2559        }
2560    }
2561
2562    /**
2563     * Resolved cell references keyed by `spl_object_id`. Populated by
2564     * `precomputeTableCellGrid` so `finalizeRowspanHeights` can
2565     * mutate cells without re-walking the tree.
2566     *
2567     * @var array<int, \Phpdftk\HtmlToPdf\Box\TableCellBox>
2568     */
2569    private array $resolvedCellReferences = [];
2570
2571    /**
2572     * Reorder a `<table>`'s direct children so that `<caption>` boxes
2573     * with `caption-side: bottom` sit AFTER the rest of the table
2574     * content. Top-side captions (the default) stay in their original
2575     * relative position. Non-caption children keep document order
2576     * among themselves. CSS 2.1 Â§17.4.1.
2577     */
2578    private function reorderTableCaptions(\Phpdftk\HtmlToPdf\Box\TableBox $table): void
2579    {
2580        $top = [];
2581        $middle = [];
2582        $bottom = [];
2583        $hasBottomCaption = false;
2584        foreach ($table->children as $child) {
2585            if ($child->element !== null
2586                && strtolower($child->element->localName) === 'caption'
2587            ) {
2588                $side = $child->style->get('caption-side');
2589                if ($side instanceof Keyword && strtolower($side->name) === 'bottom') {
2590                    $bottom[] = $child;
2591                    $hasBottomCaption = true;
2592                    continue;
2593                }
2594                $top[] = $child;
2595                continue;
2596            }
2597            $middle[] = $child;
2598        }
2599        if (!$hasBottomCaption) {
2600            // Default top-only or no captions â€” children already in
2601            // the right order; skip the rebuild.
2602            return;
2603        }
2604        $table->children = array_merge($top, $middle, $bottom);
2605    }
2606
2607    private function isBorderCollapse(\Phpdftk\HtmlToPdf\Box\TableBox $table): bool
2608    {
2609        $v = $table->style->get('border-collapse');
2610        return $v instanceof \Phpdftk\Css\Value\Keyword
2611            && strtolower($v->name) === 'collapse';
2612    }
2613
2614    /**
2615     * Walk the table's rows + cells; zero out border-right widths on cells
2616     * that aren't the last in their row, and border-bottom widths on cells
2617     * that aren't in the last row. Net effect: adjacent cells share a
2618     * single border line instead of doubling â€” the simple-case
2619     * approximation of CSS Tables 3 Â§11.2 "collapsing borders model".
2620     */
2621    private function collapseBorders(\Phpdftk\HtmlToPdf\Box\TableBox $table): void
2622    {
2623        /** @var list<\Phpdftk\HtmlToPdf\Box\TableRowBox> $rows */
2624        $rows = [];
2625        // Pre-order DFS in document order via reversed-child push.
2626        $stack = [$table];
2627        while ($stack !== []) {
2628            $node = array_shift($stack);
2629            if ($node instanceof \Phpdftk\HtmlToPdf\Box\TableRowBox) {
2630                $rows[] = $node;
2631                continue;
2632            }
2633            $children = $node->children;
2634            foreach (array_reverse($children) as $c) {
2635                array_unshift($stack, $c);
2636            }
2637        }
2638        $rowCount = count($rows);
2639        foreach ($rows as $rIdx => $row) {
2640            $cells = array_values(array_filter(
2641                $row->children,
2642                static fn($c): bool => $c instanceof \Phpdftk\HtmlToPdf\Box\TableCellBox,
2643            ));
2644            $cellCount = count($cells);
2645            foreach ($cells as $cIdx => $cell) {
2646                if ($cIdx < $cellCount - 1) {
2647                    $cell->geometry->borderRight = 0.0;
2648                }
2649                if ($rIdx < $rowCount - 1) {
2650                    $cell->geometry->borderBottom = 0.0;
2651                }
2652            }
2653        }
2654    }
2655
2656    /**
2657     * HTML 5 `<td colspan>` / `<th colspan>` â€” defaults to 1, clamps to
2658     * â‰¥ 1 even when the attribute is missing / non-numeric.
2659     */
2660    private function cellColspan(\Phpdftk\HtmlToPdf\Box\TableCellBox $cell): int
2661    {
2662        if ($cell->element === null) {
2663            return 1;
2664        }
2665        $raw = $cell->element->getAttribute('colspan');
2666        if ($raw === null || preg_match('/^\d+$/', trim($raw)) !== 1) {
2667            return 1;
2668        }
2669        return max(1, (int) trim($raw));
2670    }
2671
2672    private function resolveInlineLengths(Box $box, LengthContext $context): void
2673    {
2674        // Walk every inline descendant and resolve its lengths against an
2675        // updated context. Each inline level may change `currentFontSize`
2676        // for its descendants (so `1em` on a grandchild reflects the
2677        // grandparent's resolved font-size).
2678        $this->cascade->resolveLengths($box->style, $context);
2679        $fontSizeValue = $box->style->get('font-size');
2680        $childContext = $context;
2681        if ($fontSizeValue instanceof Length) {
2682            $childContext = $context->withCurrentFontSize($fontSizeValue->value);
2683        }
2684        foreach ($box->children as $child) {
2685            $this->resolveInlineLengths($child, $childContext);
2686        }
2687    }
2688}