Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.37% covered (warning)
70.37%
696 / 989
41.38% covered (danger)
41.38%
24 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
BoxGenerator
70.37% covered (warning)
70.37%
696 / 989
41.38% covered (danger)
41.38%
24 / 58
7054.72
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
 generate
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 getNamedStrings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRunningElements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildElementBox
93.85% covered (success)
93.85%
168 / 179
0.00% covered (danger)
0.00%
0 / 1
79.41
 containsBlockLevel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 containsInFlowBlockLevel
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 isAbsolutelyPositioned
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 imageIsLoadable
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
9.38
 applyPictureSourceOverride
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
17.17
 sourceTypeAcceptable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 firstSrcsetUrl
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 parseSrcsetCandidates
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
8.06
 makePseudoBox
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 collectSelectOptions
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
13.19
 resolvePseudoContent
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
9.11
 resolveQuotePair
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 currentQuoteDepth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 contentItemAsString
81.36% covered (warning)
81.36%
48 / 59
0.00% covered (danger)
0.00%
0 / 1
41.49
 applyCounterReset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 extractRunningPositionName
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 applyStringSet
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 splitStringSetGroups
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
19.85
 resolveStringSetPart
58.06% covered (warning)
58.06%
18 / 31
0.00% covered (danger)
0.00%
0 / 1
41.89
 applyCounterSet
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 applyCounterIncrement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 forEachCounterPair
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
10.80
 formatCounter
7.50% covered (danger)
7.50%
3 / 40
0.00% covered (danger)
0.00%
0 / 1
218.61
 lowerGreek
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 hebrew
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 armenian
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 georgian
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 kanaList
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 bijectiveBase26
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 roman
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 flushInlineGroup
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 onlyCollapsibleWhitespace
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 stripWhitespaceTextChildren
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
 makeBox
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
 isForeignContentRoot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 foreignContentKind
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 isOutOfFlow
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 isFlexOrGridContainer
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 displayKeyword
17.65% covered (danger)
17.65%
3 / 17
0.00% covered (danger)
0.00%
0 / 1
65.85
 isInitialValue
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 expandDisplayContents
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
14.67
 applyPresentationalAttributes
68.57% covered (warning)
68.57%
72 / 105
0.00% covered (danger)
0.00%
0 / 1
171.76
 presentationalInsetsAndBoxSizing
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
15.96
 isAutoLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isReplacedSizeAuto
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 constrainReplacedNaturalSize
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
23.13
 hasSizeContainment
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 naturalImageSize
77.78% covered (warning)
77.78%
21 / 27
0.00% covered (danger)
0.00%
0 / 1
17.47
 resolveLocalImagePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseHtmlPercentage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseHtmlLength
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 mixesBlockAndInline
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 isInlineLevel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Box;
6
7use Phpdftk\Css\Cascade\Cascade;
8use Phpdftk\Css\Cascade\CascadedValues;
9use Phpdftk\Css\Sheet\Stylesheet;
10use Phpdftk\Css\Value\Keyword;
11use Phpdftk\Html\Dom\Document;
12use Phpdftk\Html\Dom\Element;
13use Phpdftk\Html\Dom\Text;
14
15/**
16 * Walks a parsed HTML document, runs the CSS cascade against each element,
17 * and emits the box tree.
18 *
19 * Phase 1E.1 implements the common path of CSS Display 3 box generation:
20 *  - `display: block` / `list-item` → {@see BlockBox}
21 *  - `display: inline` → {@see InlineBox}
22 *  - `display: inline-block` and replaced elements → {@see AtomicInlineBox}
23 *  - `display: none` → element + subtree skipped
24 *  - Text nodes inside any element → {@see TextBox}
25 *  - Anonymous block wrapping per CSS Display 3 §3.4 when a block parent
26 *    has mixed inline + block children
27 *
28 * Display values we don't yet generate for (table, flex, grid, ruby) fall
29 * through to BlockBox as a sensible default — layout will reject them in a
30 * dedicated message until those sub-phases ship.
31 *
32 * The flat-tree composition that Q11 calls for (slot distribution +
33 * shadow-tree traversal) lives in 1E.2; this version walks the light DOM.
34 */
35final class BoxGenerator
36{
37    /**
38     * Live CSS counter state during a single `generate()` walk. Keyed by
39     * counter name, value is the current count. Reset per generate; not
40     * shared across documents.
41     *
42     * @var array<string, int>
43     */
44    private array $counters = [];
45
46    /**
47     * CSS Generated Content for Paged Media 3 §5 — named-string
48     * store populated as `string-set` declarations flow through
49     * the document. Keyed by the string name (the first arg of
50     * `string-set: <name> <value>`), value is the resolved string.
51     * Page-margin painting reads this via {@see getNamedStrings}.
52     *
53     * @var array<string, string>
54     */
55    private array $namedStrings = [];
56
57    /**
58     * CSS Generated Content for Paged Media 3 §4 — running-element
59     * store populated by `position: running(name)` declarations.
60     * Keyed by the running name, value is the element's text
61     * content captured at the point the element was visited.
62     * Page-margin painting reads this via {@see getRunningElements}
63     * to resolve `content: element(name)`.
64     *
65     * @var array<string, string>
66     */
67    private array $runningElements = [];
68
69    public function __construct(
70        private readonly Cascade $cascade = new Cascade(),
71        /**
72         * Base directory for resolving local-file `<img src>` paths when
73         * reading intrinsic image dimensions. Same posture as the
74         * painter's `baseDir`: `null` disables local-file lookups (only
75         * `data:` URLs supply natural sizes); a non-null value joins
76         * relative paths and rejects any escape via `realpath()`.
77         */
78        private readonly ?string $baseDir = null,
79        /**
80         * Optional broader sandbox the resolved path must remain
81         * under (mirrors Painter / RendererOptions). Defaults to
82         * `baseDir`.
83         */
84        private readonly ?string $sandboxRoot = null,
85    ) {}
86
87    /**
88     * Generate a box tree from a parsed HTML document + a list of
89     * stylesheets in their cascade-origin order.
90     *
91     * @param list<Stylesheet> $sheets
92     */
93    public function generate(Document $document, array $sheets): ?Box
94    {
95        $root = $document->documentElement;
96        if ($root === null) {
97            return null;
98        }
99        $this->counters = [];
100        $this->namedStrings = [];
101        $this->runningElements = [];
102        return $this->buildElementBox($root, $sheets, null);
103    }
104
105    /**
106     * Snapshot of the named-string store accumulated during the
107     * last {@see generate} run. Used by the page-margin painter
108     * to resolve `content: string(name)` references in @page
109     * margin boxes.
110     *
111     * @return array<string, string>
112     */
113    public function getNamedStrings(): array
114    {
115        return $this->namedStrings;
116    }
117
118    /**
119     * Snapshot of the running-element store. Used by the page-
120     * margin painter to resolve `content: element(name)` against
121     * `position: running(name)` opt-outs in the document body.
122     *
123     * @return array<string, string>
124     */
125    public function getRunningElements(): array
126    {
127        return $this->runningElements;
128    }
129
130    /** @param list<Stylesheet> $sheets */
131    private function buildElementBox(
132        Element $element,
133        array $sheets,
134        ?CascadedValues $parentValues,
135    ): ?Box {
136        $values = $this->cascade->computeFor($sheets, $element, $parentValues);
137        $this->applyPresentationalAttributes(
138            $element,
139            $values,
140            $parentValues !== null && $this->isFlexOrGridContainer($parentValues),
141        );
142        $display = $this->displayKeyword($values);
143        if ($display === 'none') {
144            return null;
145        }
146        // CSS Grid 3 §11 — `display: grid-lanes` (masonry layout)
147        // currently aliases to `display: grid` with `grid-auto-flow`
148        // derived from which axis the author specified tracks for.
149        // For a row-axis grid-lanes (only `grid-template-rows` set),
150        // items flow column-major (auto-flow: column) and the
151        // column tracks grow implicitly. Symmetric for column-axis.
152        // The actual masonry algorithm (variable-height packing
153        // across tracks) is a future enhancement; this aliasing
154        // already lights up the empty-container / order /
155        // basic-placement subset of the corpus.
156        if ($display === 'grid-lanes' || $display === 'inline-grid-lanes') {
157            $hasRowTracks = !$this->isInitialValue($values->get('grid-template-rows'));
158            $hasColTracks = !$this->isInitialValue($values->get('grid-template-columns'));
159            if ($hasRowTracks && !$hasColTracks) {
160                $values->set('grid-auto-flow', new Keyword('column'));
161            }
162            $values->set('display', new Keyword($display === 'inline-grid-lanes' ? 'inline-grid' : 'grid'));
163            $display = $display === 'inline-grid-lanes' ? 'inline-grid' : 'grid';
164        }
165        // CSS Writing Modes 3 §2 — `direction` doesn't apply to
166        // certain internal-table display types (table-row-group /
167        // -header-group / -footer-group / -row / -column /
168        // -column-group). Browsers reset the value to the inherited
169        // one from the parent rather than honouring an explicit
170        // declaration on the row-group itself. Force the cascade back
171        // to the parent's resolved direction (or `ltr` at the root)
172        // so descendants don't pick up an invalid declaration via
173        // inheritance.
174        if (in_array($display, ['table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-column', 'table-column-group'], true)) {
175            $parentDirection = $parentValues?->get('direction');
176            if ($parentDirection instanceof Keyword) {
177                $values->set('direction', $parentDirection);
178            } else {
179                $values->set('direction', new Keyword('ltr'));
180            }
181        }
182        // CSS Display 3 §3.2.1 — `display: contents` on the root
183        // element is "blockified": the value is treated as `block`
184        // so the root still generates a box and its background /
185        // borders still propagate to the canvas. The caller (the
186        // top-level `generate()` entry) hits this for the document
187        // root only; in-tree `display: contents` is handled by the
188        // expansion in the child-collection loop below.
189        if ($display === 'contents' && $parentValues === null) {
190            $values->set('display', new Keyword('block'));
191            $display = 'block';
192        }
193        // CSS 2.1 §9.7 + CSS Display §2.7 — out-of-flow elements
194        // (position: absolute / fixed, float: left / right) are
195        // "blockified": an inline-level computed `display` becomes
196        // `block` so the box participates in the abs-pos / float
197        // layout pipeline rather than the inline-flow line-box
198        // path. This is what `<img position: absolute; left: 7.5px>`
199        // needs to honour its corner anchors (#21, also unblocks
200        // the SVG-embed positioning fixture in #143).
201        //
202        // Foreign content (root <math> and <svg>) is intentionally
203        // excluded: those elements route through their own atomic-
204        // inline painters (`paintInlineMath` / `paintInlineSvg`)
205        // that already honour cascaded `position` / `left` / `top`
206        // via `resolveInlineAbsoluteOrigin`. Blockifying them
207        // would re-route through the generic block pipeline that
208        // doesn't know how to delegate to those painters — and
209        // doing so regresses `mathml/presentation-markup/spaces/
210        // space-3` (which uses `<math style="position: absolute;
211        // top: 0; left: 0">`).
212        if (in_array($display, ['inline', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table'], true)
213            && $this->isOutOfFlow($values)
214            && !$this->isForeignContentRoot($element)
215        ) {
216            $values->set('display', new Keyword('block'));
217            $display = 'block';
218        }
219        // Foreign-content roots (`<svg>` / `<math>`) are replaced
220        // atomic-inline boxes routed to the dedicated foreign painters.
221        // The UA sheet's `svg, math { display: inline-block }` matches the
222        // unprefixed form by tag name; the prefixed XHTML form
223        // (`<svg:svg>`, localName `"svg:svg"`) misses that selector and
224        // would fall back to generic inline flow. Force any inline-level
225        // foreign root to `inline-block` so it generates an
226        // `AtomicInlineBox`.
227        if ($this->isForeignContentRoot($element)
228            && in_array($display, ['inline', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table'], true)
229        ) {
230            $values->set('display', new Keyword('inline-block'));
231            $display = 'inline-block';
232        }
233        // CSS Containment 2 §4 — `content-visibility: hidden`
234        // suppresses box generation just like `display: none` for
235        // static print. (`auto` is a runtime-visibility optimisation
236        // with no print equivalent and is treated as `visible`.)
237        $cv = $values->get('content-visibility');
238        if ($cv instanceof Keyword && strtolower($cv->name) === 'hidden') {
239            return null;
240        }
241        // CSS GCPM 3 §4 — `position: running(<name>)` opts the
242        // element out of normal flow and into the running-element
243        // store. No box is generated; the element's text content
244        // becomes available to @page margin boxes via
245        // `content: element(<name>)`.
246        $runningName = $this->extractRunningPositionName($values);
247        if ($runningName !== null) {
248            $this->runningElements[$runningName] = $element->textContent();
249            return null;
250        }
251
252        // CSS Generated Content 3 §2: apply counter-reset (creates +
253        // sets), then counter-set (sets to a specific value WITHOUT
254        // creating a new scope), then counter-increment (bumps) so any
255        // `::before` content that reads `counter()` sees the post-
256        // increment value at this element's position in document order.
257        // counter-set's distinction from counter-reset is scope-related;
258        // since BoxGenerator carries a single flat counter table for
259        // print render rather than a per-scope stack, both reduce to the
260        // same write here but the property is still honoured rather than
261        // silently dropped.
262        $this->applyCounterReset($values);
263        $this->applyCounterSet($values);
264        $this->applyCounterIncrement($values);
265        $this->applyStringSet($element, $values);
266
267        // HTML `<br>` produces a sentinel line-break box — a hard break
268        // inside the parent inline formatting context that survives
269        // whitespace collapsing under `white-space: normal`.
270        if (strtolower($element->localName) === 'br') {
271            return new LineBreakBox($element, $values);
272        }
273        // HTML 5 `<wbr>` — a soft-break opportunity. Lower to an
274        // `InlineBox` carrying a zero-width-space TextBox so UAX #14 has
275        // a wrap point even when surrounding text doesn't.
276        if (strtolower($element->localName) === 'wbr') {
277            $inline = new InlineBox($element, $values);
278            $inline->addChild(new TextBox($element, $values, "\u{200B}"));
279            return $inline;
280        }
281
282        // HTML 5 §4.8.4.2 — when the `<img>` is wrapped in a
283        // `<picture>`, walk the preceding `<source>` siblings to
284        // pick the best one for the print medium. Phase-1 honours
285        // a `media` attribute containing `print` or `all` (or an
286        // absent media attribute, which means "all media").
287        if (strtolower($element->localName) === 'img') {
288            $this->applyPictureSourceOverride($element);
289            // HTML 5 §4.8.3 — the alt-text fallback only kicks in when
290            // the image *can't* be painted: missing src, an unloadable
291            // source, or an unsupported format. When the painter can
292            // resolve the src, render the image; otherwise hand the
293            // alt text to inline layout as a synthetic TextBox so the
294            // surrounding flow doesn't collapse to nothing.
295            $alt = $element->getAttribute('alt');
296            if ($alt !== null && $alt !== '' && !$this->imageIsLoadable($element)) {
297                $inline = new InlineBox($element, $values);
298                $inline->addChild(new TextBox($element, $values, $alt));
299                return $inline;
300            }
301        }
302
303        // HTML 5 §4.10.5.1: `<input type="text|email|search|...">` renders
304        // its `value` attribute as static text for print output. PDF
305        // AcroForm field generation is a Phase 2 task. Emit as an
306        // `InlineBox` so the text child flows through inline layout.
307        if (strtolower($element->localName) === 'input') {
308            $type = strtolower($element->getAttribute('type') ?? 'text');
309            // HTML 5 §4.10.5.1.7 — `<input type=hidden>` is never
310            // rendered, regardless of the `hidden` attribute on
311            // its ancestors.
312            if ($type === 'hidden') {
313                return null;
314            }
315            $textTypes = ['text', 'email', 'search', 'tel', 'url', 'number', 'date', 'time', 'datetime-local'];
316            if (in_array($type, $textTypes, true)) {
317                $inline = new InlineBox($element, $values);
318                $value = $element->getAttribute('value') ?? '';
319                if ($value !== '') {
320                    $inline->addChild(new TextBox($element, $values, $value));
321                }
322                return $inline;
323            }
324            // HTML 5 §4.10.5.1.15 — password fields render their
325            // value as a sequence of U+2022 bullets so the printed
326            // form keeps a sense of "this field is populated"
327            // without leaking the value.
328            if ($type === 'password') {
329                $inline = new InlineBox($element, $values);
330                $value = $element->getAttribute('value') ?? '';
331                if ($value !== '') {
332                    $masked = str_repeat("\u{2022}", mb_strlen($value, 'UTF-8'));
333                    $inline->addChild(new TextBox($element, $values, $masked));
334                }
335                return $inline;
336            }
337            // HTML 5 §4.10.5.1.21 — `<input type=file>` renders a
338            // placeholder label since the actual file picker is
339            // interactive. The chosen filename never reaches a
340            // server-side print render.
341            if ($type === 'file') {
342                $inline = new InlineBox($element, $values);
343                $inline->addChild(new TextBox($element, $values, 'No file chosen'));
344                return $inline;
345            }
346            // HTML 5 §4.10.5.1.18: button-type inputs render the
347            // `value` as the button label. Phase 2 will paint them as
348            // proper PDF widget annotations; for now we just emit the
349            // label text inline.
350            $buttonTypes = ['button', 'submit', 'reset'];
351            if (in_array($type, $buttonTypes, true)) {
352                $inline = new InlineBox($element, $values);
353                $label = $element->getAttribute('value');
354                if ($label === null || $label === '') {
355                    // HTML 5 default labels when `value` is missing.
356                    $label = match ($type) {
357                        'submit' => 'Submit',
358                        'reset' => 'Reset',
359                        default => '',
360                    };
361                }
362                if ($label !== '') {
363                    $inline->addChild(new TextBox($element, $values, $label));
364                }
365                return $inline;
366            }
367            // Checkbox / radio — render an ASCII visual indicator so
368            // form-print output stays informative without depending on
369            // ☐/☑ glyphs in the user's font.
370            if ($type === 'checkbox' || $type === 'radio') {
371                $checked = $element->getAttribute('checked') !== null;
372                $marker = $type === 'checkbox'
373                    ? ($checked ? '[x] ' : '[ ] ')
374                    : ($checked ? '(o) ' : '( ) ');
375                $inline = new InlineBox($element, $values);
376                $inline->addChild(new TextBox($element, $values, $marker));
377                return $inline;
378            }
379        }
380
381        // HTML 5 §4.5.27: `<wbr>` (Word Break Opportunity) is a void
382        // inline element that just marks a permissible line break.
383        // Emit a U+200B (zero-width space) text child — it has zero
384        // advance width but the line breaker recognises it as a break
385        // opportunity, so a long unbroken token wrapping a `<wbr>`
386        // can split at that point.
387        if (strtolower($element->localName) === 'wbr') {
388            $inline = new InlineBox($element, $values);
389            $inline->addChild(new TextBox($element, $values, "\u{200B}"));
390            return $inline;
391        }
392
393        // HTML 5 §4.10.7: `<select>` renders only its currently-selected
394        // `<option>` in static print output (no dropdown widget).
395        //  - Single-select (default): one option, the first one with
396        //    `selected` (else the first option).
397        //  - `<select multiple>`: every option with `selected` (else
398        //    the empty selection), each on its own line.
399        // `<optgroup label="...">` labels the contained options with
400        // an inline-level "label: " prefix so the print form keeps
401        // the grouping visible.
402        if (strtolower($element->localName) === 'select') {
403            $isMultiple = $element->getAttribute('multiple') !== null;
404            /** @var list<array{label: ?string, option: Element}> $renderedOptions */
405            $renderedOptions = $this->collectSelectOptions($element, $isMultiple);
406            $inline = new InlineBox($element, $values);
407            foreach ($renderedOptions as $i => $entry) {
408                if ($i > 0) {
409                    // Separate multi-select entries with a newline so
410                    // they stack across lines instead of running on.
411                    $inline->addChild(new TextBox($element, $values, "\n"));
412                }
413                if ($entry['label'] !== null) {
414                    $inline->addChild(new TextBox($element, $values, $entry['label'] . ': '));
415                }
416                $text = $entry['option']->textContent();
417                if ($text !== '') {
418                    $inline->addChild(new TextBox($element, $values, $text));
419                }
420            }
421            return $inline;
422        }
423
424        $box = $this->makeBox($element, $values, $display);
425
426        // Walk children, building child boxes. Text nodes become TextBoxes.
427        // `::before` is generated content prepended to the element's own
428        // children; `::after` is appended. Both are inline boxes carrying a
429        // synthetic TextBox of the `content` string. Phase-1 supports
430        // `content: <string>` only — `attr()`, `counter()`, `open-quote` /
431        // `close-quote`, etc. fall through to the `normal` initial.
432        $rawChildren = [];
433        $before = $this->makePseudoBox($element, $sheets, $values, 'before');
434        if ($before !== null) {
435            $rawChildren[] = $before;
436        }
437        for ($n = $element->firstChild; $n !== null; $n = $n->nextSibling) {
438            if ($n instanceof Element) {
439                // CSS Display 3 §3.2 — `display: contents` makes the
440                // element generate no box of its own; its children
441                // render as if they were direct children of this
442                // element's parent (i.e. the box we're currently
443                // building). Recurse via the helper so nested
444                // `display: contents` chains flatten cleanly.
445                $childCascade = $this->cascade->computeFor($sheets, $n, $values);
446                $this->applyPresentationalAttributes($n, $childCascade, $this->isFlexOrGridContainer($values));
447                if ($this->displayKeyword($childCascade) === 'contents') {
448                    foreach ($this->expandDisplayContents($n, $sheets, $values) as $grandchild) {
449                        $rawChildren[] = $grandchild;
450                    }
451                    continue;
452                }
453                $child = $this->buildElementBox($n, $sheets, $values);
454                if ($child !== null) {
455                    $rawChildren[] = $child;
456                }
457            } elseif ($n instanceof Text) {
458                if ($n->data === '') {
459                    continue;
460                }
461                $rawChildren[] = new TextBox($element, $values, $n->data);
462            }
463            // Comments and other node types are dropped.
464        }
465        $after = $this->makePseudoBox($element, $sheets, $values, 'after');
466        if ($after !== null) {
467            $rawChildren[] = $after;
468        }
469
470        // CSS Flexbox 1 §4 / CSS Grid Layout 2 §6: an anonymous flex /
471        // grid item that contains only whitespace is not rendered (as
472        // if its text nodes were `display: none`). Without this filter
473        // the trailing `\n` after `<div class="box"></div>` becomes a
474        // second flex item and consumes the slack `justify-content`
475        // would otherwise distribute.
476        if ($box instanceof FlexBox || $box instanceof GridBox) {
477            $rawChildren = $this->stripWhitespaceTextChildren($rawChildren, $values);
478        }
479
480        // CSS 2.1 §9.2.1.1 — when an inline box has a block-level
481        // descendant, the inline box splits around the block. The
482        // block sits between two anonymous inline halves, all
483        // wrapped in an anonymous block. We implement the simpler
484        // single-level case: an InlineBox / AtomicInlineBox whose
485        // immediate `$rawChildren` contain at least one block-level
486        // child gets promoted to an AnonymousBlockBox whose children
487        // are alternating (anonymous inline halves around blocks).
488        // The original element's cascade rides on the
489        // AnonymousBlockBox so `position: relative` on the inline
490        // still affects the block half per spec.
491        $splitsAroundBlock = $box instanceof AtomicInlineBox
492            ? $this->containsInFlowBlockLevel($rawChildren)
493            : $this->containsBlockLevel($rawChildren);
494        if (($box instanceof InlineBox || $box instanceof AtomicInlineBox)
495            && $splitsAroundBlock
496        ) {
497            $promoted = new AnonymousBlockBox($element, $values);
498            $inlineGroup = [];
499            foreach ($rawChildren as $child) {
500                if ($this->isInlineLevel($child)) {
501                    $inlineGroup[] = $child;
502                    continue;
503                }
504                if ($inlineGroup !== []) {
505                    $half = new InlineBox($element, $values);
506                    foreach ($inlineGroup as $g) {
507                        $half->addChild($g);
508                    }
509                    $promoted->addChild($half);
510                    $inlineGroup = [];
511                }
512                $promoted->addChild($child);
513            }
514            if ($inlineGroup !== []) {
515                $half = new InlineBox($element, $values);
516                foreach ($inlineGroup as $g) {
517                    $half->addChild($g);
518                }
519                $promoted->addChild($half);
520            }
521            return $promoted;
522        }
523
524        // Anonymous-block wrapping per CSS Display 3 §3.4: only inside
525        // block-context parents whose children mix block + inline.
526        $needsAnonymous = $box instanceof BlockBox && $this->mixesBlockAndInline($rawChildren);
527        if (!$needsAnonymous) {
528            foreach ($rawChildren as $child) {
529                $box->addChild($child);
530            }
531            return $box;
532        }
533
534        // Run through children; group contiguous inline-ish children under
535        // an AnonymousBlockBox sharing the parent's style.
536        $inlineGroup = [];
537        foreach ($rawChildren as $child) {
538            if ($this->isInlineLevel($child)) {
539                $inlineGroup[] = $child;
540                continue;
541            }
542            $this->flushInlineGroup($box, $values, $inlineGroup);
543            $inlineGroup = [];
544            $box->addChild($child);
545        }
546        $this->flushInlineGroup($box, $values, $inlineGroup);
547        return $box;
548    }
549
550    /**
551     * `true` when any direct child of `$children` is block-level.
552     * Used by the block-in-inline split (CSS 2.1 §9.2.1.1) to detect
553     * when an inline box needs promotion to an anonymous block.
554     *
555     * @param list<Box> $children
556     */
557    private function containsBlockLevel(array $children): bool
558    {
559        foreach ($children as $c) {
560            if (!$this->isInlineLevel($c)) {
561                return true;
562            }
563        }
564        return false;
565    }
566
567    /** @param list<Box> $children */
568    private function containsInFlowBlockLevel(array $children): bool
569    {
570        foreach ($children as $c) {
571            if (!$this->isInlineLevel($c) && !$this->isAbsolutelyPositioned($c->style)) {
572                return true;
573            }
574        }
575        return false;
576    }
577
578    /**
579     * `position: absolute | fixed` only — NOT floats. A floated block child
580     * still interacts with the inline box's layout in ways our flex /
581     * multicol paths depend on, so it keeps triggering blockification;
582     * only genuinely out-of-flow (abs-pos) children are skipped.
583     */
584    private function isAbsolutelyPositioned(CascadedValues $values): bool
585    {
586        $position = $values->get('position');
587        return $position instanceof Keyword
588            && in_array(strtolower($position->name), ['absolute', 'fixed'], true);
589    }
590
591    /**
592     * HTML 5 §4.8.4.2 — when an `<img>` is the fallback inside a
593     * `<picture>`, the browser walks the `<source>` siblings and
594     * picks the first one whose `media` attribute matches. For
595     * print rendering: pick the first `<source>` with
596     * `media="print"` (or `media="all"` or no media attribute) and
597     * use the first URL of its `srcset` as the effective `src`.
598     *
599     * Mutates the element's `src` attribute in place — feels
600     * intrusive but means the existing painter code that reads
601     * `$element->getAttribute('src')` Just Works without any
602     * extra plumbing through the box tree.
603     */
604    /**
605     * `true` when the painter can resolve the `<img>`'s `src` to bytes
606     * the renderer can paint. Drives the alt-text fallback in
607     * `boxForElement`: a loadable image keeps its `AtomicInlineBox`
608     * status (painted as an Image XObject or routed through the SVG
609     * renderer for `image/svg+xml`); an unloadable one falls back to
610     * the alt text so the surrounding inline flow still has content.
611     *
612     * Looks for `src` first, then `srcset` (first candidate only —
613     * full responsive selection is a separate substrate gate). Data
614     * URLs are loadable when the MIME label is one the painter
615     * recognises; local paths are loadable when they parse as one
616     * of the supported formats via {@see ImageParser}.
617     */
618    private function imageIsLoadable(Element $img): bool
619    {
620        $src = $img->getAttribute('src');
621        if ($src === null || $src === '') {
622            return false;
623        }
624        if (str_starts_with($src, 'data:')) {
625            // The renderer's data: handlers accept image/png, image/jpeg
626            // (raster), image/svg+xml, plus a few siblings the painter
627            // routes the same way. Anything else falls back to alt.
628            return preg_match(
629                '~^data:image/(png|jpe?g|gif|bmp|webp|svg\+xml|tiff?|jpeg2000|jbig2)\b~i',
630                $src,
631            ) === 1;
632        }
633        if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) {
634            // Network sources are loadable only when the renderer has
635            // a resource loader attached. The painter still handles
636            // the actual fetch (and any errors there fall back to
637            // the no-image path), but we want the box-generator
638            // decision to track loader configuration.
639            return false;
640        }
641        return $this->naturalImageSize($src) !== null;
642    }
643
644    private function applyPictureSourceOverride(Element $img): void
645    {
646        $parent = $img->parentNode;
647        if (!($parent instanceof Element)
648            || strtolower($parent->localName) !== 'picture'
649        ) {
650            return;
651        }
652        foreach ($parent->children() as $sibling) {
653            if ($sibling === $img) {
654                continue;
655            }
656            if (strtolower($sibling->localName) !== 'source') {
657                continue;
658            }
659            $media = $sibling->getAttribute('media');
660            if ($media !== null && $media !== '') {
661                $lower = strtolower(trim($media));
662                if ($lower !== 'all' && !str_contains($lower, 'print')) {
663                    continue;
664                }
665            }
666            // HTML 5 §4.8.4.2.4 — `<source type="image/...">` lets the
667            // author flag a format hint. Skip a source whose declared
668            // MIME isn't a format the painter can decode (currently
669            // PNG + JPEG via the image-metadata pipeline).
670            $type = $sibling->getAttribute('type');
671            if ($type !== null && $type !== '' && !$this->sourceTypeAcceptable($type)) {
672                continue;
673            }
674            $srcset = $sibling->getAttribute('srcset');
675            if ($srcset === null || trim($srcset) === '') {
676                continue;
677            }
678            $url = $this->firstSrcsetUrl($srcset);
679            if ($url !== null && $url !== '') {
680                $img->setAttribute('src', $url);
681                return;
682            }
683        }
684    }
685
686    /**
687     * Return true when the `<source type="...">` MIME indicates a
688     * format the painter can render. Print PDF supports raster PNG
689     * and JPEG today; anything else (AVIF, WebP, HEIF, SVG-as-image)
690     * gets skipped so the next `<source>` or the `<img>` fallback
691     * wins.
692     */
693    private function sourceTypeAcceptable(string $type): bool
694    {
695        $lower = strtolower(trim($type));
696        $supported = ['image/png', 'image/jpeg', 'image/jpg'];
697        return in_array($lower, $supported, true);
698    }
699
700    /**
701     * Pick the best `srcset` candidate for print. HTML 5 §4.8.4.2.4
702     * syntax: comma-separated `url [descriptor]` pairs where the
703     * descriptor is `Nx` (density) or `Nw` (width). When no
704     * descriptor is given, defaults to `1x`.
705     *
706     * Print rendering targets high resolution (300+ DPI) so the
707     * algorithm picks the candidate with the highest density. Width
708     * descriptors are converted to "approximate density" using a
709     * reference width of 100 (so a 200w candidate counts as 2x,
710     * 400w as 4x). Bare candidates count as 1x. On ties the first
711     * declared candidate wins.
712     */
713    private function firstSrcsetUrl(string $srcset): ?string
714    {
715        $candidates = $this->parseSrcsetCandidates($srcset);
716        if ($candidates === []) {
717            return null;
718        }
719        $best = null;
720        $bestDensity = -INF;
721        foreach ($candidates as $cand) {
722            if ($cand['density'] > $bestDensity) {
723                $best = $cand['url'];
724                $bestDensity = $cand['density'];
725            }
726        }
727        return $best;
728    }
729
730    /**
731     * Parse a `srcset` value into a list of `{url, density}` candidates.
732     * Descriptor parsing:
733     *  - `Nx` → density = N
734     *  - `Nw` → density ≈ N / 100 (matches typical author intent)
735     *  - missing → density = 1
736     *  - unrecognised descriptor → candidate is dropped
737     *
738     * @return list<array{url: string, density: float}>
739     */
740    private function parseSrcsetCandidates(string $srcset): array
741    {
742        $out = [];
743        foreach (explode(',', $srcset) as $part) {
744            $part = trim($part);
745            if ($part === '') {
746                continue;
747            }
748            $tokens = preg_split('/\s+/', $part, 2) ?: [];
749            $url = $tokens[0] ?? '';
750            if ($url === '') {
751                continue;
752            }
753            $descriptor = trim($tokens[1] ?? '');
754            if ($descriptor === '') {
755                $out[] = ['url' => $url, 'density' => 1.0];
756                continue;
757            }
758            if (preg_match('/^([0-9]*\.?[0-9]+)([xw])$/i', $descriptor, $m) !== 1) {
759                continue;
760            }
761            $value = (float) $m[1];
762            $unit = strtolower($m[2]);
763            $density = $unit === 'x' ? $value : $value / 100.0;
764            $out[] = ['url' => $url, 'density' => $density];
765        }
766        return $out;
767    }
768
769    /**
770     * Build a pseudo-element box (`::before` / `::after`) for an element
771     * when the cascade produces a non-`none` / non-`normal` `content`
772     * value. Returns null when no rule targets the pseudo, or when the
773     * content keyword indicates no generated box.
774     *
775     * @param list<Stylesheet> $sheets
776     */
777    private function makePseudoBox(
778        Element $element,
779        array $sheets,
780        CascadedValues $hostValues,
781        string $pseudoName,
782    ): ?Box {
783        $pseudoValues = $this->cascade->computeFor($sheets, $element, $hostValues, $pseudoName);
784        $content = $pseudoValues->get('content');
785        $text = $this->resolvePseudoContent($content, $element, $pseudoValues);
786        if ($text === null) {
787            return null;
788        }
789        $display = $this->displayKeyword($pseudoValues);
790        // CSS Display 3 §3.2 — `display: contents` on a pseudo-element
791        // suppresses its box entirely; its generated `content` flows
792        // into the parent as if it were a plain text node carrying
793        // the pseudo's inherited text styles. Skip the box wrap and
794        // return a TextBox directly so the pseudo's border / background
795        // / etc. don't paint (the pseudo has no box).
796        if ($display === 'contents') {
797            return $text === '' ? null : new TextBox($element, $pseudoValues, $text);
798        }
799        // Pseudo-elements default to `inline` when no `display` rule fires.
800        $pseudo = $this->makeBox($element, $pseudoValues, $display);
801        if ($text !== '') {
802            $pseudo->addChild(new TextBox($element, $pseudoValues, $text));
803        }
804        return $pseudo;
805    }
806
807    /**
808     * Walk a `<select>`'s children (and one level of `<optgroup>`)
809     * collecting the `<option>` elements to render. For single-select,
810     * returns at most one entry — the first option with `selected`
811     * else the first option overall. For `<select multiple>`, returns
812     * every option carrying `selected`. Each entry pairs the option
813     * with the optgroup label that contains it (or null).
814     *
815     * @return list<array{label: ?string, option: Element}>
816     */
817    private function collectSelectOptions(Element $select, bool $multiple): array
818    {
819        /** @var list<array{label: ?string, option: Element}> $available */
820        $available = [];
821        /** @var list<array{label: ?string, option: Element}> $selectedEntries */
822        $selectedEntries = [];
823        foreach ($select->children() as $child) {
824            if (!$child instanceof Element) {
825                continue;
826            }
827            $tag = strtolower($child->localName);
828            if ($tag === 'option') {
829                $entry = ['label' => null, 'option' => $child];
830                $available[] = $entry;
831                if ($child->getAttribute('selected') !== null) {
832                    $selectedEntries[] = $entry;
833                }
834            } elseif ($tag === 'optgroup') {
835                $label = $child->getAttribute('label');
836                foreach ($child->children() as $grand) {
837                    if (!$grand instanceof Element) {
838                        continue;
839                    }
840                    if (strtolower($grand->localName) !== 'option') {
841                        continue;
842                    }
843                    $entry = ['label' => $label, 'option' => $grand];
844                    $available[] = $entry;
845                    if ($grand->getAttribute('selected') !== null) {
846                        $selectedEntries[] = $entry;
847                    }
848                }
849            }
850        }
851        if ($multiple) {
852            return $selectedEntries;
853        }
854        if ($selectedEntries !== []) {
855            return [$selectedEntries[0]];
856        }
857        if ($available !== []) {
858            return [$available[0]];
859        }
860        return [];
861    }
862
863    /**
864     * Resolve the `content` value to a plain string. Returns null when the
865     * pseudo-element should produce no box (`none` / `normal` / unsupported
866     * generators like `counter()` / `<image>` — Phase 2). Returns the empty
867     * string when `content` is explicitly an empty string (the pseudo box
868     * still generates).
869     *
870     * Supports `<string>`, `attr(name)`, and any space-joined list of those.
871     */
872    private function resolvePseudoContent(?\Phpdftk\Css\Value\Value $value, Element $host, CascadedValues $values): ?string
873    {
874        if ($value === null) {
875            return null;
876        }
877        if ($value instanceof Keyword) {
878            $name = strtolower($value->name);
879            if ($name === 'none' || $name === 'normal') {
880                return null;
881            }
882            // Other keywords (open-quote / close-quote / no-open-quote /
883            // no-close-quote / etc.) fall through to `contentItemAsString`
884            // which produces the right glyph.
885        }
886        $item = $this->contentItemAsString($value, $host, $values);
887        if ($item !== null) {
888            return $item;
889        }
890        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
891            $out = '';
892            foreach ($value->values as $v) {
893                $piece = $this->contentItemAsString($v, $host, $values);
894                if ($piece === null) {
895                    // Unsupported component (counter/url/etc.) — bail.
896                    return null;
897                }
898                $out .= $piece;
899            }
900            return $out;
901        }
902        return null;
903    }
904
905    /**
906     * Translate a single content-list item into a plain string, returning
907     * null when the item isn't a Phase-1 supported producer. Handles
908     * `<string>`, `attr(name)`, and the `open-quote` / `close-quote`
909     * keywords. Phase-1 emits an ASCII double quote for both — full
910     * `quotes` property + nesting depth tracking lands in a follow-up.
911     */
912    /**
913     * Resolve CSS Generated Content 3 §3.1 `quotes` to the
914     * `[openQuote, closeQuote]` pair for `open-quote` / `close-quote`
915     * content keywords. `auto` (initial value) defers to the
916     * typographic default U+201C / U+201D ("smart quotes"). `none`
917     * suppresses quote glyphs entirely — both `open-quote` and
918     * `close-quote` evaluate to the empty string in that case.
919     * Explicit string lists are paired open/close; nested-depth
920     * tracking through ancestor `<q>` chains picks the pair at the
921     * current depth (clamping to the last pair when nesting exceeds
922     * the list).
923     *
924     * @return array{0:string, 1:string}|null  Null means `quotes: none`.
925     */
926    private function resolveQuotePair(CascadedValues $values, int $depth): ?array
927    {
928        $value = $values->get('quotes');
929        if ($value instanceof Keyword && strtolower($value->name) === 'none') {
930            return null;
931        }
932        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
933            $pairs = [];
934            $strings = [];
935            foreach ($value->values as $v) {
936                if ($v instanceof \Phpdftk\Css\Value\StringValue) {
937                    $strings[] = $v->value;
938                    if (count($strings) === 2) {
939                        $pairs[] = [$strings[0], $strings[1]];
940                        $strings = [];
941                    }
942                }
943            }
944            if ($pairs !== []) {
945                $idx = max(0, min($depth, count($pairs) - 1));
946                return $pairs[$idx];
947            }
948        }
949        // U+201C LEFT DOUBLE QUOTATION MARK + U+201D RIGHT DOUBLE
950        // QUOTATION MARK — the typographic default for English. Other
951        // locales (German „..." / French «...») are Phase 2 once the
952        // cascade tracks `:lang()`-driven UA stylesheets.
953        return ["\u{201C}", "\u{201D}"];
954    }
955
956    /**
957     * Walk up the host element's `<q>` ancestor chain to compute the
958     * current quote nesting depth. Each enclosing `<q>` bumps the
959     * depth by one. The depth is what indexes into the `quotes`
960     * property's pair list.
961     */
962    private function currentQuoteDepth(Element $host): int
963    {
964        $depth = 0;
965        $node = $host->parentNode;
966        while ($node !== null) {
967            if ($node instanceof Element && strtolower($node->localName) === 'q') {
968                $depth++;
969            }
970            $node = $node->parentNode;
971        }
972        return $depth;
973    }
974
975    private function contentItemAsString(\Phpdftk\Css\Value\Value $value, Element $host, CascadedValues $values): ?string
976    {
977        if ($value instanceof \Phpdftk\Css\Value\StringValue) {
978            return $value->value;
979        }
980        if ($value instanceof Keyword) {
981            $kw = strtolower($value->name);
982            if ($kw === 'open-quote' || $kw === 'close-quote') {
983                $depth = $this->currentQuoteDepth($host);
984                $pair = $this->resolveQuotePair($values, $depth);
985                if ($pair === null) {
986                    return '';
987                }
988                return $kw === 'open-quote' ? $pair[0] : $pair[1];
989            }
990            return match ($kw) {
991                'no-open-quote', 'no-close-quote' => '',
992                default => null,
993            };
994        }
995        // CSS Values 5 §11 typed AttrFunction (preferred path).
996        if ($value instanceof \Phpdftk\Css\Value\AttrFunction) {
997            $name = $value->attributeName;
998            if ($name !== '') {
999                $attrValue = $host->getAttribute($name);
1000                if ($attrValue !== null) {
1001                    return $attrValue;
1002                }
1003                // Fallback expression on missing attribute — use
1004                // its serialized form for now (the typed fallback
1005                // value lands when AttrFunction is consumed by
1006                // computed-value time).
1007                if ($value->fallback !== null) {
1008                    return $value->fallback->toCss();
1009                }
1010                return '';
1011            }
1012        }
1013        // Legacy generic CssFunction path for value-paths that
1014        // bypass Parser::makeDeclaration.
1015        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1016            && strtolower($value->name) === 'attr'
1017            && $value->arguments !== []
1018        ) {
1019            $arg = $value->arguments[0];
1020            $name = null;
1021            if ($arg instanceof Keyword) {
1022                $name = $arg->name;
1023            } elseif ($arg instanceof \Phpdftk\Css\Value\StringValue) {
1024                $name = $arg->value;
1025            }
1026            if ($name !== null && $name !== '') {
1027                $attrValue = $host->getAttribute($name);
1028                return $attrValue ?? '';
1029            }
1030        }
1031        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1032            && strtolower($value->name) === 'counter'
1033            && $value->arguments !== []
1034        ) {
1035            $nameArg = $value->arguments[0];
1036            if ($nameArg instanceof Keyword) {
1037                $count = $this->counters[$nameArg->name] ?? 0;
1038                $style = isset($value->arguments[1]) && $value->arguments[1] instanceof Keyword
1039                    ? strtolower($value->arguments[1]->name)
1040                    : 'decimal';
1041                return $this->formatCounter($count, $style);
1042            }
1043        }
1044        // CSS Generated Content 3 §2.3 — `counters(name, separator, style?)`
1045        // formats the nested chain of counters with `name` joined by
1046        // `separator`. The cascade doesn't track per-scope counter
1047        // stacks yet, so this falls back to formatting the single
1048        // current value (matching the common authored use case
1049        // `counters(foo, ".")` on a non-nested counter).
1050        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1051            && strtolower($value->name) === 'counters'
1052            && count($value->arguments) >= 2
1053        ) {
1054            $nameArg = $value->arguments[0];
1055            $sepArg = $value->arguments[1];
1056            if (!$nameArg instanceof Keyword || !$sepArg instanceof \Phpdftk\Css\Value\StringValue) {
1057                return null;
1058            }
1059            $count = $this->counters[$nameArg->name] ?? 0;
1060            $style = isset($value->arguments[2]) && $value->arguments[2] instanceof Keyword
1061                ? strtolower($value->arguments[2]->name)
1062                : 'decimal';
1063            // Single-scope fallback: emit the current counter value
1064            // (no separator-joining since there's no nested chain).
1065            // The separator is retained for grammar compatibility.
1066            return $this->formatCounter($count, $style);
1067        }
1068        // `content: url(...)` is a replaced-element generator. For
1069        // Phase-2 we accept the syntax but emit no text — the pseudo
1070        // box still generates so author CSS targeting it (e.g.
1071        // `::before { content: url(badge.png); margin-right: 4px }`)
1072        // doesn't get silently dropped. Image insertion through
1073        // generated content is a follow-up requiring XObject hooks
1074        // to thread through pseudo-element generation.
1075        if ($value instanceof \Phpdftk\Css\Value\Url) {
1076            return '';
1077        }
1078        return null;
1079    }
1080
1081    /**
1082     * Apply `counter-reset: <name> [<int>]?` declarations to {@see counters}.
1083     * Multiple name/value pairs in a list are supported.
1084     */
1085    private function applyCounterReset(CascadedValues $values): void
1086    {
1087        $value = $values->get('counter-reset');
1088        $this->forEachCounterPair($value, function (string $name, int $defaultOrSpecified): void {
1089            $this->counters[$name] = $defaultOrSpecified;
1090        }, defaultValue: 0);
1091    }
1092
1093    /**
1094     * Extract `<name>` from `position: running(<name>)` when the
1095     * cascaded `position` value is a generic CssFunction wrapping
1096     * a bare ident argument. Returns null for any other position
1097     * value, so normal positioning (static / relative / absolute
1098     * / fixed) keeps its existing layout path.
1099     */
1100    private function extractRunningPositionName(CascadedValues $values): ?string
1101    {
1102        $pos = $values->get('position');
1103        if (!($pos instanceof \Phpdftk\Css\Value\CssFunction)
1104            || strtolower($pos->name) !== 'running'
1105            || $pos->arguments === []
1106        ) {
1107            return null;
1108        }
1109        $arg = $pos->arguments[0];
1110        if (!($arg instanceof Keyword)) {
1111            return null;
1112        }
1113        return $arg->name;
1114    }
1115
1116    /**
1117     * Apply `string-set: <name> <content-list>` declarations — set
1118     * the named string value to a resolved content list. Used by
1119     * GCPM 3 §5 for running headers / footers.
1120     *
1121     * Supported `<content-list>` items for the initial pass:
1122     *
1123     *   - `<string>` literal       → emit literally
1124     *   - `content()`              → emit the element's text content
1125     *   - `attr(name)`             → emit the named attribute value
1126     *
1127     * Multiple `string-set` pairs may appear in a comma-separated
1128     * list; each pair is processed independently. Unsupported
1129     * content-list items are silently skipped so an unrecognised
1130     * form doesn't corrupt the rest of the assignment.
1131     */
1132    private function applyStringSet(Element $element, CascadedValues $values): void
1133    {
1134        $value = $values->get('string-set');
1135        if ($value === null
1136            || ($value instanceof Keyword && strtolower($value->name) === 'none')
1137        ) {
1138            return;
1139        }
1140        $groups = $this->splitStringSetGroups($value);
1141        foreach ($groups as $group) {
1142            if (count($group) < 2) {
1143                continue;
1144            }
1145            $head = $group[0];
1146            if (!($head instanceof Keyword)) {
1147                continue;
1148            }
1149            $name = $head->name;
1150            $resolved = '';
1151            for ($i = 1; $i < count($group); $i++) {
1152                $resolved .= $this->resolveStringSetPart($group[$i], $element);
1153            }
1154            $this->namedStrings[$name] = $resolved;
1155        }
1156    }
1157
1158    /**
1159     * Split the `string-set` value into the per-name groups —
1160     * `string-set: a "x", b "y"` becomes `[[Kw(a), "x"], [Kw(b), "y"]]`.
1161     * Each group is a name followed by a content list. Top-level
1162     * commas are separators between groups; everything else is
1163     * part of the current group.
1164     *
1165     * @return list<list<\Phpdftk\Css\Value\Value>>
1166     */
1167    private function splitStringSetGroups(\Phpdftk\Css\Value\Value $value): array
1168    {
1169        if (!($value instanceof \Phpdftk\Css\Value\ValueList)) {
1170            return [[$value]];
1171        }
1172        if ($value->separator === \Phpdftk\Css\Value\ListSeparator::Comma) {
1173            $out = [];
1174            foreach ($value->values as $item) {
1175                $out[] = $item instanceof \Phpdftk\Css\Value\ValueList
1176                    && $item->separator === \Phpdftk\Css\Value\ListSeparator::Space
1177                        ? $item->values
1178                        : [$item];
1179            }
1180            return $out;
1181        }
1182        return [$value->values];
1183    }
1184
1185    private function resolveStringSetPart(\Phpdftk\Css\Value\Value $value, Element $host): string
1186    {
1187        if ($value instanceof \Phpdftk\Css\Value\StringValue) {
1188            return $value->value;
1189        }
1190        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1191            && strtolower($value->name) === 'content'
1192        ) {
1193            // CSS GCPM 3 §5.1 — `content()` reads the host element's
1194            // text content. Arguments select sub-text (text, before,
1195            // after, first-letter); only the default form is honoured
1196            // here for now.
1197            return $host->textContent();
1198        }
1199        if ($value instanceof \Phpdftk\Css\Value\AttrFunction) {
1200            $name = $value->attributeName;
1201            if ($name === '') {
1202                return '';
1203            }
1204            return $host->getAttribute($name) ?? '';
1205        }
1206        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1207            && strtolower($value->name) === 'attr'
1208            && $value->arguments !== []
1209        ) {
1210            $arg = $value->arguments[0];
1211            $name = $arg instanceof Keyword ? $arg->name : null;
1212            if ($name === null || $name === '') {
1213                return '';
1214            }
1215            return $host->getAttribute($name) ?? '';
1216        }
1217        if ($value instanceof \Phpdftk\Css\Value\CssFunction
1218            && strtolower($value->name) === 'counter'
1219            && $value->arguments !== []
1220        ) {
1221            // CSS GCPM 3 §5.1 — `counter(<name> [, <style>]?)` inside
1222            // string-set emits the current counter value at this
1223            // element. Reuses the existing counter store + formatter.
1224            $args = $value->arguments;
1225            $head = $args[0];
1226            if (!($head instanceof Keyword)) {
1227                return '';
1228            }
1229            $count = $this->counters[$head->name] ?? 0;
1230            $style = 'decimal';
1231            if (isset($args[1]) && $args[1] instanceof Keyword) {
1232                $style = strtolower($args[1]->name);
1233            }
1234            return $this->formatCounter($count, $style);
1235        }
1236        return '';
1237    }
1238
1239    /**
1240     * Apply `counter-set: <name> [<int>]?` declarations — sets the
1241     * named counter to the specified value (default 0), without the
1242     * scope-creating semantics of `counter-reset`. CSS Lists 3 §6.
1243     */
1244    private function applyCounterSet(CascadedValues $values): void
1245    {
1246        $value = $values->get('counter-set');
1247        $this->forEachCounterPair($value, function (string $name, int $defaultOrSpecified): void {
1248            $this->counters[$name] = $defaultOrSpecified;
1249        }, defaultValue: 0);
1250    }
1251
1252    /**
1253     * Apply `counter-increment: <name> [<int>]?` declarations — bumps the
1254     * named counter by the specified delta (default +1).
1255     */
1256    private function applyCounterIncrement(CascadedValues $values): void
1257    {
1258        $value = $values->get('counter-increment');
1259        $this->forEachCounterPair($value, function (string $name, int $delta): void {
1260            $this->counters[$name] = ($this->counters[$name] ?? 0) + $delta;
1261        }, defaultValue: 1);
1262    }
1263
1264    /**
1265     * Walk a `counter-reset` / `counter-increment` value and invoke the
1266     * callback for each `<name> [<int>]?` pair encountered. Handles single
1267     * Keyword, single Keyword + Integer, and Space-separated `ValueList`
1268     * shapes. Skips when the value is the `none` keyword.
1269     *
1270     * @param \Closure(string, int): void $cb
1271     */
1272    private function forEachCounterPair(?\Phpdftk\Css\Value\Value $value, \Closure $cb, int $defaultValue): void
1273    {
1274        if ($value === null
1275            || ($value instanceof Keyword && strtolower($value->name) === 'none')
1276        ) {
1277            return;
1278        }
1279        if ($value instanceof Keyword) {
1280            $cb($value->name, $defaultValue);
1281            return;
1282        }
1283        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
1284            $items = $value->values;
1285            $i = 0;
1286            $n = count($items);
1287            while ($i < $n) {
1288                if (!($items[$i] instanceof Keyword)) {
1289                    $i++;
1290                    continue;
1291                }
1292                $name = $items[$i]->name;
1293                if ($i + 1 < $n && $items[$i + 1] instanceof \Phpdftk\Css\Value\Integer) {
1294                    $cb($name, $items[$i + 1]->value);
1295                    $i += 2;
1296                } else {
1297                    $cb($name, $defaultValue);
1298                    $i++;
1299                }
1300            }
1301        }
1302    }
1303
1304    /**
1305     * Format `$count` per `$style`. Supports the CSS Counter Styles 3 §6
1306     * predefined styles:
1307     *
1308     *  - decimal, decimal-leading-zero
1309     *  - lower-alpha / upper-alpha (aliases lower-latin / upper-latin)
1310     *  - lower-roman / upper-roman
1311     *  - lower-greek (α β γ ...)
1312     *  - cjk-decimal (Chinese decimal — uses the same arabic digits but
1313     *    appended with U+3001 punctuation per browsers' implementation)
1314     *  - hebrew (Hebrew letter numerals 1-999)
1315     *  - armenian / lower-armenian / upper-armenian (1-9999)
1316     *  - georgian (1-19999)
1317     *  - hiragana / hiragana-iroha (Japanese kana ordering)
1318     *  - katakana / katakana-iroha
1319     *
1320     * Unknown style names fall back to decimal.
1321     */
1322    private function formatCounter(int $count, string $style): string
1323    {
1324        return match ($style) {
1325            'decimal-leading-zero' => sprintf('%02d', $count),
1326            'lower-alpha', 'lower-latin' => $this->bijectiveBase26($count, lower: true),
1327            'upper-alpha', 'upper-latin' => $this->bijectiveBase26($count, lower: false),
1328            'lower-roman' => strtolower($this->roman($count)),
1329            'upper-roman' => $this->roman($count),
1330            'lower-greek' => $this->lowerGreek($count),
1331            'hebrew' => $this->hebrew($count),
1332            'armenian', 'upper-armenian' => $this->armenian($count, lower: false),
1333            'lower-armenian' => $this->armenian($count, lower: true),
1334            'georgian' => $this->georgian($count),
1335            'hiragana' => $this->kanaList($count, [
1336                'あ','い','う','え','お','か','き','く','け','こ',
1337                'さ','し','す','せ','そ','た','ち','つ','て','と',
1338                'な','に','ぬ','ね','の','は','ひ','ふ','へ','ほ',
1339                'ま','み','む','め','も','や','ゆ','よ','ら','り',
1340                'る','れ','ろ','わ','ゐ','ゑ','を','ん',
1341            ]),
1342            'hiragana-iroha' => $this->kanaList($count, [
1343                'い','ろ','は','に','ほ','へ','と','ち','り','ぬ',
1344                'る','を','わ','か','よ','た','れ','そ','つ','ね',
1345                'な','ら','む','う','ゐ','の','お','く','や','ま',
1346                'け','ふ','こ','え','て','あ','さ','き','ゆ','め',
1347                'み','し','ゑ','ひ','も','せ','す',
1348            ]),
1349            'katakana' => $this->kanaList($count, [
1350                'ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ',
1351                'サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト',
1352                'ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ',
1353                'マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ',
1354                'ル','レ','ロ','ワ','ヰ','ヱ','ヲ','ン',
1355            ]),
1356            'katakana-iroha' => $this->kanaList($count, [
1357                'イ','ロ','ハ','ニ','ホ','ヘ','ト','チ','リ','ヌ',
1358                'ル','ヲ','ワ','カ','ヨ','タ','レ','ソ','ツ','ネ',
1359                'ナ','ラ','ム','ウ','ヰ','ノ','オ','ク','ヤ','マ',
1360                'ケ','フ','コ','エ','テ','ア','サ','キ','ユ','メ',
1361                'ミ','シ','ヱ','ヒ','モ','セ','ス',
1362            ]),
1363            default => (string) $count,
1364        };
1365    }
1366
1367    /**
1368     * CSS Counter Styles 3 §6.4 — lower-greek. 1-24 maps to α-ω
1369     * (skipping final-σ and using ς in position 18 per the spec
1370     * actually uses non-final sigma). Values outside 1-24 wrap
1371     * via bijective base-24 over the alphabet.
1372     */
1373    private function lowerGreek(int $n): string
1374    {
1375        $alphabet = [
1376            'α','β','γ','δ','ε','ζ','η','θ','ι','κ','λ','μ',
1377            'ν','ξ','ο','π','ρ','σ','τ','υ','φ','χ','ψ','ω',
1378        ];
1379        if ($n < 1) {
1380            return (string) $n;
1381        }
1382        $out = '';
1383        while ($n > 0) {
1384            $n--;
1385            $out = $alphabet[$n % 24] . $out;
1386            $n = intdiv($n, 24);
1387        }
1388        return $out;
1389    }
1390
1391    /**
1392     * CSS Counter Styles 3 §6.5 — hebrew numerals 1-999.
1393     * Out-of-range falls back to decimal.
1394     */
1395    private function hebrew(int $n): string
1396    {
1397        if ($n < 1 || $n > 999) {
1398            return (string) $n;
1399        }
1400        $map = [
1401            400 => 'ת', 300 => 'ש', 200 => 'ר', 100 => 'ק',
1402            90  => 'צ', 80  => 'פ', 70  => 'ע', 60  => 'ס',
1403            50  => 'נ', 40  => 'מ', 30  => 'ל', 20  => 'כ',
1404            19  => 'יט', 18 => 'יח', 17 => 'יז', 16 => 'טז', 15 => 'טו',
1405            14  => 'יד', 13 => 'יג', 12 => 'יב', 11 => 'יא',
1406            10  => 'י',
1407            9 => 'ט', 8 => 'ח', 7 => 'ז', 6 => 'ו', 5 => 'ה',
1408            4 => 'ד', 3 => 'ג', 2 => 'ב', 1 => 'א',
1409        ];
1410        $out = '';
1411        foreach ($map as $v => $s) {
1412            while ($n >= $v) {
1413                $out .= $s;
1414                $n -= $v;
1415            }
1416        }
1417        return $out;
1418    }
1419
1420    /**
1421     * CSS Counter Styles 3 §6.7 — Armenian numerals. Range 1-9999.
1422     */
1423    private function armenian(int $n, bool $lower): string
1424    {
1425        if ($n < 1 || $n > 9999) {
1426            return (string) $n;
1427        }
1428        $upperOnes = ['Ա','Բ','Գ','Դ','Ե','Զ','Է','Ը','Թ'];
1429        $upperTens = ['Ժ','Ի','Լ','Խ','Ծ','Կ','Հ','Ձ','Ղ'];
1430        $upperHundreds = ['Ճ','Մ','Յ','Ն','Շ','Ո','Չ','Պ','Ջ'];
1431        $upperThousands = ['Ռ','Ս','Վ','Տ','Ր','Ց','Ւ','Փ','Ք'];
1432        $thousands = intdiv($n, 1000);
1433        $hundreds = intdiv($n % 1000, 100);
1434        $tens = intdiv($n % 100, 10);
1435        $ones = $n % 10;
1436        $out = '';
1437        if ($thousands > 0) {
1438            $out .= $upperThousands[$thousands - 1];
1439        }
1440        if ($hundreds > 0) {
1441            $out .= $upperHundreds[$hundreds - 1];
1442        }
1443        if ($tens > 0) {
1444            $out .= $upperTens[$tens - 1];
1445        }
1446        if ($ones > 0) {
1447            $out .= $upperOnes[$ones - 1];
1448        }
1449        return $lower ? mb_strtolower($out, 'UTF-8') : $out;
1450    }
1451
1452    /**
1453     * CSS Counter Styles 3 §6.6 — Georgian numerals (Mkhedruli).
1454     * Range 1-19999.
1455     */
1456    private function georgian(int $n): string
1457    {
1458        if ($n < 1 || $n > 19999) {
1459            return (string) $n;
1460        }
1461        $ones = ['ა','ბ','გ','დ','ე','ვ','ზ','ჱ','თ'];
1462        $tens = ['ი','კ','ლ','მ','ნ','ჲ','ო','პ','ჟ'];
1463        $hundreds = ['რ','ს','ტ','ჳ','ფ','ქ','ღ','ყ','შ'];
1464        $thousands = ['ჩ','ც','ძ','წ','ჭ','ხ','ჴ','ჯ','ჰ'];
1465        $tt = intdiv($n, 10000);
1466        $h = intdiv($n % 10000, 1000);
1467        $t = intdiv($n % 1000, 100);
1468        $te = intdiv($n % 100, 10);
1469        $o = $n % 10;
1470        $out = '';
1471        if ($tt > 0) {
1472            $out .= 'ჵ';
1473        }
1474        if ($h > 0) {
1475            $out .= $thousands[$h - 1];
1476        }
1477        if ($t > 0) {
1478            $out .= $hundreds[$t - 1];
1479        }
1480        if ($te > 0) {
1481            $out .= $tens[$te - 1];
1482        }
1483        if ($o > 0) {
1484            $out .= $ones[$o - 1];
1485        }
1486        return $out;
1487    }
1488
1489    /**
1490     * Generic kana / alphabetic style — bijective expansion over
1491     * the supplied symbol list. Used by hiragana / katakana
1492     * (gojuon + iroha orderings).
1493     *
1494     * @param list<string> $symbols
1495     */
1496    private function kanaList(int $n, array $symbols): string
1497    {
1498        if ($n < 1 || $symbols === []) {
1499            return (string) $n;
1500        }
1501        $base = count($symbols);
1502        $out = '';
1503        while ($n > 0) {
1504            $n--;
1505            $out = $symbols[$n % $base] . $out;
1506            $n = intdiv($n, $base);
1507        }
1508        return $out;
1509    }
1510
1511    private function bijectiveBase26(int $n, bool $lower): string
1512    {
1513        if ($n <= 0) {
1514            return (string) $n;
1515        }
1516        $out = '';
1517        while ($n > 0) {
1518            $n--;
1519            $out = chr(($lower ? ord('a') : ord('A')) + ($n % 26)) . $out;
1520            $n = intdiv($n, 26);
1521        }
1522        return $out;
1523    }
1524
1525    private function roman(int $n): string
1526    {
1527        if ($n < 1 || $n > 3999) {
1528            return (string) $n;
1529        }
1530        $map = [
1531            1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD',
1532            100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL',
1533            10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I',
1534        ];
1535        $out = '';
1536        foreach ($map as $v => $s) {
1537            while ($n >= $v) {
1538                $out .= $s;
1539                $n -= $v;
1540            }
1541        }
1542        return $out;
1543    }
1544
1545    /** @param list<Box> $inlineGroup */
1546    private function flushInlineGroup(Box $parent, CascadedValues $values, array $inlineGroup): void
1547    {
1548        if ($inlineGroup === []) {
1549            return;
1550        }
1551        // CSS 2.1 §9.2.2.1 / Display 3 §3.4 — anonymous block boxes that
1552        // contain only whitespace text are removed during box generation.
1553        // The whitespace was already going to collapse to nothing in
1554        // inline layout, but keeping the wrapper as an empty box still
1555        // breaks adjacent-sibling margin collapse on the parent (the
1556        // first sibling's `margin-bottom` no longer adjoins the next
1557        // sibling's `margin-top`).
1558        if ($this->onlyCollapsibleWhitespace($inlineGroup, $values)) {
1559            return;
1560        }
1561        // CSS Display 3 §3.4 — anonymous block boxes have no element
1562        // and inherit only the inheritable properties from their
1563        // parent. Crucially, non-inherited properties (background,
1564        // width, height, border, padding, margin, …) MUST stay at
1565        // their initial values; otherwise an anonymous wrapper around
1566        // a run of whitespace text inside a `width: 100px; height:
1567        // 100px; background: black` parent would paint a second
1568        // 100×100 black rect at the cursor.
1569        $anonValues = $this->cascade->anonymousFromParent($values);
1570        $anon = new AnonymousBlockBox(null, $anonValues);
1571        foreach ($inlineGroup as $c) {
1572            $anon->addChild($c);
1573        }
1574        $parent->addChild($anon);
1575    }
1576
1577    /**
1578     * True when every entry in `$inlineGroup` is a TextBox carrying only
1579     * ASCII / Unicode collapsible whitespace — and the parent's
1580     * `white-space` value lets that whitespace collapse. Whitespace-
1581     * preserving values (`pre`, `pre-wrap`, `pre-line`, `break-spaces`)
1582     * keep the run, since the spec says we have to lay them out.
1583     *
1584     * @param list<Box> $inlineGroup
1585     */
1586    private function onlyCollapsibleWhitespace(array $inlineGroup, CascadedValues $values): bool
1587    {
1588        $whiteSpace = $values->get('white-space');
1589        if ($whiteSpace instanceof Keyword) {
1590            $kw = strtolower($whiteSpace->name);
1591            if ($kw === 'pre' || $kw === 'pre-wrap' || $kw === 'pre-line' || $kw === 'break-spaces') {
1592                return false;
1593            }
1594        }
1595        foreach ($inlineGroup as $box) {
1596            if (!$box instanceof TextBox) {
1597                return false;
1598            }
1599            if (preg_match('/^[\s\x{200B}]*$/u', $box->text) !== 1) {
1600                return false;
1601            }
1602        }
1603        return true;
1604    }
1605
1606    /**
1607     * Drop direct TextBox children whose text is entirely collapsible
1608     * whitespace when the parent is a flex / grid container, matching
1609     * the "anonymous whitespace flex item is not rendered" rule in
1610     * CSS Flexbox 1 §4 (echoed by CSS Grid Layout 2 §6).
1611     *
1612     * @param  list<Box> $rawChildren
1613     * @return list<Box>
1614     */
1615    private function stripWhitespaceTextChildren(array $rawChildren, CascadedValues $values): array
1616    {
1617        $whiteSpace = $values->get('white-space');
1618        if ($whiteSpace instanceof Keyword) {
1619            $kw = strtolower($whiteSpace->name);
1620            if ($kw === 'pre' || $kw === 'pre-wrap' || $kw === 'pre-line' || $kw === 'break-spaces') {
1621                return $rawChildren;
1622            }
1623        }
1624        $out = [];
1625        foreach ($rawChildren as $child) {
1626            if ($child instanceof TextBox
1627                && preg_match('/^[\s\x{200B}]*$/u', $child->text) === 1
1628            ) {
1629                continue;
1630            }
1631            $out[] = $child;
1632        }
1633        return $out;
1634    }
1635
1636    private function makeBox(Element $element, CascadedValues $values, string $display): Box
1637    {
1638        return match ($display) {
1639            'inline' => new InlineBox($element, $values),
1640            'inline-block', 'inline-table', 'inline-flex', 'inline-grid'
1641                => new AtomicInlineBox($element, $values),
1642            'table' => new TableBox($element, $values),
1643            'table-row' => new TableRowBox($element, $values),
1644            'table-cell' => new TableCellBox($element, $values),
1645            'table-column', 'table-column-group' => new TableColumnBox($element, $values),
1646            'flex' => new FlexBox($element, $values),
1647            'grid' => new GridBox($element, $values),
1648            default => new BlockBox($element, $values),
1649        };
1650    }
1651
1652    /**
1653     * Root foreign-content elements (`<math>` and `<svg>`) route
1654     * through dedicated atomic-inline painters that resolve their
1655     * own positioning via `resolveInlineAbsoluteOrigin`. They must
1656     * NOT be blockified by the CSS Display §2.7 out-of-flow rule
1657     * because the generic block pipeline doesn't know how to
1658     * delegate to those painters. See the `mathml/spaces/space-3`
1659     * regression note where blockification dropped the inline-math
1660     * paint entirely.
1661     */
1662    private function isForeignContentRoot(Element $element): bool
1663    {
1664        return self::foreignContentKind($element) !== null;
1665    }
1666
1667    /**
1668     * Classify an element as a foreign-content root: `'svg'`, `'math'`,
1669     * or `null`. Handles both the standard namespaced form (`<svg>` in
1670     * the SVG namespace) AND the prefixed XHTML form (`<svg:svg>` /
1671     * `<math:math>`), which the HTML parser leaves as a plain element
1672     * with `localName` like `"svg:svg"` and the HTML namespace — the
1673     * prefix then identifies the foreign content.
1674     */
1675    public static function foreignContentKind(Element $element): ?string
1676    {
1677        $tag = strtolower($element->localName);
1678        $colon = strrpos($tag, ':');
1679        $prefixed = $colon !== false;
1680        $local = $prefixed ? substr($tag, $colon + 1) : $tag;
1681        $ns = $element->namespaceUri();
1682        if ($local === 'math' && ($prefixed || $ns === \Phpdftk\Mathml\Parser::MATHML_NS)) {
1683            return 'math';
1684        }
1685        if ($local === 'svg' && ($prefixed || $ns === \Phpdftk\Svg\Parser::SVG_NS)) {
1686            return 'svg';
1687        }
1688        return null;
1689    }
1690
1691    /**
1692     * CSS 2.1 §9.7 — an element is "out-of-flow" when its `position`
1693     * is `absolute` / `fixed` or its `float` is `left` / `right`.
1694     * Out-of-flow elements are blockified: an inline-level computed
1695     * `display` becomes `block`.
1696     */
1697    private function isOutOfFlow(CascadedValues $values): bool
1698    {
1699        $position = $values->get('position');
1700        if ($position instanceof Keyword) {
1701            $name = strtolower($position->name);
1702            if ($name === 'absolute' || $name === 'fixed') {
1703                return true;
1704            }
1705        }
1706        $float = $values->get('float');
1707        if ($float instanceof Keyword) {
1708            $name = strtolower($float->name);
1709            if ($name === 'left' || $name === 'right') {
1710                return true;
1711            }
1712        }
1713        return false;
1714    }
1715
1716    /**
1717     * `true` when the cascaded display establishes a flex or grid
1718     * formatting context, so its children are flex / grid items whose
1719     * min/max sizing is owned by the flex / grid algorithm rather than
1720     * the replaced-element box-gen constraint pass.
1721     */
1722    private function isFlexOrGridContainer(CascadedValues $values): bool
1723    {
1724        return in_array(
1725            $this->displayKeyword($values),
1726            ['flex', 'inline-flex', 'grid', 'inline-grid'],
1727            true,
1728        );
1729    }
1730
1731    private function displayKeyword(CascadedValues $values): string
1732    {
1733        $display = $values->get('display');
1734        if ($display instanceof Keyword) {
1735            return strtolower($display->name);
1736        }
1737        // CSS Display 3 §2 — `display: <outside> <inside>` (the two-
1738        // keyword syntax, e.g. `display: inline grid` /
1739        // `display: inline grid-lanes`) parses as a ValueList of
1740        // Keywords. We compose them into the canonical single
1741        // keyword form (`inline-grid`, `inline-grid-lanes`) so the
1742        // rest of BoxGenerator's `match ($display)` paths keep
1743        // working as before.
1744        if ($display instanceof \Phpdftk\Css\Value\ValueList) {
1745            $names = [];
1746            foreach ($display->values as $v) {
1747                if ($v instanceof Keyword) {
1748                    $names[] = strtolower($v->name);
1749                }
1750            }
1751            if ($names !== []) {
1752                $outside = $names[0];
1753                $inside = $names[1] ?? null;
1754                if ($outside === 'inline' && $inside !== null) {
1755                    return 'inline-' . $inside;
1756                }
1757                if ($outside === 'block' && $inside !== null) {
1758                    return $inside;
1759                }
1760                return implode('-', $names);
1761            }
1762        }
1763        return 'inline';
1764    }
1765
1766    /**
1767     * Returns `true` when the cascaded value is the property's initial
1768     * (or a `none` / `auto` keyword that's effectively initial for the
1769     * track-list properties). Used by the `display: grid-lanes`
1770     * aliasing to decide which axis the author specified tracks for.
1771     */
1772    private function isInitialValue(?\Phpdftk\Css\Value\Value $value): bool
1773    {
1774        if ($value === null) {
1775            return true;
1776        }
1777        if ($value instanceof Keyword) {
1778            $name = strtolower($value->name);
1779            return $name === 'none' || $name === 'auto' || $name === 'initial';
1780        }
1781        return false;
1782    }
1783
1784    /**
1785     * CSS Display 3 §3.2 — expand a `display: contents` element's
1786     * children as if they were direct children of the parent that
1787     * called us. Nested `display: contents` elements flatten
1788     * recursively. Text node children become {@see TextBox}es
1789     * attached to the parent's text cascade (the parent passed in
1790     * `$parentValues`).
1791     *
1792     * Pseudo-element generation (`::before` / `::after`) on a
1793     * `display: contents` element is honoured because the cascade
1794     * runs as normal — the pseudos generate boxes which get
1795     * collected here just like any other child.
1796     *
1797     * @param list<Stylesheet> $sheets
1798     * @return list<Box>
1799     */
1800    private function expandDisplayContents(
1801        Element $element,
1802        array $sheets,
1803        CascadedValues $parentValues,
1804    ): array {
1805        $values = $this->cascade->computeFor($sheets, $element, $parentValues);
1806        $this->applyPresentationalAttributes($element, $values);
1807        $result = [];
1808        // `::before` generated content participates in the flattened
1809        // child stream — it's defined on the display:contents element
1810        // so it visually still attaches "at" that element's position.
1811        $before = $this->makePseudoBox($element, $sheets, $values, 'before');
1812        if ($before !== null) {
1813            $result[] = $before;
1814        }
1815        for ($n = $element->firstChild; $n !== null; $n = $n->nextSibling) {
1816            if ($n instanceof Element) {
1817                $childCascade = $this->cascade->computeFor($sheets, $n, $values);
1818                $this->applyPresentationalAttributes($n, $childCascade, $this->isFlexOrGridContainer($values));
1819                if ($this->displayKeyword($childCascade) === 'contents') {
1820                    foreach ($this->expandDisplayContents($n, $sheets, $values) as $g) {
1821                        $result[] = $g;
1822                    }
1823                    continue;
1824                }
1825                $box = $this->buildElementBox($n, $sheets, $values);
1826                if ($box !== null) {
1827                    $result[] = $box;
1828                }
1829            } elseif ($n instanceof Text) {
1830                if ($n->data === '') {
1831                    continue;
1832                }
1833                // Text node children of a display:contents element
1834                // attach as TextBoxes using the element's own cascade
1835                // (so the contents-styled element's inherited
1836                // text properties still apply, even though the
1837                // element itself produces no box).
1838                $result[] = new TextBox($element, $values, $n->data);
1839            }
1840        }
1841        $after = $this->makePseudoBox($element, $sheets, $values, 'after');
1842        if ($after !== null) {
1843            $result[] = $after;
1844        }
1845        return $result;
1846    }
1847
1848    /**
1849     * Pre-CSS HTML attributes that map to CSS properties — `<img width>`,
1850     * `<img height>`, `<font color>` etc. Per HTML 5 §15.3, these
1851     * "presentational attributes" map into the user-agent style sheet at
1852     * the lowest specificity. We apply them after the cascade so any
1853     * author CSS still wins, but they provide the size to layout for
1854     * elements that lack explicit `width` / `height` declarations.
1855     */
1856    private function applyPresentationalAttributes(Element $element, CascadedValues $values, bool $parentIsFlexOrGrid = false): void
1857    {
1858        $tag = strtolower($element->localName);
1859        if ($tag === 'img' || $tag === 'embed' || $tag === 'iframe' || $tag === 'video') {
1860            foreach (['width', 'height'] as $attr) {
1861                if ($values->has($attr) && !$this->isAutoLength($values->get($attr))) {
1862                    continue; // author CSS wins
1863                }
1864                $raw = $element->getAttribute($attr);
1865                if ($raw === null) {
1866                    continue;
1867                }
1868                // HTML 5 §2.4.4.4 dimension values allow a trailing `%`;
1869                // browsers map `<img width="100%">` to a CSS percentage on
1870                // the replaced box (resolved against its containing block at
1871                // layout time), not the intrinsic size.
1872                $pct = $this->parseHtmlPercentage($raw);
1873                if ($pct !== null) {
1874                    $values->set($attr, new \Phpdftk\Css\Value\Percentage($pct));
1875                    continue;
1876                }
1877                $px = $this->parseHtmlLength($raw);
1878                if ($px !== null) {
1879                    $values->set($attr, new \Phpdftk\Css\Value\Length($px, \Phpdftk\Css\Value\LengthUnit::Px));
1880                }
1881            }
1882            // Intrinsic-dimension fallback for `<img src="data:image/...">`
1883            // when neither CSS nor HTML attributes provide width/height.
1884            // Decode the data URL once via ImageParser::parseString so layout
1885            // gets the natural pixel dimensions, then derive missing sides
1886            // from the aspect ratio when exactly one dimension is given
1887            // (CSS Images 3 §3.3 "used image dimensions").
1888            if ($tag === 'img') {
1889                $wValue = $values->get('width');
1890                $hValue = $values->get('height');
1891                // CSS Sizing 3 §5.2 — `min-content` / `max-content` /
1892                // `fit-content` on a replaced element resolve to its
1893                // intrinsic (natural) size, so for dimension derivation
1894                // they behave like `auto`.
1895                $wUnset = !$values->has('width') || $this->isReplacedSizeAuto($wValue);
1896                $hUnset = !$values->has('height') || $this->isReplacedSizeAuto($hValue);
1897                // Replaced elements have an intrinsic aspect ratio
1898                // per CSS Sizing 4 §5.1. Expose it as the
1899                // `aspect-ratio` cascade value (when the author
1900                // hasn't overridden it) so layout primitives like
1901                // `aspectRatioTransfer` and Flexbox §4.5 automatic
1902                // minimum sizing can read it without re-decoding
1903                // the image.
1904                $natural = $this->naturalImageSize($element->getAttribute('src'));
1905                if ($natural !== null) {
1906                    [$nw, $nh] = $natural;
1907                    if ($nw > 0 && $nh > 0 && !$values->has('aspect-ratio')) {
1908                        $values->set(
1909                            'aspect-ratio',
1910                            new \Phpdftk\Css\Value\ValueList(
1911                                [
1912                                    new \Phpdftk\Css\Value\Number((float) $nw),
1913                                    new \Phpdftk\Css\Value\Number((float) $nh),
1914                                ],
1915                                \Phpdftk\Css\Value\ListSeparator::Slash,
1916                            ),
1917                        );
1918                    }
1919                }
1920                if ($wUnset || $hUnset) {
1921                    if ($natural !== null) {
1922                        [$nw, $nh] = $natural;
1923                        if ($wUnset && $hUnset) {
1924                            // Both dimensions auto / intrinsic: start at the
1925                            // natural size, then apply any length `min/max-
1926                            // width|height` preserving the aspect ratio
1927                            // (CSS 2.1 §10.4 constraint-violation table —
1928                            // e.g. `max-width: 100px` on a 150x150 image
1929                            // yields 100x100, not 100x150). Percentage /
1930                            // keyword min-max are left to the layout-time
1931                            // replaced clamp. A flex / grid *item*'s min/max
1932                            // is resolved by the flex / grid algorithm (which
1933                            // also transfers through the ratio), so leave the
1934                            // natural size untouched there to avoid double-
1935                            // applying the constraint.
1936                            [$uw, $uh] = $parentIsFlexOrGrid
1937                                ? [(float) $nw, (float) $nh]
1938                                : $this->constrainReplacedNaturalSize((float) $nw, (float) $nh, $values);
1939                            $values->set('width', new \Phpdftk\Css\Value\Length($uw, \Phpdftk\Css\Value\LengthUnit::Px));
1940                            $values->set('height', new \Phpdftk\Css\Value\Length($uh, \Phpdftk\Css\Value\LengthUnit::Px));
1941                        } elseif ($wUnset && $hValue instanceof \Phpdftk\Css\Value\Length && $nh > 0) {
1942                            // Under `box-sizing: border-box`, the declared
1943                            // height includes the padding/border vertical
1944                            // inset, so derive the content height first,
1945                            // then add the horizontal inset to land on the
1946                            // declared width that produces the right
1947                            // content-width-to-content-height ratio.
1948                            [$hInset, $vInset, $borderBox] = $this->presentationalInsetsAndBoxSizing($values);
1949                            $declaredH = $hValue->value;
1950                            $contentH = $borderBox ? max(0.0, $declaredH - $vInset) : $declaredH;
1951                            $contentW = $contentH * ($nw / $nh);
1952                            $declaredW = $borderBox ? ($contentW + $hInset) : $contentW;
1953                            $values->set(
1954                                'width',
1955                                new \Phpdftk\Css\Value\Length(
1956                                    $declaredW,
1957                                    \Phpdftk\Css\Value\LengthUnit::Px,
1958                                ),
1959                            );
1960                        } elseif ($hUnset && $wValue instanceof \Phpdftk\Css\Value\Length && $nw > 0) {
1961                            [$hInset, $vInset, $borderBox] = $this->presentationalInsetsAndBoxSizing($values);
1962                            $declaredW = $wValue->value;
1963                            $contentW = $borderBox ? max(0.0, $declaredW - $hInset) : $declaredW;
1964                            $contentH = $contentW * ($nh / $nw);
1965                            $declaredH = $borderBox ? ($contentH + $vInset) : $contentH;
1966                            $values->set(
1967                                'height',
1968                                new \Phpdftk\Css\Value\Length(
1969                                    $declaredH,
1970                                    \Phpdftk\Css\Value\LengthUnit::Px,
1971                                ),
1972                            );
1973                        }
1974                    }
1975                }
1976            }
1977        }
1978        // HTML 5 §4.12.5 — a `<canvas>` is a replaced element whose
1979        // intrinsic dimensions are its `width` / `height` content
1980        // attributes (default 300 x 150). When BOTH are given
1981        // explicitly, apply them as presentational width/height (unless
1982        // the author set CSS dims, mirroring the `<img>` path) so block
1983        // layout sizes the canvas, and expose the intrinsic ratio as
1984        // `aspect-ratio` so the replaced-element keyword sizing can
1985        // transfer a definite cross size into a `min-content` /
1986        // `max-content` main size. An attribute-less canvas keeps the
1987        // default 300 x 150 handled downstream — forcing that default
1988        // here disturbs cases (object-view-box, vertical writing modes)
1989        // that already render correctly. Skip under `contain: size`,
1990        // where CSS Containment 3 §4.1 substitutes `contain-intrinsic-
1991        // size` for the intrinsic size.
1992        $canvasW = $tag === 'canvas' ? $this->parseHtmlLength($element->getAttribute('width') ?? '') : null;
1993        $canvasH = $tag === 'canvas' ? $this->parseHtmlLength($element->getAttribute('height') ?? '') : null;
1994        if ($canvasW !== null && $canvasW > 0.0
1995            && $canvasH !== null && $canvasH > 0.0
1996            && !$this->hasSizeContainment($values)
1997        ) {
1998            foreach (['width' => $canvasW, 'height' => $canvasH] as $attr => $val) {
1999                if (!$values->has($attr) || $this->isAutoLength($values->get($attr))) {
2000                    $values->set($attr, new \Phpdftk\Css\Value\Length($val, \Phpdftk\Css\Value\LengthUnit::Px));
2001                }
2002            }
2003            if (!$values->has('aspect-ratio')) {
2004                $values->set(
2005                    'aspect-ratio',
2006                    new \Phpdftk\Css\Value\ValueList(
2007                        [
2008                            new \Phpdftk\Css\Value\Number($canvasW),
2009                            new \Phpdftk\Css\Value\Number($canvasH),
2010                        ],
2011                        \Phpdftk\Css\Value\ListSeparator::Slash,
2012                    ),
2013                );
2014            }
2015        }
2016        // HTML 5 §4.4.5.1: `<ol type="A">` / `"a"` / `"I"` / `"i"` / `"1"`
2017        // maps to a `list-style-type` keyword. `<ul type="..."` is the
2018        // older HTML 4 form; supported because real-world docs still use
2019        // it.
2020        if ($tag === 'ol' || $tag === 'ul') {
2021            $type = $element->getAttribute('type');
2022            if ($type !== null && $type !== '') {
2023                $keyword = match ($type) {
2024                    '1' => 'decimal',
2025                    'A' => 'upper-alpha',
2026                    'a' => 'lower-alpha',
2027                    'I' => 'upper-roman',
2028                    'i' => 'lower-roman',
2029                    'disc', 'circle', 'square' => $type,
2030                    default => null,
2031                };
2032                if ($keyword !== null) {
2033                    // Author CSS still wins: only apply when the cascade
2034                    // hasn't already set a non-default value.
2035                    $current = $values->get('list-style-type');
2036                    $defaulted = $current instanceof Keyword
2037                        && in_array(strtolower($current->name), ['disc', 'decimal'], true);
2038                    if (!$values->has('list-style-type') || $defaulted) {
2039                        $values->set('list-style-type', new Keyword($keyword));
2040                    }
2041                }
2042            }
2043        }
2044    }
2045
2046    /**
2047     * Sum the horizontal and vertical padding + border lengths from a
2048     * cascaded-values bundle, plus whether `box-sizing` is `border-box`.
2049     * Used by the `<img>` intrinsic-ratio derivation to compute the
2050     * declared dimension that produces the right content dimension once
2051     * border-box subtracts the inset.
2052     *
2053     * @return array{0: float, 1: float, 2: bool}
2054     */
2055    private function presentationalInsetsAndBoxSizing(\Phpdftk\Css\Cascade\CascadedValues $values): array
2056    {
2057        $sumLength = function (string $property) use ($values): float {
2058            $v = $values->get($property);
2059            return $v instanceof \Phpdftk\Css\Value\Length
2060                ? \Phpdftk\Css\Cascade\LengthResolver::clampPx($v->value)
2061                : 0.0;
2062        };
2063        $borderSide = function (string $side) use ($values): float {
2064            $styleValue = $values->get("border-$side-style");
2065            if ($styleValue instanceof Keyword && strtolower($styleValue->name) === 'none') {
2066                return 0.0;
2067            }
2068            $width = $values->get("border-$side-width");
2069            if ($width instanceof \Phpdftk\Css\Value\Length) {
2070                return \Phpdftk\Css\Cascade\LengthResolver::clampPx($width->value);
2071            }
2072            if ($width instanceof Keyword) {
2073                return match (strtolower($width->name)) {
2074                    'thin' => 1.0,
2075                    'medium' => 3.0,
2076                    'thick' => 5.0,
2077                    default => 0.0,
2078                };
2079            }
2080            return 0.0;
2081        };
2082        $hInset = $sumLength('padding-left') + $sumLength('padding-right')
2083            + $borderSide('left') + $borderSide('right');
2084        $vInset = $sumLength('padding-top') + $sumLength('padding-bottom')
2085            + $borderSide('top') + $borderSide('bottom');
2086        $boxSizing = $values->get('box-sizing');
2087        $borderBox = $boxSizing instanceof Keyword
2088            && strtolower($boxSizing->name) === 'border-box';
2089        return [$hInset, $vInset, $borderBox];
2090    }
2091
2092    private function isAutoLength(?\Phpdftk\Css\Value\Value $v): bool
2093    {
2094        return $v instanceof Keyword && strtolower($v->name) === 'auto';
2095    }
2096
2097    /**
2098     * `true` when a replaced element's width/height is `auto` or one of
2099     * the intrinsic sizing keywords (`min-content` / `max-content` /
2100     * `fit-content`), all of which resolve to the natural dimension for
2101     * dimension derivation. {@see isAutoLength} but also matching the
2102     * content keywords.
2103     */
2104    private function isReplacedSizeAuto(?\Phpdftk\Css\Value\Value $v): bool
2105    {
2106        return $v instanceof Keyword
2107            && in_array(strtolower($v->name), ['auto', 'min-content', 'max-content', 'fit-content'], true);
2108    }
2109
2110    /**
2111     * CSS 2.1 §10.4 — given a replaced element's natural size and a
2112     * cascaded-values bundle, apply any *length* `min/max-width|height`
2113     * while preserving the aspect ratio. Percentage and keyword
2114     * constraints are skipped here (no containing block at box-gen time;
2115     * the layout-time replaced clamp handles those). The clamp order
2116     * (max then min, width before height) approximates the §10.4
2117     * constraint-violation table for the common single-constraint cases.
2118     *
2119     * @return array{0: float, 1: float} used [width, height]
2120     */
2121    private function constrainReplacedNaturalSize(float $w, float $h, \Phpdftk\Css\Cascade\CascadedValues $values): array
2122    {
2123        if ($h <= 0.0 || $w <= 0.0) {
2124            return [$w, $h];
2125        }
2126        $ratio = $w / $h;
2127        $len = static function (?\Phpdftk\Css\Value\Value $v): ?float {
2128            return $v instanceof \Phpdftk\Css\Value\Length && $v->value >= 0.0 ? $v->value : null;
2129        };
2130        $maxW = $len($values->get('max-width'));
2131        $maxH = $len($values->get('max-height'));
2132        $minW = $len($values->get('min-width'));
2133        $minH = $len($values->get('min-height'));
2134        if ($maxW !== null && $w > $maxW) {
2135            $w = $maxW;
2136            $h = $w / $ratio;
2137        }
2138        if ($maxH !== null && $h > $maxH) {
2139            $h = $maxH;
2140            $w = $h * $ratio;
2141        }
2142        if ($minW !== null && $w < $minW) {
2143            $w = $minW;
2144            $h = $w / $ratio;
2145        }
2146        if ($minH !== null && $h < $minH) {
2147            $h = $minH;
2148            $w = $h * $ratio;
2149        }
2150        return [$w, $h];
2151    }
2152
2153    /**
2154     * CSS Containment 3 §2 — `true` when the cascaded `contain` enables
2155     * size containment: the `size` or `strict` keyword, or a list that
2156     * includes `size`. (`content` = layout|paint|style does NOT contain
2157     * size.) Under size containment a replaced element's intrinsic size
2158     * is taken from `contain-intrinsic-size`, so attribute / natural
2159     * dimensions must not be applied.
2160     */
2161    private function hasSizeContainment(\Phpdftk\Css\Cascade\CascadedValues $values): bool
2162    {
2163        $contain = $values->get('contain');
2164        if ($contain instanceof Keyword) {
2165            return in_array(strtolower($contain->name), ['size', 'strict'], true);
2166        }
2167        if ($contain instanceof \Phpdftk\Css\Value\ValueList) {
2168            foreach ($contain->values as $part) {
2169                if ($part instanceof Keyword && strtolower($part->name) === 'size') {
2170                    return true;
2171                }
2172            }
2173        }
2174        return false;
2175    }
2176
2177    /**
2178     * Read the natural pixel dimensions for an `<img src>` value. Returns
2179     * `[width, height]` or null when the URL isn't a recognised Phase-1
2180     * variant, when local-file resolution fails security gates, or when
2181     * the underlying bytes don't parse as a supported image format.
2182     *
2183     * Supported sources:
2184     *   - `data:image/{png,jpeg};base64,...` (and the rfc2397 non-base64
2185     *     form) — bytes go straight to `ImageParser::parseString`.
2186     *   - relative or absolute filesystem paths — joined with `baseDir`,
2187     *     must resolve under it via `realpath()`. Stream-wrapper URLs
2188     *     (`http://`, `phar://`, etc.) are rejected.
2189     *
2190     * @return array{int, int}|null
2191     */
2192    private function naturalImageSize(?string $src): ?array
2193    {
2194        if ($src === null || $src === '') {
2195            return null;
2196        }
2197        if (str_starts_with($src, 'data:')) {
2198            // Accept any `data:image/...` MIME, plus `data:image/svg+xml`
2199            // textual payloads. The sniffer in `ImageParser::parseString`
2200            // dispatches on the actual byte signature, so we don't gate
2201            // on the MIME label here — broader than the prior
2202            // `(png|jpeg|jpg)` allow-list and consistent with the
2203            // painter's permissive handling.
2204            if (preg_match('~^data:image/[^;,]+(?:;([^,]*))?,(.*)$~s', $src, $m) !== 1) {
2205                return null;
2206            }
2207            $parameters = strtolower($m[1]);
2208            $rawPayload = $m[2];
2209            $payload = str_contains($parameters, 'base64')
2210                ? base64_decode($rawPayload, strict: true)
2211                : urldecode($rawPayload);
2212            if ($payload === false || $payload === '') {
2213                return null;
2214            }
2215            try {
2216                $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload);
2217            } catch (\Throwable) {
2218                return null;
2219            }
2220            return $info->width > 0 && $info->height > 0
2221                ? [$info->width, $info->height]
2222                : null;
2223        }
2224        $resolved = $this->resolveLocalImagePath($src);
2225        if ($resolved === null) {
2226            return null;
2227        }
2228        try {
2229            $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved);
2230        } catch (\Throwable) {
2231            return null;
2232        }
2233        return $info->width > 0 && $info->height > 0
2234            ? [$info->width, $info->height]
2235            : null;
2236    }
2237
2238    /**
2239     * Resolve an `<img src>` value to a real local-file path, or null
2240     * when the path can't be confirmed safe. Delegates to the unified
2241     * `Phpdftk\Filesystem\ResourceLoader` so the BoxGenerator and the
2242     * painter share one resolver — they must agree on what's loadable
2243     * (the BoxGenerator decides layout, the painter fetches the bytes).
2244     */
2245    private function resolveLocalImagePath(string $src): ?string
2246    {
2247        return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir, $this->sandboxRoot))
2248            ->resolveLocalPath($src);
2249    }
2250
2251    /**
2252     * HTML legacy `width` / `height` attributes: plain integer = pixels;
2253     * trailing `%` = percentage (not yet honoured here, returns null and
2254     * leaves the value at whatever the cascade said). Everything else is
2255     * rejected.
2256     */
2257    /**
2258     * HTML 5 §2.4.4.4 — a dimension attribute value ending in `%` is a
2259     * percentage. Returns the numeric percentage (e.g. `100` for
2260     * `"100%"`), or null when the value isn't a percentage form. Callers
2261     * that only accept absolute pixel sizes (e.g. a `<canvas>` bitmap)
2262     * skip this and use {@see parseHtmlLength}.
2263     */
2264    private function parseHtmlPercentage(string $raw): ?float
2265    {
2266        $raw = trim($raw);
2267        if (preg_match('/^(\d+(?:\.\d+)?)%$/', $raw, $m) === 1) {
2268            return (float) $m[1];
2269        }
2270        return null;
2271    }
2272
2273    private function parseHtmlLength(string $raw): ?float
2274    {
2275        $raw = trim($raw);
2276        if ($raw === '') {
2277            return null;
2278        }
2279        // HTML 5 §2.4.4.4 "rules for parsing dimension values" — skip
2280        // a leading number, then accept (and ignore) a trailing `px`
2281        // suffix per the relaxed dimension form. Browsers treat
2282        // `width="100"` and `width="100px"` identically on
2283        // `<img>` / `<embed>` / `<iframe>` / `<video>`; rejecting
2284        // the `px` form drops the value back to `width: auto` and
2285        // mis-sizes the replaced element.
2286        if (preg_match('/^(\d+(?:\.\d+)?)(?:px)?$/i', $raw, $m) === 1) {
2287            return (float) $m[1];
2288        }
2289        return null;
2290    }
2291
2292    /** @param list<Box> $boxes */
2293    private function mixesBlockAndInline(array $boxes): bool
2294    {
2295        $hasBlock = false;
2296        $hasInline = false;
2297        foreach ($boxes as $b) {
2298            if ($this->isInlineLevel($b)) {
2299                $hasInline = true;
2300            } else {
2301                $hasBlock = true;
2302            }
2303            if ($hasBlock && $hasInline) {
2304                return true;
2305            }
2306        }
2307        return false;
2308    }
2309
2310    private function isInlineLevel(Box $box): bool
2311    {
2312        return $box instanceof InlineBox
2313            || $box instanceof TextBox
2314            || $box instanceof AtomicInlineBox
2315            || $box instanceof LineBreakBox;
2316    }
2317}