Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.34% covered (success)
96.34%
1499 / 1556
61.90% covered (warning)
61.90%
39 / 63
CRAP
0.00% covered (danger)
0.00%
0 / 1
TreeBuilder
96.34% covered (success)
96.34%
1499 / 1556
61.90% covered (warning)
61.90%
39 / 63
645
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFragment
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 resetInsertionModeForFragment
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
47.80
 build
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 dispatch
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
25
 modeInitial
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 resolveDocumentMode
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 modeBeforeHtml
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 modeBeforeHead
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 insertImplicitHeadAndReprocess
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 modeInHead
97.39% covered (success)
97.39%
112 / 115
0.00% covered (danger)
0.00%
0 / 1
32
 modeAfterHead
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
14
 modeInBody
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 modeInBodyStartTag
100.00% covered (success)
100.00%
193 / 193
100.00% covered (success)
100.00%
1 / 1
65
 modeInBodyEndTag
96.20% covered (success)
96.20%
76 / 79
0.00% covered (danger)
0.00%
0 / 1
27
 closePElement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertImplicitBody
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 processInBodyForStrayHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 modeText
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 modeAfterBody
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 modeAfterAfterBody
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 insertHtmlElement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 appropriatePlaceForInserting
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
13.23
 processAsInBodyWithFosterParenting
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createElementForToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 insertCharacter
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 insertComment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 adoptionAgency
93.26% covered (success)
93.26%
83 / 89
0.00% covered (danger)
0.00%
0 / 1
32.31
 processFormattingFallback
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 reconstructActiveFormatting
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
10
 modeInTable
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 modeInTableStartTag
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
15
 modeInTableEndTag
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 modeInTableText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 flushPendingTableCharacters
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 modeInCaption
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
12.13
 modeInColumnGroup
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
18
 modeInTableBody
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
17
 modeInRow
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
17.05
 modeInCell
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
13
 modeInFrameset
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
17
 modeAfterFrameset
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 modeAfterAfterFrameset
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 modeInHeadNoscript
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
15
 shouldDispatchInForeignContent
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
16
 adjustedCurrentNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insertForeignElement
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 modeInForeignContent
96.72% covered (success)
96.72%
59 / 61
0.00% covered (danger)
0.00%
0 / 1
29
 isMathmlTextIntegrationPoint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isHtmlIntegrationPoint
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 modeInTemplate
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
14
 modeInSelect
93.51% covered (success)
93.51%
72 / 77
0.00% covered (danger)
0.00%
0 / 1
35.34
 modeInSelectInTable
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 closeCell
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 currentNodeIsTableContext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 clearStackToTableContext
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 clearStackToTableBodyContext
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 clearStackToTableRowContext
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 resetInsertionModeAppropriately
91.11% covered (success)
91.11%
41 / 45
0.00% covered (danger)
0.00%
0 / 1
18.23
 isWhitespaceOnlyCharacter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 reprocess
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 resolveShadowRootMode
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 tokenHasAttribute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html\TreeConstruction;
6
7use Phpdftk\Html\Dom\Comment;
8use Phpdftk\Html\Dom\Document;
9use Phpdftk\Html\Dom\DocumentMode;
10use Phpdftk\Html\Dom\DocumentType;
11use Phpdftk\Html\Dom\Element;
12use Phpdftk\Html\Dom\HTMLTemplateElement;
13use Phpdftk\Html\Dom\Node;
14use Phpdftk\Html\Dom\ShadowRootInit;
15use Phpdftk\Html\Dom\ShadowRootMode;
16use Phpdftk\Html\Dom\Text;
17use Phpdftk\Html\ParserOptions;
18use Phpdftk\Html\Tokenizer\CharacterToken;
19use Phpdftk\Html\Tokenizer\CommentToken;
20use Phpdftk\Html\Tokenizer\DoctypeToken;
21use Phpdftk\Html\Tokenizer\EndTagToken;
22use Phpdftk\Html\Tokenizer\EofToken;
23use Phpdftk\Html\Tokenizer\StartTagToken;
24use Phpdftk\Html\Tokenizer\Token;
25use Phpdftk\Html\Tokenizer\Tokenizer;
26use Phpdftk\Html\Tokenizer\TokenizerState;
27
28/**
29 * Tree construction per WHATWG HTML Â§13.2.6.
30 *
31 * Phase 1B.3 implements the common-path insertion modes:
32 *  - Initial, BeforeHtml, BeforeHead, InHead, AfterHead, InBody, Text,
33 *    AfterBody, AfterAfterBody
34 *
35 * Modes deferred to Phase 1B.3-bis:
36 *  - All table-related modes (InTable, InTableText, InCaption, InColumnGroup,
37 *    InTableBody, InRow, InCell) and foster parenting
38 *  - InSelect, InSelectInTable
39 *  - InTemplate (declarative shadow DOM tree construction)
40 *  - InFrameset, AfterFrameset, AfterAfterFrameset
41 *  - InHeadNoscript
42 *  - Foreign content (SVG/MathML) insertion mode
43 *  - Adoption agency algorithm (complex misnested-formatting recovery)
44 *  - Full Noah's Ark dedup in ActiveFormattingElements::push
45 *
46 * Encountering a deferred mode triggers a NotImplementedYet exception with
47 * the specific spec section that hasn't landed yet â€” designed to be
48 * informative when html5lib-tests are wired up incrementally.
49 */
50final class TreeBuilder
51{
52    public InsertionMode $insertionMode = InsertionMode::Initial;
53    public InsertionMode $originalInsertionMode = InsertionMode::Initial;
54
55    public readonly Document $document;
56    public readonly OpenElementsStack $openElements;
57    public readonly ActiveFormattingElements $activeFormatting;
58
59    public ?Element $headElement = null;
60    public ?Element $formElement = null;
61
62    /** Frameset-ok flag per spec. False once we've seen content that prevents <frameset>. */
63    public bool $framesetOk = true;
64
65    private bool $done = false;
66    private ?Tokenizer $activeTokenizer = null;
67
68    /**
69     * Pending table character tokens: collected by InTableText, emitted as
70     * either text into the table (if all whitespace) or foster-parented out
71     * to the table's previous sibling (per Â§13.2.6.4.10).
72     *
73     * @var list<string>
74     */
75    private array $pendingTableCharacters = [];
76    private bool $pendingTableCharactersHaveNonWhitespace = false;
77
78    /**
79     * Foster-parenting flag. Toggled true only when an InTable / InTableBody /
80     * InRow handler falls through to "anything else" and dispatches into
81     * modeInBody â€” i.e. when the *content* (not the table's own row/cell
82     * structure) should land before the table.
83     */
84    private bool $fosterParenting = false;
85
86    /**
87     * Stack of template insertion modes. Pushed when a `<template>` start
88     * tag is encountered; popped on `</template>`. Used by InTemplate to
89     * recover the proper containing mode when nested templates close.
90     *
91     * @var list<InsertionMode>
92     */
93    private array $templateInsertionModes = [];
94
95    public function __construct(
96        public readonly ParserOptions $options = new ParserOptions(),
97        ?Document $document = null,
98    ) {
99        $this->document = $document ?? new Document();
100        $this->openElements = new OpenElementsStack();
101        $this->activeFormatting = new ActiveFormattingElements();
102    }
103
104    /**
105     * Parse an HTML fragment in the context of an element per WHATWG Â§13.4.
106     * Builds a synthetic `<html>` root, primes the open-elements stack and
107     * insertion mode based on the context, runs the parser, then returns the
108     * root's children wrapped in a fresh DocumentFragment.
109     */
110    public function buildFragment(Tokenizer $tokenizer, Element $context): \Phpdftk\Html\Dom\DocumentFragment
111    {
112        // Step 4: create the html root and push it.
113        $htmlRoot = $this->document->createElement('html');
114        $this->document->appendChild($htmlRoot);
115        $this->openElements->push($htmlRoot);
116
117        // Step 5: if context is a template, push InTemplate onto template-mode stack.
118        if ($context instanceof HTMLTemplateElement) {
119            $this->templateInsertionModes[] = InsertionMode::InTemplate;
120        }
121
122        // Step 6: reset insertion mode appropriately based on context.
123        $this->resetInsertionModeForFragment($context);
124
125        // Step 7: find the appropriate form element by walking up from context.
126        for ($node = $context; $node !== null; $node = $node->parentNode) {
127            if ($node instanceof Element
128                && $node->localName === 'form'
129                && $node->namespaceURI === Document::HTML_NS
130            ) {
131                $this->formElement = $node;
132                break;
133            }
134        }
135
136        // Step 8: run the parser.
137        $this->build($tokenizer);
138
139        // Step 9: move html root's children into a fragment owned by the
140        // CONTEXT's document (so the fragment is usable in that document).
141        $fragment = $context->ownerDocument->createDocumentFragment();
142        while ($htmlRoot->firstChild !== null) {
143            $child = $htmlRoot->firstChild;
144            $htmlRoot->removeChild($child);
145            // The child's ownerDocument is the synthetic doc; for the host's
146            // document to accept it cleanly we'd need to adopt it. For Phase
147            // 1B.4 we accept the cross-document linkage; consumers that need
148            // a strict same-document fragment should clone.
149            $fragment->appendChild($child);
150        }
151        return $fragment;
152    }
153
154    /**
155     * Reset insertion mode appropriately for fragment parsing â€” like the
156     * full algorithm but treats the context element as the implicit bottom
157     * of the open-elements stack. See WHATWG Â§13.2.4.1.
158     */
159    private function resetInsertionModeForFragment(Element $context): void
160    {
161        $name = $context->localName;
162        $ns = $context->namespaceURI;
163        if ($ns !== Document::HTML_NS) {
164            $this->insertionMode = InsertionMode::InBody;
165            return;
166        }
167        $this->insertionMode = match ($name) {
168            'select' => InsertionMode::InSelect,
169            'td', 'th' => InsertionMode::InCell,
170            'tr' => InsertionMode::InRow,
171            'tbody', 'thead', 'tfoot' => InsertionMode::InTableBody,
172            'caption' => InsertionMode::InCaption,
173            'colgroup' => InsertionMode::InColumnGroup,
174            'table' => InsertionMode::InTable,
175            'template' => InsertionMode::InTemplate,
176            'head' => InsertionMode::InHead,
177            'body' => InsertionMode::InBody,
178            'frameset' => InsertionMode::InFrameset,
179            'html' => InsertionMode::BeforeHead,
180            default => InsertionMode::InBody,
181        };
182    }
183
184    public function build(Tokenizer $tokenizer): Document
185    {
186        // Pull one token at a time so we can mutate the tokenizer state
187        // between tokens â€” e.g. switching to RCDATA when we see <title>,
188        // RAWTEXT for <style>, ScriptData for <script>.
189        $this->activeTokenizer = $tokenizer;
190        while (($token = $tokenizer->nextToken()) !== null) {
191            $this->dispatch($token, $tokenizer);
192            if ($this->done) {
193                break;
194            }
195        }
196        return $this->document;
197    }
198
199    private function dispatch(Token $token, Tokenizer $tokenizer): void
200    {
201        // Foreign content (SVG / MathML) dispatch per WHATWG Â§13.2.6.5. If
202        // the adjusted current node is in a foreign namespace AND the token
203        // isn't an explicit "breakout" trigger, process via the foreign-
204        // content rules instead of the normal insertion mode.
205        if ($this->shouldDispatchInForeignContent($token)) {
206            $this->modeInForeignContent($token);
207            return;
208        }
209        match ($this->insertionMode) {
210            InsertionMode::Initial => $this->modeInitial($token),
211            InsertionMode::BeforeHtml => $this->modeBeforeHtml($token),
212            InsertionMode::BeforeHead => $this->modeBeforeHead($token),
213            InsertionMode::InHead => $this->modeInHead($token, $tokenizer),
214            InsertionMode::AfterHead => $this->modeAfterHead($token),
215            InsertionMode::InBody => $this->modeInBody($token, $tokenizer),
216            InsertionMode::Text => $this->modeText($token),
217            InsertionMode::AfterBody => $this->modeAfterBody($token),
218            InsertionMode::AfterAfterBody => $this->modeAfterAfterBody($token),
219            InsertionMode::InTable => $this->modeInTable($token, $tokenizer),
220            InsertionMode::InTableText => $this->modeInTableText($token, $tokenizer),
221            InsertionMode::InCaption => $this->modeInCaption($token, $tokenizer),
222            InsertionMode::InColumnGroup => $this->modeInColumnGroup($token, $tokenizer),
223            InsertionMode::InTableBody => $this->modeInTableBody($token, $tokenizer),
224            InsertionMode::InRow => $this->modeInRow($token, $tokenizer),
225            InsertionMode::InCell => $this->modeInCell($token, $tokenizer),
226            InsertionMode::InSelect => $this->modeInSelect($token),
227            InsertionMode::InSelectInTable => $this->modeInSelectInTable($token, $tokenizer),
228            InsertionMode::InTemplate => $this->modeInTemplate($token, $tokenizer),
229            InsertionMode::InFrameset => $this->modeInFrameset($token),
230            InsertionMode::AfterFrameset => $this->modeAfterFrameset($token, $tokenizer),
231            InsertionMode::AfterAfterFrameset => $this->modeAfterAfterFrameset($token, $tokenizer),
232            InsertionMode::InHeadNoscript => $this->modeInHeadNoscript($token, $tokenizer),
233        };
234    }
235
236    // ============================================================
237    // Initial
238    // ============================================================
239    private function modeInitial(Token $token): void
240    {
241        if ($this->isWhitespaceOnlyCharacter($token)) {
242            return;
243        }
244        if ($token instanceof CommentToken) {
245            $this->document->appendChild($this->document->createComment($token->data));
246            return;
247        }
248        if ($token instanceof DoctypeToken) {
249            $name = $token->name ?? '';
250            $publicId = $token->publicId ?? '';
251            $systemId = $token->systemId ?? '';
252            $this->document->appendChild(new DocumentType($this->document, $name, $publicId, $systemId));
253            $this->document->mode = $this->resolveDocumentMode($token);
254            $this->insertionMode = InsertionMode::BeforeHtml;
255            return;
256        }
257        // No DOCTYPE â€” quirks mode.
258        $this->document->mode = DocumentMode::Quirks;
259        $this->insertionMode = InsertionMode::BeforeHtml;
260        $this->reprocess($token);
261    }
262
263    private function resolveDocumentMode(DoctypeToken $token): DocumentMode
264    {
265        if ($token->forceQuirks) {
266            return DocumentMode::Quirks;
267        }
268        $name = $token->name ?? '';
269        if ($name !== 'html') {
270            return DocumentMode::Quirks;
271        }
272        if ($token->publicId === null && ($token->systemId === null || strcasecmp($token->systemId, 'about:legacy-compat') === 0)) {
273            return DocumentMode::NoQuirks;
274        }
275        // Public-ID-based legacy detection (subset; full table in Â§13.2.6.2)
276        if (str_starts_with(strtolower($token->publicId ?? ''), '-//w3c//dtd html 4')) {
277            return DocumentMode::LimitedQuirks;
278        }
279        return DocumentMode::NoQuirks;
280    }
281
282    // ============================================================
283    // BeforeHtml
284    // ============================================================
285    private function modeBeforeHtml(Token $token): void
286    {
287        if ($token instanceof DoctypeToken) {
288            return; // ignore
289        }
290        if ($token instanceof CommentToken) {
291            $this->document->appendChild($this->document->createComment($token->data));
292            return;
293        }
294        if ($this->isWhitespaceOnlyCharacter($token)) {
295            return;
296        }
297        if ($token instanceof StartTagToken && $token->tagName === 'html') {
298            $html = $this->createElementForToken($token);
299            $this->document->appendChild($html);
300            $this->openElements->push($html);
301            $this->insertionMode = InsertionMode::BeforeHead;
302            return;
303        }
304        // Anything else: create implicit <html>, then reprocess.
305        $html = $this->document->createElement('html');
306        $this->document->appendChild($html);
307        $this->openElements->push($html);
308        $this->insertionMode = InsertionMode::BeforeHead;
309        $this->reprocess($token);
310    }
311
312    // ============================================================
313    // BeforeHead
314    // ============================================================
315    private function modeBeforeHead(Token $token): void
316    {
317        if ($this->isWhitespaceOnlyCharacter($token)) {
318            return;
319        }
320        if ($token instanceof CommentToken) {
321            $this->insertComment($token);
322            return;
323        }
324        if ($token instanceof DoctypeToken) {
325            return; // ignore
326        }
327        if ($token instanceof StartTagToken && $token->tagName === 'html') {
328            $this->processInBodyForStrayHtml($token);
329            return;
330        }
331        if ($token instanceof StartTagToken && $token->tagName === 'head') {
332            $this->headElement = $this->insertHtmlElement($token);
333            $this->insertionMode = InsertionMode::InHead;
334            return;
335        }
336        if ($token instanceof EndTagToken && in_array($token->tagName, ['head', 'body', 'html', 'br'], true)) {
337            // Treat as anything-else (insert implicit head).
338            $this->insertImplicitHeadAndReprocess($token);
339            return;
340        }
341        if ($token instanceof EndTagToken) {
342            return; // parse error, ignore
343        }
344        $this->insertImplicitHeadAndReprocess($token);
345    }
346
347    private function insertImplicitHeadAndReprocess(Token $token): void
348    {
349        $head = $this->document->createElement('head');
350        $current = $this->openElements->currentNode();
351        ($current ?? $this->document)->appendChild($head);
352        $this->openElements->push($head);
353        $this->headElement = $head;
354        $this->insertionMode = InsertionMode::InHead;
355        $this->reprocess($token);
356    }
357
358    // ============================================================
359    // InHead
360    // ============================================================
361    private function modeInHead(Token $token, Tokenizer $tokenizer): void
362    {
363        if ($this->isWhitespaceOnlyCharacter($token)) {
364            $this->insertCharacter($token);
365            return;
366        }
367        if ($token instanceof CommentToken) {
368            $this->insertComment($token);
369            return;
370        }
371        if ($token instanceof DoctypeToken) {
372            return;
373        }
374        if ($token instanceof StartTagToken) {
375            if ($token->tagName === 'html') {
376                $this->processInBodyForStrayHtml($token);
377                return;
378            }
379            if (in_array($token->tagName, ['base', 'basefont', 'bgsound', 'link', 'meta'], true)) {
380                $el = $this->insertHtmlElement($token);
381                $this->openElements->pop();
382                if ($token->selfClosing) {
383                    // acknowledged
384                }
385                return;
386            }
387            if ($token->tagName === 'title') {
388                $this->insertHtmlElement($token);
389                $tokenizer->state = TokenizerState::Rcdata;
390                $this->originalInsertionMode = $this->insertionMode;
391                $this->insertionMode = InsertionMode::Text;
392                return;
393            }
394            if (in_array($token->tagName, ['style', 'noframes'], true)) {
395                $this->insertHtmlElement($token);
396                $tokenizer->state = TokenizerState::Rawtext;
397                $this->originalInsertionMode = $this->insertionMode;
398                $this->insertionMode = InsertionMode::Text;
399                return;
400            }
401            if ($token->tagName === 'script') {
402                $this->insertHtmlElement($token);
403                $tokenizer->state = TokenizerState::ScriptData;
404                $this->originalInsertionMode = $this->insertionMode;
405                $this->insertionMode = InsertionMode::Text;
406                return;
407            }
408            if ($token->tagName === 'noscript') {
409                if ($this->options->scriptingEnabled) {
410                    // Scripting enabled â†’ noscript content opaque (RAWTEXT + Text mode).
411                    $this->insertHtmlElement($token);
412                    $tokenizer->state = TokenizerState::Rawtext;
413                    $this->originalInsertionMode = $this->insertionMode;
414                    $this->insertionMode = InsertionMode::Text;
415                    return;
416                }
417                // Scripting disabled (default) â†’ InHeadNoscript mode gates which
418                // elements can appear inside (only head-like flow content).
419                $this->insertHtmlElement($token);
420                $this->insertionMode = InsertionMode::InHeadNoscript;
421                return;
422            }
423            if ($token->tagName === 'template') {
424                // Per spec: the intended parent is the current node at the
425                // time of the token, BEFORE the template is inserted onto
426                // the stack.
427                $intendedParent = $this->openElements->currentNode();
428
429                $template = $this->insertHtmlElement($token);
430                assert($template instanceof HTMLTemplateElement);
431
432                // DSD path: shadowrootmode attribute present + parent eligible
433                // + parent doesn't already have a shadow root.
434                $shadowMode = $this->resolveShadowRootMode($token);
435                if ($shadowMode !== null
436                    && $intendedParent instanceof Element
437                    && $intendedParent->shadowRoot === null
438                    && $intendedParent->isShadowHostEligible()
439                ) {
440                    $init = new ShadowRootInit(
441                        delegatesFocus: $this->tokenHasAttribute($token, 'shadowrootdelegatesfocus'),
442                        clonable: $this->tokenHasAttribute($token, 'shadowrootclonable'),
443                        serializable: $this->tokenHasAttribute($token, 'shadowrootserializable'),
444                    );
445                    try {
446                        $shadowRoot = $intendedParent->attachShadow($shadowMode, $init);
447                        $template->content = $shadowRoot;
448                        $template->isDeclarativeShadowRoot = true;
449                    } catch (\LogicException) {
450                        // Eligibility check passed but attach failed (race condition
451                        // with another DSD template, etc.) â€” fall back to normal.
452                        $template->content = $this->document->createDocumentFragment();
453                    }
454                } else {
455                    $template->content = $this->document->createDocumentFragment();
456                }
457
458                $this->activeFormatting->pushMarker();
459                $this->framesetOk = false;
460                $this->insertionMode = InsertionMode::InTemplate;
461                $this->templateInsertionModes[] = InsertionMode::InTemplate;
462                return;
463            }
464            if ($token->tagName === 'head') {
465                return; // parse error, ignore
466            }
467            // Anything else: pop <head>, switch to AfterHead, reprocess.
468            $this->openElements->pop();
469            $this->insertionMode = InsertionMode::AfterHead;
470            $this->reprocess($token);
471            return;
472        }
473        if ($token instanceof EndTagToken) {
474            if ($token->tagName === 'head') {
475                $this->openElements->pop();
476                $this->insertionMode = InsertionMode::AfterHead;
477                return;
478            }
479            if (in_array($token->tagName, ['body', 'html', 'br'], true)) {
480                $this->openElements->pop();
481                $this->insertionMode = InsertionMode::AfterHead;
482                $this->reprocess($token);
483                return;
484            }
485            if ($token->tagName === 'template') {
486                if (!$this->openElements->containsLocalName('template')) {
487                    return; // parse error, ignore
488                }
489                // Capture the topmost template to check the DSD flag after popping.
490                $template = null;
491                $items = $this->openElements->items();
492                for ($i = count($items) - 1; $i >= 0; $i--) {
493                    $el = $items[$i];
494                    if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
495                        $template = $el;
496                        break;
497                    }
498                }
499                $this->openElements->generateImpliedEndTagsThoroughly();
500                $this->openElements->popUntilLocalName('template');
501                $this->activeFormatting->clearToLastMarker();
502                array_pop($this->templateInsertionModes);
503                $this->resetInsertionModeAppropriately();
504
505                // Phase 1B.4: DSD templates are consumed during parse â€” remove
506                // the template element from the light DOM. The shadow root on
507                // the parent is the surviving artefact.
508                if ($template instanceof HTMLTemplateElement
509                    && $template->isDeclarativeShadowRoot
510                    && $template->parentNode !== null
511                ) {
512                    $template->parentNode->removeChild($template);
513                }
514                return;
515            }
516            return; // parse error, ignore other end tags
517        }
518        if ($token instanceof EofToken) {
519            $this->openElements->pop();
520            $this->insertionMode = InsertionMode::AfterHead;
521            $this->reprocess($token);
522            return;
523        }
524        // Character (non-whitespace): pop head, switch, reprocess.
525        $this->openElements->pop();
526        $this->insertionMode = InsertionMode::AfterHead;
527        $this->reprocess($token);
528    }
529
530    // ============================================================
531    // AfterHead
532    // ============================================================
533    private function modeAfterHead(Token $token): void
534    {
535        if ($this->isWhitespaceOnlyCharacter($token)) {
536            $this->insertCharacter($token);
537            return;
538        }
539        if ($token instanceof CommentToken) {
540            $this->insertComment($token);
541            return;
542        }
543        if ($token instanceof DoctypeToken) {
544            return;
545        }
546        if ($token instanceof StartTagToken) {
547            if ($token->tagName === 'html') {
548                $this->processInBodyForStrayHtml($token);
549                return;
550            }
551            if ($token->tagName === 'body') {
552                $this->insertHtmlElement($token);
553                $this->framesetOk = false;
554                $this->insertionMode = InsertionMode::InBody;
555                return;
556            }
557            if ($token->tagName === 'frameset') {
558                $this->insertHtmlElement($token);
559                $this->insertionMode = InsertionMode::InFrameset;
560                return;
561            }
562            if (in_array($token->tagName, [
563                'base', 'basefont', 'bgsound', 'link', 'meta', 'noframes',
564                'script', 'style', 'template', 'title',
565            ], true)) {
566                // Parse error, but reprocess in InHead.
567                if ($this->headElement !== null) {
568                    $this->openElements->push($this->headElement);
569                }
570                $this->insertionMode = InsertionMode::InHead;
571                $this->reprocess($token);
572                if ($this->headElement !== null) {
573                    $this->openElements->remove($this->headElement);
574                }
575                $this->insertionMode = InsertionMode::AfterHead;
576                return;
577            }
578            if ($token->tagName === 'head') {
579                return; // parse error, ignore
580            }
581            // Anything else: implicit <body>, switch, reprocess.
582            $this->insertImplicitBody();
583            $this->insertionMode = InsertionMode::InBody;
584            $this->reprocess($token);
585            return;
586        }
587        if ($token instanceof EndTagToken) {
588            if (in_array($token->tagName, ['body', 'html', 'br'], true)) {
589                $body = $this->document->createElement('body');
590                $current = $this->openElements->currentNode();
591                ($current ?? $this->document)->appendChild($body);
592                $this->openElements->push($body);
593                $this->insertionMode = InsertionMode::InBody;
594                $this->reprocess($token);
595                return;
596            }
597            return; // parse error, ignore
598        }
599        // Anything else (non-whitespace character, EOF, etc.): insert implicit
600        // <body>, switch to InBody, reprocess. Per WHATWG Â§13.2.6.4.7.
601        $this->insertImplicitBody();
602        $this->insertionMode = InsertionMode::InBody;
603        $this->reprocess($token);
604    }
605
606    // ============================================================
607    // InBody (foundation subset)
608    // ============================================================
609    private function modeInBody(Token $token, Tokenizer $tokenizer): void
610    {
611        if ($token instanceof CharacterToken) {
612            $this->reconstructActiveFormatting();
613            $this->insertCharacter($token);
614            if (!$this->isWhitespaceOnlyCharacter($token)) {
615                $this->framesetOk = false;
616            }
617            return;
618        }
619        if ($token instanceof CommentToken) {
620            $this->insertComment($token);
621            return;
622        }
623        if ($token instanceof DoctypeToken) {
624            return;
625        }
626        if ($token instanceof StartTagToken) {
627            $this->modeInBodyStartTag($token, $tokenizer);
628            return;
629        }
630        if ($token instanceof EndTagToken) {
631            $this->modeInBodyEndTag($token);
632            return;
633        }
634        if ($token instanceof EofToken) {
635            $this->done = true;
636        }
637    }
638
639    private function modeInBodyStartTag(StartTagToken $token, Tokenizer $tokenizer): void
640    {
641        $tag = $token->tagName;
642
643        if ($tag === 'html') {
644            $this->processInBodyForStrayHtml($token);
645            return;
646        }
647
648        // Head-like elements inside body â€” reprocess in InHead.
649        if (in_array($tag, ['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'script', 'style', 'template', 'title'], true)) {
650            $this->modeInHead($token, $tokenizer);
651            return;
652        }
653
654        if ($tag === 'body') {
655            // Parse error: foreign attributes are ignored at this scope in
656            // Phase 1B.3; no merging into the open <body>.
657            return;
658        }
659        if ($tag === 'frameset') {
660            // Per spec: parse error unless framesetOk is true; the existing
661            // body must be replaceable. If conditions aren't met, ignore.
662            $items = $this->openElements->items();
663            $bodyAtIndexOne = isset($items[1])
664                && $items[1]->localName === 'body'
665                && $items[1]->namespaceURI === Document::HTML_NS;
666            if (!$bodyAtIndexOne || count($items) < 2 || !$this->framesetOk) {
667                return; // parse error, ignore
668            }
669            // Remove the existing body from its parent and from the stack.
670            $body = $items[1];
671            $body->parentNode?->removeChild($body);
672            while ($this->openElements->count() > 1) {
673                $this->openElements->pop();
674            }
675            $this->insertHtmlElement($token);
676            $this->insertionMode = InsertionMode::InFrameset;
677            return;
678        }
679
680        // Block-level elements that close a currently open <p>.
681        $closesParagraph = [
682            'address', 'article', 'aside', 'blockquote', 'center', 'details', 'dialog',
683            'dir', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'header',
684            'hgroup', 'main', 'menu', 'nav', 'ol', 'p', 'search', 'section', 'summary', 'ul',
685        ];
686        if (in_array($tag, $closesParagraph, true)) {
687            if ($this->openElements->hasInButtonScope('p')) {
688                $this->closePElement();
689            }
690            $this->insertHtmlElement($token);
691            return;
692        }
693
694        // Headings: like the closes-paragraph set, plus pop any open heading.
695        if (in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
696            if ($this->openElements->hasInButtonScope('p')) {
697                $this->closePElement();
698            }
699            $current = $this->openElements->currentNode();
700            if ($current !== null && in_array($current->localName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
701                $this->openElements->pop();
702            }
703            $this->insertHtmlElement($token);
704            return;
705        }
706
707        if ($tag === 'pre' || $tag === 'listing') {
708            if ($this->openElements->hasInButtonScope('p')) {
709                $this->closePElement();
710            }
711            $this->insertHtmlElement($token);
712            $this->framesetOk = false;
713            // Per spec: skip a single leading LF if present (handled at
714            // character-insertion time; tokenizer doesn't expose that here).
715            return;
716        }
717
718        if ($tag === 'form') {
719            if ($this->formElement !== null) {
720                return; // parse error
721            }
722            if ($this->openElements->hasInButtonScope('p')) {
723                $this->closePElement();
724            }
725            $this->formElement = $this->insertHtmlElement($token);
726            return;
727        }
728
729        if ($tag === 'li') {
730            $this->framesetOk = false;
731            for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
732                $node = $this->openElements->items()[$i];
733                if ($node->localName === 'li') {
734                    $this->openElements->generateImpliedEndTags('li');
735                    $this->openElements->popUntilLocalName('li');
736                    break;
737                }
738                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
739                    && !in_array($node->localName, ['address', 'div', 'p'], true)) {
740                    break;
741                }
742            }
743            if ($this->openElements->hasInButtonScope('p')) {
744                $this->closePElement();
745            }
746            $this->insertHtmlElement($token);
747            return;
748        }
749
750        if (in_array($tag, ['dd', 'dt'], true)) {
751            $this->framesetOk = false;
752            for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
753                $node = $this->openElements->items()[$i];
754                if (in_array($node->localName, ['dd', 'dt'], true)) {
755                    $this->openElements->generateImpliedEndTags($node->localName);
756                    $this->openElements->popUntilLocalName($node->localName);
757                    break;
758                }
759                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
760                    && !in_array($node->localName, ['address', 'div', 'p'], true)) {
761                    break;
762                }
763            }
764            if ($this->openElements->hasInButtonScope('p')) {
765                $this->closePElement();
766            }
767            $this->insertHtmlElement($token);
768            return;
769        }
770
771        // <a> has its own clause: if AFE already has an <a>, run AAA first.
772        if ($tag === 'a') {
773            $existing = $this->activeFormatting->findLastBetweenMarkerAnd('a');
774            if ($existing !== null) {
775                $this->adoptionAgency('a');
776                $this->activeFormatting->remove($existing);
777                $this->openElements->remove($existing);
778            }
779            $this->reconstructActiveFormatting();
780            $el = $this->insertHtmlElement($token);
781            $this->activeFormatting->push($el);
782            return;
783        }
784
785        // <nobr> has special handling: if a nobr is in scope, run AAA first.
786        if ($tag === 'nobr') {
787            $this->reconstructActiveFormatting();
788            if ($this->openElements->hasInScope('nobr')) {
789                $this->adoptionAgency('nobr');
790                $this->reconstructActiveFormatting();
791            }
792            $el = $this->insertHtmlElement($token);
793            $this->activeFormatting->push($el);
794            return;
795        }
796
797        // Other formatting elements â€” push onto AFE list after reconstruction.
798        $formatting = ['b', 'big', 'code', 'em', 'font', 'i', 's', 'small', 'strike', 'strong', 'tt', 'u'];
799        if (in_array($tag, $formatting, true)) {
800            $this->reconstructActiveFormatting();
801            $el = $this->insertHtmlElement($token);
802            $this->activeFormatting->push($el);
803            return;
804        }
805
806        // Void / self-closing-ish elements that clear framesetOk.
807        if (in_array($tag, ['area', 'br', 'embed', 'img', 'keygen', 'wbr'], true)) {
808            $this->reconstructActiveFormatting();
809            $this->insertHtmlElement($token);
810            $this->openElements->pop();
811            $this->framesetOk = false;
812            return;
813        }
814        // `source`, `track`, `param` are HTML 5 void elements that the
815        // spec inserts + pops in InBody but doesn't clear framesetOk for
816        // (see WHATWG Â§13.2.6.4.7 "A start tag whose tag name is one of:
817        // param, source, track"). Practical reason for treating them as
818        // void here: without it `<picture><source ...><img></picture>`
819        // ends up nesting the `<img>` inside the `<source>`.
820        if (in_array($tag, ['source', 'track', 'param'], true)) {
821            $this->insertHtmlElement($token);
822            $this->openElements->pop();
823            return;
824        }
825        if ($tag === 'hr') {
826            if ($this->openElements->hasInButtonScope('p')) {
827                $this->closePElement();
828            }
829            $this->insertHtmlElement($token);
830            $this->openElements->pop();
831            $this->framesetOk = false;
832            return;
833        }
834        if ($tag === 'input') {
835            $this->reconstructActiveFormatting();
836            $this->insertHtmlElement($token);
837            $this->openElements->pop();
838            // Only "type=hidden" preserves frameset-ok; anything else flips it.
839            $hasHiddenType = false;
840            foreach ($token->attributes as $attr) {
841                if ($attr['name'] === 'type' && strcasecmp($attr['value'], 'hidden') === 0) {
842                    $hasHiddenType = true;
843                    break;
844                }
845            }
846            if (!$hasHiddenType) {
847                $this->framesetOk = false;
848            }
849            return;
850        }
851
852        if ($tag === 'table') {
853            if ($this->document->mode !== DocumentMode::Quirks
854                && $this->openElements->hasInButtonScope('p')) {
855                $this->closePElement();
856            }
857            $this->insertHtmlElement($token);
858            $this->framesetOk = false;
859            $this->insertionMode = InsertionMode::InTable;
860            return;
861        }
862
863        if ($tag === 'select') {
864            $this->reconstructActiveFormatting();
865            $this->insertHtmlElement($token);
866            $this->framesetOk = false;
867            // If we're already inside a table-related mode, enter InSelectInTable.
868            $previousMode = $this->insertionMode;
869            $this->insertionMode = in_array($previousMode, [
870                InsertionMode::InTable, InsertionMode::InCaption, InsertionMode::InTableBody,
871                InsertionMode::InRow, InsertionMode::InCell,
872            ], true) ? InsertionMode::InSelectInTable : InsertionMode::InSelect;
873            return;
874        }
875
876        if ($tag === 'textarea') {
877            $this->insertHtmlElement($token);
878            $tokenizer->state = TokenizerState::Rcdata;
879            $this->originalInsertionMode = $this->insertionMode;
880            $this->framesetOk = false;
881            $this->insertionMode = InsertionMode::Text;
882            return;
883        }
884
885        if ($tag === 'xmp') {
886            if ($this->openElements->hasInButtonScope('p')) {
887                $this->closePElement();
888            }
889            $this->reconstructActiveFormatting();
890            $this->framesetOk = false;
891            $this->insertHtmlElement($token);
892            $tokenizer->state = TokenizerState::Rawtext;
893            $this->originalInsertionMode = $this->insertionMode;
894            $this->insertionMode = InsertionMode::Text;
895            return;
896        }
897
898        if ($tag === 'svg') {
899            $this->reconstructActiveFormatting();
900            $this->insertForeignElement($token, Document::SVG_NS, self::SVG_TAG_CASE_CORRECTIONS);
901            if ($token->selfClosing) {
902                $this->openElements->pop();
903            }
904            return;
905        }
906        if ($tag === 'math') {
907            $this->reconstructActiveFormatting();
908            $this->insertForeignElement($token, Document::MATHML_NS, []);
909            if ($token->selfClosing) {
910                $this->openElements->pop();
911            }
912            return;
913        }
914
915        if ($tag === 'iframe' || $tag === 'noembed') {
916            $this->insertHtmlElement($token);
917            $tokenizer->state = TokenizerState::Rawtext;
918            $this->originalInsertionMode = $this->insertionMode;
919            $this->framesetOk = false;
920            $this->insertionMode = InsertionMode::Text;
921            return;
922        }
923
924        // Default: just insert (any other start tag).
925        $this->reconstructActiveFormatting();
926        $this->insertHtmlElement($token);
927    }
928
929    private function modeInBodyEndTag(EndTagToken $token): void
930    {
931        $tag = $token->tagName;
932
933        if ($tag === 'template') {
934            $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
935            return;
936        }
937
938        if ($tag === 'body') {
939            if (!$this->openElements->hasInScope('body')) {
940                return; // parse error
941            }
942            $this->insertionMode = InsertionMode::AfterBody;
943            return;
944        }
945        if ($tag === 'html') {
946            if (!$this->openElements->hasInScope('body')) {
947                return;
948            }
949            $this->insertionMode = InsertionMode::AfterBody;
950            $this->reprocess($token);
951            return;
952        }
953
954        $blockLike = [
955            'address', 'article', 'aside', 'blockquote', 'button', 'center',
956            'details', 'dialog', 'dir', 'div', 'dl', 'fieldset', 'figcaption',
957            'figure', 'footer', 'header', 'hgroup', 'listing', 'main', 'menu',
958            'nav', 'ol', 'pre', 'search', 'section', 'summary', 'ul',
959        ];
960        if (in_array($tag, $blockLike, true)) {
961            if (!$this->openElements->hasInScope($tag)) {
962                return; // parse error
963            }
964            $this->openElements->generateImpliedEndTags();
965            $this->openElements->popUntilLocalName($tag);
966            return;
967        }
968
969        if ($tag === 'form') {
970            $node = $this->formElement;
971            $this->formElement = null;
972            if ($node === null || !$this->openElements->contains($node)) {
973                return;
974            }
975            $this->openElements->generateImpliedEndTags();
976            $this->openElements->remove($node);
977            return;
978        }
979
980        if ($tag === 'p') {
981            if (!$this->openElements->hasInButtonScope('p')) {
982                // Per spec: insert an implicit <p>, then close it.
983                $p = $this->document->createElement('p');
984                $current = $this->openElements->currentNode();
985                ($current ?? $this->document)->appendChild($p);
986                $this->openElements->push($p);
987            }
988            $this->closePElement();
989            return;
990        }
991
992        if ($tag === 'li') {
993            if (!$this->openElements->hasInListItemScope('li')) {
994                return;
995            }
996            $this->openElements->generateImpliedEndTags('li');
997            $this->openElements->popUntilLocalName('li');
998            return;
999        }
1000
1001        if (in_array($tag, ['dd', 'dt'], true)) {
1002            if (!$this->openElements->hasInScope($tag)) {
1003                return;
1004            }
1005            $this->openElements->generateImpliedEndTags($tag);
1006            $this->openElements->popUntilLocalName($tag);
1007            return;
1008        }
1009
1010        if (in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
1011            $headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
1012            $hasAny = false;
1013            foreach ($headings as $h) {
1014                if ($this->openElements->hasInScope($h)) {
1015                    $hasAny = true;
1016                    break;
1017                }
1018            }
1019            if (!$hasAny) {
1020                return;
1021            }
1022            $this->openElements->generateImpliedEndTags();
1023            $this->openElements->popUntilLocalName(...$headings);
1024            return;
1025        }
1026
1027        // Formatting elements â€” run the adoption agency algorithm
1028        // (WHATWG Â§13.2.6.4.7 "any other end tag" / formatting tags subset).
1029        $formatting = ['a', 'b', 'big', 'code', 'em', 'font', 'i', 'nobr', 's', 'small', 'strike', 'strong', 'tt', 'u'];
1030        if (in_array($tag, $formatting, true)) {
1031            $this->adoptionAgency($tag);
1032            return;
1033        }
1034
1035        // "Any other end tag" per spec â€” search up the open elements stack
1036        // for a matching element, generating implied end tags as we go.
1037        for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1038            $node = $this->openElements->items()[$i];
1039            if ($node->localName === $tag && $node->namespaceURI === Document::HTML_NS) {
1040                $this->openElements->generateImpliedEndTags($tag);
1041                $this->openElements->popUntilElement($node);
1042                return;
1043            }
1044            if (OpenElementsStack::isSpecialHtmlElement($node->localName)) {
1045                return; // parse error, ignore
1046            }
1047        }
1048    }
1049
1050    private function closePElement(): void
1051    {
1052        $this->openElements->generateImpliedEndTags('p');
1053        $this->openElements->popUntilLocalName('p');
1054    }
1055
1056    private function insertImplicitBody(): Element
1057    {
1058        $body = $this->document->createElement('body');
1059        $current = $this->openElements->currentNode();
1060        ($current ?? $this->document)->appendChild($body);
1061        $this->openElements->push($body);
1062        return $body;
1063    }
1064
1065    private function processInBodyForStrayHtml(StartTagToken $token): void
1066    {
1067        // Per spec parse-error case: merge attributes onto the <html> root
1068        // that aren't already present. Phase 1B.3 simplification: skip the
1069        // attribute merge; real-world impact is negligible for non-degenerate
1070        // input where the parser sees <html> only once.
1071    }
1072
1073    // ============================================================
1074    // Text mode (RCDATA / RAWTEXT / Script)
1075    // ============================================================
1076    private function modeText(Token $token): void
1077    {
1078        if ($token instanceof CharacterToken) {
1079            $this->insertCharacter($token);
1080            return;
1081        }
1082        if ($token instanceof EofToken) {
1083            $this->openElements->pop();
1084            $this->insertionMode = $this->originalInsertionMode;
1085            $this->reprocess($token);
1086            return;
1087        }
1088        if ($token instanceof EndTagToken) {
1089            // Pop and return to original insertion mode.
1090            $this->openElements->pop();
1091            $this->insertionMode = $this->originalInsertionMode;
1092            return;
1093        }
1094    }
1095
1096    // ============================================================
1097    // AfterBody / AfterAfterBody
1098    // ============================================================
1099    private function modeAfterBody(Token $token): void
1100    {
1101        if ($this->isWhitespaceOnlyCharacter($token)) {
1102            // Process as if InBody â€” text gets inserted into <body>.
1103            $this->insertCharacter($token);
1104            return;
1105        }
1106        if ($token instanceof CommentToken) {
1107            // Append to <html>.
1108            $items = $this->openElements->items();
1109            $html = $items[0] ?? null;
1110            ($html ?? $this->document)->appendChild($this->document->createComment($token->data));
1111            return;
1112        }
1113        if ($token instanceof DoctypeToken) {
1114            return;
1115        }
1116        if ($token instanceof StartTagToken && $token->tagName === 'html') {
1117            $this->processInBodyForStrayHtml($token);
1118            return;
1119        }
1120        if ($token instanceof EndTagToken && $token->tagName === 'html') {
1121            $this->insertionMode = InsertionMode::AfterAfterBody;
1122            return;
1123        }
1124        if ($token instanceof EofToken) {
1125            $this->done = true;
1126            return;
1127        }
1128        // Parse error: switch back to InBody and reprocess.
1129        $this->insertionMode = InsertionMode::InBody;
1130        $this->reprocess($token);
1131    }
1132
1133    private function modeAfterAfterBody(Token $token): void
1134    {
1135        if ($token instanceof CommentToken) {
1136            $this->document->appendChild($this->document->createComment($token->data));
1137            return;
1138        }
1139        if ($token instanceof DoctypeToken) {
1140            return;
1141        }
1142        if ($this->isWhitespaceOnlyCharacter($token)) {
1143            $this->insertCharacter($token);
1144            return;
1145        }
1146        if ($token instanceof StartTagToken && $token->tagName === 'html') {
1147            $this->processInBodyForStrayHtml($token);
1148            return;
1149        }
1150        if ($token instanceof EofToken) {
1151            $this->done = true;
1152            return;
1153        }
1154        $this->insertionMode = InsertionMode::InBody;
1155        $this->reprocess($token);
1156    }
1157
1158    // ============================================================
1159    // Insertion algorithms
1160    // ============================================================
1161    private function insertHtmlElement(StartTagToken $token): Element
1162    {
1163        $element = $this->createElementForToken($token);
1164        [$parent, $before] = $this->appropriatePlaceForInserting();
1165        if ($before !== null) {
1166            $parent->insertBefore($element, $before);
1167        } else {
1168            $parent->appendChild($element);
1169        }
1170        $this->openElements->push($element);
1171        return $element;
1172    }
1173
1174    /**
1175     * "Appropriate place for inserting a node" per WHATWG Â§13.2.6.1. Foster
1176     * parenting applies when the current node is a table/tbody/tfoot/thead/tr
1177     * AND we're in a table-related insertion mode; in that case content is
1178     * inserted *before* the table rather than inside its element children.
1179     *
1180     * @return array{0: Node, 1: ?Node} parent + optional reference sibling
1181     */
1182    private function appropriatePlaceForInserting(): array
1183    {
1184        $target = $this->openElements->currentNode() ?? $this->document;
1185        // Template redirection: inserted children flow into the template's
1186        // content fragment (DocumentFragment for normal templates, ShadowRoot
1187        // for declarative shadow DOM) rather than into the template element
1188        // itself, per WHATWG Â§13.2.6.1 + DSD.
1189        if ($target instanceof HTMLTemplateElement && $target->content !== null) {
1190            return [$target->content, null];
1191        }
1192        if (!$this->fosterParenting) {
1193            return [$target, null];
1194        }
1195        if (!$target instanceof Element
1196            || !in_array($target->localName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true)
1197            || $target->namespaceURI !== Document::HTML_NS
1198        ) {
1199            return [$target, null];
1200        }
1201        // Foster-parent target: insert before the last table on the stack.
1202        for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1203            $el = $this->openElements->items()[$i];
1204            if ($el->localName === 'table' && $el->namespaceURI === Document::HTML_NS) {
1205                $tableParent = $el->parentNode;
1206                if ($tableParent !== null) {
1207                    return [$tableParent, $el];
1208                }
1209                // No parent â€” fall back to the element before the table on the stack.
1210                if ($i > 0) {
1211                    return [$this->openElements->items()[$i - 1], null];
1212                }
1213            }
1214        }
1215        return [$target, null];
1216    }
1217
1218    private function processAsInBodyWithFosterParenting(Token $token, Tokenizer $tokenizer): void
1219    {
1220        $previous = $this->fosterParenting;
1221        $this->fosterParenting = true;
1222        try {
1223            $this->modeInBody($token, $tokenizer);
1224        } finally {
1225            $this->fosterParenting = $previous;
1226        }
1227    }
1228
1229    private function createElementForToken(StartTagToken $token): Element
1230    {
1231        $element = $this->document->createElement($token->tagName);
1232        foreach ($token->attributes as $attr) {
1233            // First-attribute-wins per WHATWG; dedup already done by tokenizer.
1234            if (!$element->hasAttribute($attr['name'])) {
1235                $element->setAttribute($attr['name'], $attr['value']);
1236            }
1237        }
1238        return $element;
1239    }
1240
1241    private function insertCharacter(Token $token): void
1242    {
1243        if (!$token instanceof CharacterToken) {
1244            return;
1245        }
1246        [$parent, $before] = $this->appropriatePlaceForInserting();
1247        if ($before !== null) {
1248            // Foster-parented text: merge with the immediately-preceding text node if any.
1249            $prev = $before->previousSibling;
1250            if ($prev instanceof Text) {
1251                $prev->data .= $token->data;
1252                return;
1253            }
1254            $parent->insertBefore($this->document->createTextNode($token->data), $before);
1255            return;
1256        }
1257        $last = $parent->lastChild;
1258        if ($last instanceof Text) {
1259            $last->data .= $token->data;
1260            return;
1261        }
1262        $parent->appendChild($this->document->createTextNode($token->data));
1263    }
1264
1265    private function insertComment(CommentToken $token): void
1266    {
1267        $current = $this->openElements->currentNode();
1268        ($current ?? $this->document)->appendChild($this->document->createComment($token->data));
1269    }
1270
1271    /**
1272     * Adoption agency algorithm per WHATWG Â§13.2.6.4.7.
1273     *
1274     * The famous "Algorithm A" â€” recovers gracefully from misnested
1275     * formatting like `<b><i></b></i>`. Called from end tags for
1276     * `a`, `b`, `big`, `code`, `em`, `font`, `i`, `nobr`, `s`, `small`,
1277     * `strike`, `strong`, `tt`, `u`, and from start tags for `<a>` and
1278     * `<nobr>` when those elements are already on the active formatting
1279     * elements list / open elements stack.
1280     */
1281    private function adoptionAgency(string $subject): void
1282    {
1283        // Step 2: current node is a non-AFE HTML element with matching name.
1284        $current = $this->openElements->currentNode();
1285        if ($current !== null
1286            && $current->namespaceURI === Document::HTML_NS
1287            && $current->localName === $subject
1288            && !$this->activeFormatting->contains($current)
1289        ) {
1290            $this->openElements->pop();
1291            return;
1292        }
1293
1294        // Step 3-4: outer loop, max 8 iterations.
1295        for ($outerLoop = 0; $outerLoop < 8; $outerLoop++) {
1296            // 4c: find the formatting element.
1297            $formattingElement = $this->activeFormatting->findLastBetweenMarkerAnd($subject);
1298
1299            // 4d: no such element â€” "any other end tag" path.
1300            if ($formattingElement === null) {
1301                $this->processFormattingFallback($subject);
1302                return;
1303            }
1304
1305            // 4e: formatting element not on open stack â€” parse error, drop from AFE.
1306            if (!$this->openElements->contains($formattingElement)) {
1307                $this->activeFormatting->remove($formattingElement);
1308                return;
1309            }
1310
1311            // 4f: on stack but not in scope â€” parse error, return.
1312            if (!$this->openElements->hasInScope($formattingElement->localName)) {
1313                return;
1314            }
1315
1316            // 4g: not the current node is a parse error but doesn't stop us.
1317
1318            // 4h: furthest block â€” topmost special element below formatting element.
1319            $formattingIdx = $this->openElements->indexOf($formattingElement);
1320            if ($formattingIdx === null) {
1321                return;
1322            }
1323            $furthestBlock = null;
1324            $furthestBlockIdx = null;
1325            for ($i = $formattingIdx + 1; $i < $this->openElements->count(); $i++) {
1326                $node = $this->openElements->items()[$i];
1327                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
1328                    && $node->namespaceURI === Document::HTML_NS) {
1329                    $furthestBlock = $node;
1330                    $furthestBlockIdx = $i;
1331                    break;
1332                }
1333            }
1334
1335            // 4i: no furthest block â€” pop everything down to formatting element, drop from AFE.
1336            if ($furthestBlock === null || $furthestBlockIdx === null) {
1337                while ($this->openElements->currentNode() !== $formattingElement) {
1338                    $this->openElements->pop();
1339                }
1340                $this->openElements->pop();
1341                $this->activeFormatting->remove($formattingElement);
1342                return;
1343            }
1344
1345            // 4j: common ancestor = element immediately above formatting element.
1346            $commonAncestor = $this->openElements->items()[$formattingIdx - 1] ?? null;
1347            if ($commonAncestor === null) {
1348                return;
1349            }
1350
1351            // 4k: bookmark in AFE at formatting element's position.
1352            $bookmark = $this->activeFormatting->indexOf($formattingElement);
1353            if ($bookmark === null) {
1354                return;
1355            }
1356
1357            // 4l-m: setup inner loop variables.
1358            $node = $furthestBlock;
1359            $nodeIdx = $furthestBlockIdx;
1360            $lastNode = $furthestBlock;
1361
1362            for ($innerLoop = 1; $innerLoop < 20; $innerLoop++) {
1363                // 4n.ii: node = element immediately above the current node in the stack.
1364                $nodeIdx--;
1365                if ($nodeIdx < 0) {
1366                    break;
1367                }
1368                $node = $this->openElements->items()[$nodeIdx];
1369
1370                // 4n.iii: stop when we reach formatting element.
1371                if ($node === $formattingElement) {
1372                    break;
1373                }
1374
1375                // 4n.iv: kick out from AFE after 3 iterations.
1376                if ($innerLoop > 3 && $this->activeFormatting->contains($node)) {
1377                    $this->activeFormatting->remove($node);
1378                }
1379
1380                // 4n.v: not in AFE â€” remove from open elements, continue (idx already adjusted).
1381                if (!$this->activeFormatting->contains($node)) {
1382                    $this->openElements->removeAt($nodeIdx);
1383                    // furthestBlockIdx and formattingIdx shift down by 1.
1384                    $furthestBlockIdx--;
1385                    continue;
1386                }
1387
1388                // 4n.vi: clone node, replace in both AFE and open elements.
1389                $newNode = $this->document->createElement($node->localName);
1390                foreach ($node->attributes() as $attr) {
1391                    $newNode->setAttributeNode($attr);
1392                }
1393                $this->activeFormatting->replace($node, $newNode);
1394                $this->openElements->replaceAt($nodeIdx, $newNode);
1395
1396                // 4n.vii: if last node was furthest block, move bookmark to after newNode in AFE.
1397                if ($lastNode === $furthestBlock) {
1398                    $newIdx = $this->activeFormatting->indexOf($newNode);
1399                    if ($newIdx !== null) {
1400                        $bookmark = $newIdx + 1;
1401                    }
1402                }
1403
1404                // 4n.viii: detach lastNode and append it to newNode.
1405                if ($lastNode->parentNode !== null) {
1406                    $lastNode->parentNode->removeChild($lastNode);
1407                }
1408                $newNode->appendChild($lastNode);
1409
1410                // 4n.ix: lastNode = node (after replacement, that's newNode).
1411                $lastNode = $newNode;
1412                $node = $newNode;
1413            }
1414
1415            // 4o: insert lastNode under common ancestor (foster-parented if applicable).
1416            if ($lastNode->parentNode !== null) {
1417                $lastNode->parentNode->removeChild($lastNode);
1418            }
1419            // Use appropriatePlaceForInserting via override: we want $commonAncestor as target.
1420            // Foster-parenting only applies when commonAncestor is a table-context element AND
1421            // we're in a table-related mode. Phase 1B.3-bis simplification: append directly.
1422            $commonAncestor->appendChild($lastNode);
1423
1424            // 4p: create new element for formatting element's token.
1425            $newFormatting = $this->document->createElement($formattingElement->localName);
1426            foreach ($formattingElement->attributes() as $attr) {
1427                $newFormatting->setAttributeNode($attr);
1428            }
1429
1430            // 4q: move children of furthest block to new formatting element.
1431            while ($furthestBlock->firstChild !== null) {
1432                $newFormatting->appendChild($furthestBlock->firstChild);
1433            }
1434
1435            // 4r: append new formatting element to furthest block.
1436            $furthestBlock->appendChild($newFormatting);
1437
1438            // 4s: replace formatting element in AFE with new formatting at bookmark.
1439            $this->activeFormatting->remove($formattingElement);
1440            $afeCount = count($this->activeFormatting->entries());
1441            $bookmark = max(0, min($bookmark, $afeCount));
1442            $this->activeFormatting->insertAt($bookmark, $newFormatting);
1443
1444            // 4t: remove formatting from open stack, insert new immediately after furthest block.
1445            $this->openElements->remove($formattingElement);
1446            $furthestBlockIdx = $this->openElements->indexOf($furthestBlock);
1447            if ($furthestBlockIdx === null) {
1448                return;
1449            }
1450            $this->openElements->insertAt($furthestBlockIdx + 1, $newFormatting);
1451        }
1452    }
1453
1454    /**
1455     * AAA's "any other end tag" fallback (step 4d): search the open elements
1456     * stack for a matching element, generate implied end tags, pop. Mirrors
1457     * the same logic in modeInBodyEndTag's catch-all.
1458     */
1459    private function processFormattingFallback(string $subject): void
1460    {
1461        for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1462            $node = $this->openElements->items()[$i];
1463            if ($node->localName === $subject && $node->namespaceURI === Document::HTML_NS) {
1464                $this->openElements->generateImpliedEndTags($subject);
1465                $this->openElements->popUntilElement($node);
1466                return;
1467            }
1468            if (OpenElementsStack::isSpecialHtmlElement($node->localName)) {
1469                return; // parse error, ignore
1470            }
1471        }
1472    }
1473
1474    /**
1475     * Reconstruct the active formatting elements per Â§13.2.4.3. After certain
1476     * elements close, formatting elements that should still be active need
1477     * to be re-opened (e.g. text after a `</p>` that's still inside `<b>`).
1478     */
1479    private function reconstructActiveFormatting(): void
1480    {
1481        $entries = $this->activeFormatting->entries();
1482        if ($entries === []) {
1483            return;
1484        }
1485        $last = $entries[count($entries) - 1] ?? null;
1486        if ($last === null) {
1487            return; // marker â€” nothing to reconstruct
1488        }
1489        if ($this->openElements->contains($last)) {
1490            return; // already on the stack
1491        }
1492
1493        // Walk back to find the first entry that's still on the stack or a marker.
1494        $i = count($entries) - 1;
1495        while ($i > 0) {
1496            $i--;
1497            $entry = $entries[$i];
1498            if ($entry === null) {
1499                $i++;
1500                break;
1501            }
1502            if ($this->openElements->contains($entry)) {
1503                $i++;
1504                break;
1505            }
1506        }
1507
1508        // From i forward: clone and re-insert each entry.
1509        for (; $i < count($entries); $i++) {
1510            $entry = $entries[$i];
1511            if ($entry === null) {
1512                continue;
1513            }
1514            $clone = $this->document->createElement($entry->localName);
1515            foreach ($entry->attributes() as $attr) {
1516                $clone->setAttributeNode($attr);
1517            }
1518            $current = $this->openElements->currentNode();
1519            ($current ?? $this->document)->appendChild($clone);
1520            $this->openElements->push($clone);
1521            $this->activeFormatting->replace($entry, $clone);
1522        }
1523    }
1524
1525    // ============================================================
1526    // InTable (§13.2.6.4.9)
1527    // ============================================================
1528    private function modeInTable(Token $token, Tokenizer $tokenizer): void
1529    {
1530        if ($token instanceof CharacterToken
1531            && $this->currentNodeIsTableContext()
1532        ) {
1533            $this->pendingTableCharacters = [];
1534            $this->pendingTableCharactersHaveNonWhitespace = false;
1535            $this->originalInsertionMode = $this->insertionMode;
1536            $this->insertionMode = InsertionMode::InTableText;
1537            $this->reprocess($token);
1538            return;
1539        }
1540        if ($token instanceof CommentToken) {
1541            $this->insertComment($token);
1542            return;
1543        }
1544        if ($token instanceof DoctypeToken) {
1545            return;
1546        }
1547        if ($token instanceof StartTagToken) {
1548            $this->modeInTableStartTag($token, $tokenizer);
1549            return;
1550        }
1551        if ($token instanceof EndTagToken) {
1552            $this->modeInTableEndTag($token);
1553            return;
1554        }
1555        if ($token instanceof EofToken) {
1556            $this->modeInBody($token, $tokenizer);
1557        }
1558    }
1559
1560    private function modeInTableStartTag(StartTagToken $token, Tokenizer $tokenizer): void
1561    {
1562        $tag = $token->tagName;
1563        if ($tag === 'caption') {
1564            $this->clearStackToTableContext();
1565            $this->activeFormatting->pushMarker();
1566            $this->insertHtmlElement($token);
1567            $this->insertionMode = InsertionMode::InCaption;
1568            return;
1569        }
1570        if ($tag === 'colgroup') {
1571            $this->clearStackToTableContext();
1572            $this->insertHtmlElement($token);
1573            $this->insertionMode = InsertionMode::InColumnGroup;
1574            return;
1575        }
1576        if ($tag === 'col') {
1577            $this->clearStackToTableContext();
1578            $colgroup = $this->document->createElement('colgroup');
1579            $current = $this->openElements->currentNode();
1580            ($current ?? $this->document)->appendChild($colgroup);
1581            $this->openElements->push($colgroup);
1582            $this->insertionMode = InsertionMode::InColumnGroup;
1583            $this->reprocess($token);
1584            return;
1585        }
1586        if (in_array($tag, ['tbody', 'tfoot', 'thead'], true)) {
1587            $this->clearStackToTableContext();
1588            $this->insertHtmlElement($token);
1589            $this->insertionMode = InsertionMode::InTableBody;
1590            return;
1591        }
1592        if (in_array($tag, ['td', 'th', 'tr'], true)) {
1593            $this->clearStackToTableContext();
1594            $synthetic = $this->document->createElement('tbody');
1595            $current = $this->openElements->currentNode();
1596            ($current ?? $this->document)->appendChild($synthetic);
1597            $this->openElements->push($synthetic);
1598            $this->insertionMode = InsertionMode::InTableBody;
1599            $this->reprocess($token);
1600            return;
1601        }
1602        if ($tag === 'table') {
1603            // Parse error: implicit </table>, then reprocess.
1604            if (!$this->openElements->hasInTableScope('table')) {
1605                return;
1606            }
1607            $this->openElements->popUntilLocalName('table');
1608            $this->resetInsertionModeAppropriately();
1609            $this->reprocess($token);
1610            return;
1611        }
1612        if (in_array($tag, ['style', 'script', 'template'], true)) {
1613            // Process via InHead (which knows how to switch tokenizer states).
1614            $this->modeInHead($token, $tokenizer);
1615            return;
1616        }
1617        if ($tag === 'input') {
1618            // Per spec: if type="hidden", insert normally; otherwise fall through to "anything else".
1619            $isHidden = false;
1620            foreach ($token->attributes as $attr) {
1621                if ($attr['name'] === 'type' && strcasecmp($attr['value'], 'hidden') === 0) {
1622                    $isHidden = true;
1623                    break;
1624                }
1625            }
1626            if ($isHidden) {
1627                $this->insertHtmlElement($token);
1628                $this->openElements->pop();
1629                return;
1630            }
1631        }
1632        if ($tag === 'form') {
1633            // Parse error per spec; we just bail with no form.
1634            return;
1635        }
1636        // "Anything else" â€” parse error; process the token under InBody with
1637        // foster-parenting enabled (handled by appropriatePlaceForInserting).
1638        $this->processAsInBodyWithFosterParenting($token, $tokenizer);
1639    }
1640
1641    private function modeInTableEndTag(EndTagToken $token): void
1642    {
1643        $tag = $token->tagName;
1644        if ($tag === 'table') {
1645            if (!$this->openElements->hasInTableScope('table')) {
1646                return; // parse error
1647            }
1648            $this->openElements->popUntilLocalName('table');
1649            $this->resetInsertionModeAppropriately();
1650            return;
1651        }
1652        if (in_array($tag, ['body', 'caption', 'col', 'colgroup', 'html', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)) {
1653            return; // parse error, ignore
1654        }
1655        if (in_array($tag, ['style', 'script', 'template'], true)) {
1656            $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
1657            return;
1658        }
1659        // "Anything else" â€” process under InBody with foster parenting.
1660        $this->processAsInBodyWithFosterParenting($token, $this->activeTokenizer ?? new Tokenizer(''));
1661    }
1662
1663    // ============================================================
1664    // InTableText (§13.2.6.4.10)
1665    // ============================================================
1666    private function modeInTableText(Token $token, Tokenizer $tokenizer): void
1667    {
1668        if ($token instanceof CharacterToken) {
1669            if ($token->data === "\u{0000}") {
1670                return; // parse error, drop NUL
1671            }
1672            $this->pendingTableCharacters[] = $token->data;
1673            if (preg_match('/[^\t\n\f\r ]/', $token->data) === 1) {
1674                $this->pendingTableCharactersHaveNonWhitespace = true;
1675            }
1676            return;
1677        }
1678        // Any other token: flush the buffered characters and return to original mode.
1679        $this->flushPendingTableCharacters();
1680        $this->insertionMode = $this->originalInsertionMode;
1681        $this->reprocess($token);
1682    }
1683
1684    private function flushPendingTableCharacters(): void
1685    {
1686        if ($this->pendingTableCharacters === []) {
1687            return;
1688        }
1689        if ($this->pendingTableCharactersHaveNonWhitespace) {
1690            // Per spec: process each character via InBody rules with foster
1691            // parenting enabled.
1692            $combined = implode('', $this->pendingTableCharacters);
1693            $previous = $this->fosterParenting;
1694            $this->fosterParenting = true;
1695            try {
1696                $this->reconstructActiveFormatting();
1697                $this->insertCharacter(new CharacterToken($combined));
1698            } finally {
1699                $this->fosterParenting = $previous;
1700            }
1701            $this->framesetOk = false;
1702        } else {
1703            // All-whitespace: insert verbatim into table context.
1704            foreach ($this->pendingTableCharacters as $chunk) {
1705                $this->insertCharacter(new CharacterToken($chunk));
1706            }
1707        }
1708        $this->pendingTableCharacters = [];
1709        $this->pendingTableCharactersHaveNonWhitespace = false;
1710    }
1711
1712    // ============================================================
1713    // InCaption (§13.2.6.4.11)
1714    // ============================================================
1715    private function modeInCaption(Token $token, Tokenizer $tokenizer): void
1716    {
1717        if ($token instanceof EndTagToken && $token->tagName === 'caption') {
1718            if (!$this->openElements->hasInTableScope('caption')) {
1719                return; // parse error
1720            }
1721            $this->openElements->generateImpliedEndTags();
1722            $this->openElements->popUntilLocalName('caption');
1723            $this->activeFormatting->clearToLastMarker();
1724            $this->insertionMode = InsertionMode::InTable;
1725            return;
1726        }
1727        if ($token instanceof StartTagToken
1728            && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
1729        ) {
1730            // Implicit </caption>, then reprocess in InTable.
1731            if (!$this->openElements->hasInTableScope('caption')) {
1732                return;
1733            }
1734            $this->openElements->generateImpliedEndTags();
1735            $this->openElements->popUntilLocalName('caption');
1736            $this->activeFormatting->clearToLastMarker();
1737            $this->insertionMode = InsertionMode::InTable;
1738            $this->reprocess($token);
1739            return;
1740        }
1741        if ($token instanceof EndTagToken && $token->tagName === 'table') {
1742            if (!$this->openElements->hasInTableScope('caption')) {
1743                return;
1744            }
1745            $this->openElements->generateImpliedEndTags();
1746            $this->openElements->popUntilLocalName('caption');
1747            $this->activeFormatting->clearToLastMarker();
1748            $this->insertionMode = InsertionMode::InTable;
1749            $this->reprocess($token);
1750            return;
1751        }
1752        if ($token instanceof EndTagToken
1753            && in_array($token->tagName, ['body', 'col', 'colgroup', 'html', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
1754        ) {
1755            return; // parse error
1756        }
1757        // Anything else: process under InBody.
1758        $this->modeInBody($token, $tokenizer);
1759    }
1760
1761    // ============================================================
1762    // InColumnGroup (§13.2.6.4.12)
1763    // ============================================================
1764    private function modeInColumnGroup(Token $token, Tokenizer $tokenizer): void
1765    {
1766        if ($this->isWhitespaceOnlyCharacter($token)) {
1767            $this->insertCharacter($token);
1768            return;
1769        }
1770        if ($token instanceof CommentToken) {
1771            $this->insertComment($token);
1772            return;
1773        }
1774        if ($token instanceof DoctypeToken) {
1775            return;
1776        }
1777        if ($token instanceof StartTagToken && $token->tagName === 'col') {
1778            $this->insertHtmlElement($token);
1779            $this->openElements->pop();
1780            return;
1781        }
1782        if ($token instanceof EndTagToken && $token->tagName === 'colgroup') {
1783            $current = $this->openElements->currentNode();
1784            if ($current === null || $current->localName !== 'colgroup') {
1785                return; // parse error
1786            }
1787            $this->openElements->pop();
1788            $this->insertionMode = InsertionMode::InTable;
1789            return;
1790        }
1791        if ($token instanceof EndTagToken && $token->tagName === 'col') {
1792            return; // parse error
1793        }
1794        if ($token instanceof StartTagToken && $token->tagName === 'template') {
1795            $this->modeInHead($token, $tokenizer);
1796            return;
1797        }
1798        if ($token instanceof EndTagToken && $token->tagName === 'template') {
1799            $this->modeInHead($token, $tokenizer);
1800            return;
1801        }
1802        // "Anything else" â€” implicit </colgroup>, then reprocess in InTable.
1803        $current = $this->openElements->currentNode();
1804        if ($current === null || $current->localName !== 'colgroup') {
1805            return; // parse error
1806        }
1807        $this->openElements->pop();
1808        $this->insertionMode = InsertionMode::InTable;
1809        $this->reprocess($token);
1810    }
1811
1812    // ============================================================
1813    // InTableBody (§13.2.6.4.13)
1814    // ============================================================
1815    private function modeInTableBody(Token $token, Tokenizer $tokenizer): void
1816    {
1817        if ($token instanceof StartTagToken && $token->tagName === 'tr') {
1818            $this->clearStackToTableBodyContext();
1819            $this->insertHtmlElement($token);
1820            $this->insertionMode = InsertionMode::InRow;
1821            return;
1822        }
1823        if ($token instanceof StartTagToken && in_array($token->tagName, ['th', 'td'], true)) {
1824            // Implicit <tr>, then reprocess.
1825            $this->clearStackToTableBodyContext();
1826            $synthetic = $this->document->createElement('tr');
1827            $current = $this->openElements->currentNode();
1828            ($current ?? $this->document)->appendChild($synthetic);
1829            $this->openElements->push($synthetic);
1830            $this->insertionMode = InsertionMode::InRow;
1831            $this->reprocess($token);
1832            return;
1833        }
1834        if ($token instanceof EndTagToken
1835            && in_array($token->tagName, ['tbody', 'tfoot', 'thead'], true)
1836        ) {
1837            if (!$this->openElements->hasInTableScope($token->tagName)) {
1838                return;
1839            }
1840            $this->clearStackToTableBodyContext();
1841            $this->openElements->pop();
1842            $this->insertionMode = InsertionMode::InTable;
1843            return;
1844        }
1845        if (($token instanceof StartTagToken
1846                && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'tfoot', 'thead'], true))
1847            || ($token instanceof EndTagToken && $token->tagName === 'table')
1848        ) {
1849            $tbodyScope = $this->openElements->hasInTableScope('tbody')
1850                || $this->openElements->hasInTableScope('tfoot')
1851                || $this->openElements->hasInTableScope('thead');
1852            if (!$tbodyScope) {
1853                return; // parse error
1854            }
1855            $this->clearStackToTableBodyContext();
1856            $this->openElements->pop();
1857            $this->insertionMode = InsertionMode::InTable;
1858            $this->reprocess($token);
1859            return;
1860        }
1861        if ($token instanceof EndTagToken
1862            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html', 'td', 'th', 'tr'], true)
1863        ) {
1864            return; // parse error
1865        }
1866        $this->modeInTable($token, $tokenizer);
1867    }
1868
1869    // ============================================================
1870    // InRow (§13.2.6.4.14)
1871    // ============================================================
1872    private function modeInRow(Token $token, Tokenizer $tokenizer): void
1873    {
1874        if ($token instanceof StartTagToken && in_array($token->tagName, ['th', 'td'], true)) {
1875            $this->clearStackToTableRowContext();
1876            $this->insertHtmlElement($token);
1877            $this->insertionMode = InsertionMode::InCell;
1878            $this->activeFormatting->pushMarker();
1879            return;
1880        }
1881        if ($token instanceof EndTagToken && $token->tagName === 'tr') {
1882            if (!$this->openElements->hasInTableScope('tr')) {
1883                return;
1884            }
1885            $this->clearStackToTableRowContext();
1886            $this->openElements->pop();
1887            $this->insertionMode = InsertionMode::InTableBody;
1888            return;
1889        }
1890        if (($token instanceof StartTagToken
1891                && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'tfoot', 'thead', 'tr'], true))
1892            || ($token instanceof EndTagToken && $token->tagName === 'table')
1893        ) {
1894            if (!$this->openElements->hasInTableScope('tr')) {
1895                return;
1896            }
1897            $this->clearStackToTableRowContext();
1898            $this->openElements->pop();
1899            $this->insertionMode = InsertionMode::InTableBody;
1900            $this->reprocess($token);
1901            return;
1902        }
1903        if ($token instanceof EndTagToken && in_array($token->tagName, ['tbody', 'tfoot', 'thead'], true)) {
1904            if (!$this->openElements->hasInTableScope($token->tagName)) {
1905                return; // parse error
1906            }
1907            if (!$this->openElements->hasInTableScope('tr')) {
1908                return;
1909            }
1910            $this->clearStackToTableRowContext();
1911            $this->openElements->pop();
1912            $this->insertionMode = InsertionMode::InTableBody;
1913            $this->reprocess($token);
1914            return;
1915        }
1916        if ($token instanceof EndTagToken
1917            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html', 'td', 'th'], true)
1918        ) {
1919            return; // parse error
1920        }
1921        $this->modeInTable($token, $tokenizer);
1922    }
1923
1924    // ============================================================
1925    // InCell (§13.2.6.4.15)
1926    // ============================================================
1927    private function modeInCell(Token $token, Tokenizer $tokenizer): void
1928    {
1929        if ($token instanceof EndTagToken && in_array($token->tagName, ['td', 'th'], true)) {
1930            if (!$this->openElements->hasInTableScope($token->tagName)) {
1931                return;
1932            }
1933            $this->openElements->generateImpliedEndTags();
1934            $this->openElements->popUntilLocalName($token->tagName);
1935            $this->activeFormatting->clearToLastMarker();
1936            $this->insertionMode = InsertionMode::InRow;
1937            return;
1938        }
1939        if ($token instanceof StartTagToken
1940            && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
1941        ) {
1942            if (!$this->openElements->hasInTableScope('td')
1943                && !$this->openElements->hasInTableScope('th')
1944            ) {
1945                return; // parse error
1946            }
1947            $this->closeCell();
1948            $this->reprocess($token);
1949            return;
1950        }
1951        if ($token instanceof EndTagToken
1952            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html'], true)
1953        ) {
1954            return; // parse error
1955        }
1956        if ($token instanceof EndTagToken
1957            && in_array($token->tagName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true)
1958        ) {
1959            if (!$this->openElements->hasInTableScope($token->tagName)) {
1960                return;
1961            }
1962            $this->closeCell();
1963            $this->reprocess($token);
1964            return;
1965        }
1966        $this->modeInBody($token, $tokenizer);
1967    }
1968
1969    // ============================================================
1970    // InFrameset / AfterFrameset / AfterAfterFrameset (§13.2.6.4.19–21)
1971    // ============================================================
1972    private function modeInFrameset(Token $token): void
1973    {
1974        if ($this->isWhitespaceOnlyCharacter($token)) {
1975            $this->insertCharacter($token);
1976            return;
1977        }
1978        if ($token instanceof CommentToken) {
1979            $this->insertComment($token);
1980            return;
1981        }
1982        if ($token instanceof DoctypeToken) {
1983            return;
1984        }
1985        if ($token instanceof StartTagToken) {
1986            $tag = $token->tagName;
1987            if ($tag === 'html') {
1988                $this->processInBodyForStrayHtml($token);
1989                return;
1990            }
1991            if ($tag === 'frameset') {
1992                $this->insertHtmlElement($token);
1993                return;
1994            }
1995            if ($tag === 'frame') {
1996                $this->insertHtmlElement($token);
1997                $this->openElements->pop(); // void
1998                return;
1999            }
2000            if ($tag === 'noframes') {
2001                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
2002                return;
2003            }
2004            return; // parse error, ignore
2005        }
2006        if ($token instanceof EndTagToken) {
2007            if ($token->tagName === 'frameset') {
2008                $current = $this->openElements->currentNode();
2009                if ($current === null || ($current->localName === 'html' && $current->namespaceURI === Document::HTML_NS)) {
2010                    return; // parse error in fragment mode
2011                }
2012                $this->openElements->pop();
2013                // If not in fragment mode and current node is no longer a frameset, switch to AfterFrameset.
2014                $current = $this->openElements->currentNode();
2015                if ($current === null || $current->localName !== 'frameset') {
2016                    $this->insertionMode = InsertionMode::AfterFrameset;
2017                }
2018                return;
2019            }
2020            return; // parse error, ignore
2021        }
2022        if ($token instanceof EofToken) {
2023            // Parse error if current node isn't html.
2024            $this->done = true;
2025        }
2026    }
2027
2028    private function modeAfterFrameset(Token $token, Tokenizer $tokenizer): void
2029    {
2030        if ($this->isWhitespaceOnlyCharacter($token)) {
2031            $this->insertCharacter($token);
2032            return;
2033        }
2034        if ($token instanceof CommentToken) {
2035            $this->insertComment($token);
2036            return;
2037        }
2038        if ($token instanceof DoctypeToken) {
2039            return;
2040        }
2041        if ($token instanceof StartTagToken) {
2042            if ($token->tagName === 'html') {
2043                $this->processInBodyForStrayHtml($token);
2044                return;
2045            }
2046            if ($token->tagName === 'noframes') {
2047                $this->modeInHead($token, $tokenizer);
2048                return;
2049            }
2050            return; // parse error, ignore
2051        }
2052        if ($token instanceof EndTagToken && $token->tagName === 'html') {
2053            $this->insertionMode = InsertionMode::AfterAfterFrameset;
2054            return;
2055        }
2056        if ($token instanceof EofToken) {
2057            $this->done = true;
2058        }
2059    }
2060
2061    private function modeAfterAfterFrameset(Token $token, Tokenizer $tokenizer): void
2062    {
2063        if ($token instanceof CommentToken) {
2064            $this->document->appendChild($this->document->createComment($token->data));
2065            return;
2066        }
2067        if ($token instanceof DoctypeToken) {
2068            return;
2069        }
2070        if ($this->isWhitespaceOnlyCharacter($token)) {
2071            $this->insertCharacter($token);
2072            return;
2073        }
2074        if ($token instanceof StartTagToken && $token->tagName === 'html') {
2075            $this->processInBodyForStrayHtml($token);
2076            return;
2077        }
2078        if ($token instanceof StartTagToken && $token->tagName === 'noframes') {
2079            $this->modeInHead($token, $tokenizer);
2080            return;
2081        }
2082        if ($token instanceof EofToken) {
2083            $this->done = true;
2084        }
2085    }
2086
2087    // ============================================================
2088    // InHeadNoscript (§13.2.6.4.5) â€” only used when scripting is enabled
2089    // ============================================================
2090    private function modeInHeadNoscript(Token $token, Tokenizer $tokenizer): void
2091    {
2092        if ($token instanceof DoctypeToken) {
2093            return; // parse error
2094        }
2095        if ($token instanceof StartTagToken && $token->tagName === 'html') {
2096            $this->processInBodyForStrayHtml($token);
2097            return;
2098        }
2099        if ($token instanceof EndTagToken && $token->tagName === 'noscript') {
2100            $this->openElements->pop();
2101            $this->insertionMode = InsertionMode::InHead;
2102            return;
2103        }
2104        if ($this->isWhitespaceOnlyCharacter($token)
2105            || $token instanceof CommentToken
2106            || ($token instanceof StartTagToken && in_array($token->tagName, [
2107                'basefont', 'bgsound', 'link', 'meta', 'noframes', 'style',
2108            ], true))
2109        ) {
2110            $this->modeInHead($token, $tokenizer);
2111            return;
2112        }
2113        if ($token instanceof EndTagToken && $token->tagName === 'br') {
2114            // "Any other end tag" fallthrough â€” handled below.
2115        } elseif ($token instanceof EndTagToken) {
2116            return; // parse error, ignore
2117        }
2118        if ($token instanceof StartTagToken && in_array($token->tagName, ['head', 'noscript'], true)) {
2119            return; // parse error, ignore
2120        }
2121        // Anything else: parse error. Pop noscript, back to InHead, reprocess.
2122        $this->openElements->pop();
2123        $this->insertionMode = InsertionMode::InHead;
2124        $this->reprocess($token);
2125    }
2126
2127    /**
2128     * SVG element name case corrections per WHATWG Â§13.2.6.5. The tokenizer
2129     * lower-cases tag names; SVG uses camelCase for several elements and
2130     * the parser is required to restore the canonical form.
2131     */
2132    private const array SVG_TAG_CASE_CORRECTIONS = [
2133        'altglyph' => 'altGlyph', 'altglyphdef' => 'altGlyphDef',
2134        'altglyphitem' => 'altGlyphItem', 'animatecolor' => 'animateColor',
2135        'animatemotion' => 'animateMotion', 'animatetransform' => 'animateTransform',
2136        'clippath' => 'clipPath', 'feblend' => 'feBlend',
2137        'fecolormatrix' => 'feColorMatrix', 'fecomponenttransfer' => 'feComponentTransfer',
2138        'fecomposite' => 'feComposite', 'feconvolvematrix' => 'feConvolveMatrix',
2139        'fediffuselighting' => 'feDiffuseLighting', 'fedisplacementmap' => 'feDisplacementMap',
2140        'fedistantlight' => 'feDistantLight', 'fedropshadow' => 'feDropShadow',
2141        'feflood' => 'feFlood', 'fefunca' => 'feFuncA', 'fefuncb' => 'feFuncB',
2142        'fefuncg' => 'feFuncG', 'fefuncr' => 'feFuncR', 'fegaussianblur' => 'feGaussianBlur',
2143        'feimage' => 'feImage', 'femerge' => 'feMerge', 'femergenode' => 'feMergeNode',
2144        'femorphology' => 'feMorphology', 'feoffset' => 'feOffset',
2145        'fepointlight' => 'fePointLight', 'fespecularlighting' => 'feSpecularLighting',
2146        'fespotlight' => 'feSpotLight', 'fetile' => 'feTile', 'feturbulence' => 'feTurbulence',
2147        'foreignobject' => 'foreignObject', 'glyphref' => 'glyphRef',
2148        'lineargradient' => 'linearGradient', 'radialgradient' => 'radialGradient',
2149        'textpath' => 'textPath',
2150    ];
2151
2152    /**
2153     * HTML-element names that "break out" of foreign content per Â§13.2.6.5
2154     * "Any start tag whose tag name is one of: ..." â€” encountering one of
2155     * these in foreign content pops back to HTML.
2156     */
2157    private const array FOREIGN_BREAKOUT_TAGS = [
2158        'b', 'big', 'blockquote', 'body', 'br', 'center', 'code', 'dd', 'div',
2159        'dl', 'dt', 'em', 'embed', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head',
2160        'hr', 'i', 'img', 'li', 'listing', 'menu', 'meta', 'nobr', 'ol', 'p',
2161        'pre', 'ruby', 's', 'small', 'span', 'strong', 'strike', 'sub', 'sup',
2162        'table', 'tt', 'u', 'ul', 'var',
2163    ];
2164
2165    private function shouldDispatchInForeignContent(Token $token): bool
2166    {
2167        $adjustedCurrent = $this->adjustedCurrentNode();
2168        if ($adjustedCurrent === null || $adjustedCurrent->namespaceURI === Document::HTML_NS) {
2169            return false;
2170        }
2171        // EOF: handled by the regular mode.
2172        if ($token instanceof EofToken) {
2173            return false;
2174        }
2175        // Per WHATWG Â§13.2.6.1 tree-construction dispatcher: at integration
2176        // points HTML rules win over foreign-content rules. Without these
2177        // gates, a break-out tag inside (say) <foreignObject> would pop back
2178        // to foreignObject, re-dispatch, and bounce into foreign content
2179        // again â€” infinite loop.
2180        if ($this->isMathmlTextIntegrationPoint($adjustedCurrent)) {
2181            if ($token instanceof CharacterToken) {
2182                return false;
2183            }
2184            if ($token instanceof StartTagToken
2185                && $token->tagName !== 'mglyph'
2186                && $token->tagName !== 'malignmark'
2187            ) {
2188                return false;
2189            }
2190        }
2191        if ($adjustedCurrent->namespaceURI === Document::MATHML_NS
2192            && $adjustedCurrent->localName === 'annotation-xml'
2193            && $token instanceof StartTagToken
2194            && $token->tagName === 'svg'
2195        ) {
2196            return false;
2197        }
2198        if ($this->isHtmlIntegrationPoint($adjustedCurrent)) {
2199            if ($token instanceof StartTagToken || $token instanceof CharacterToken) {
2200                return false;
2201            }
2202        }
2203        return true;
2204    }
2205
2206    private function adjustedCurrentNode(): ?Element
2207    {
2208        // Fragment parsing isn't wired in yet; adjusted current node === current node.
2209        return $this->openElements->currentNode();
2210    }
2211
2212    /**
2213     * Insert a foreign element (SVG or MathML) onto the stack with namespace
2214     * applied. $caseTable optionally remaps lower-case tokenizer names back
2215     * to canonical camelCase (used for SVG element names).
2216     *
2217     * @param array<string, string> $caseTable
2218     */
2219    private function insertForeignElement(StartTagToken $token, string $namespace, array $caseTable): Element
2220    {
2221        $localName = $caseTable[$token->tagName] ?? $token->tagName;
2222        $element = $this->document->createElement($localName, $namespace);
2223        foreach ($token->attributes as $attr) {
2224            $element->setAttribute($attr['name'], $attr['value']);
2225        }
2226        [$parent, $before] = $this->appropriatePlaceForInserting();
2227        if ($before !== null) {
2228            $parent->insertBefore($element, $before);
2229        } else {
2230            $parent->appendChild($element);
2231        }
2232        $this->openElements->push($element);
2233        return $element;
2234    }
2235
2236    /**
2237     * Foreign content processing per Â§13.2.6.5. Phase 1B.3-bis implements the
2238     * common path: namespaced element insertion, character data, comments,
2239     * end-tag matching, and the "break out" tags that pop back to HTML.
2240     */
2241    private function modeInForeignContent(Token $token): void
2242    {
2243        if ($token instanceof CharacterToken) {
2244            if ($token->data === "\u{0000}") {
2245                $this->insertCharacter(new CharacterToken("\u{FFFD}"));
2246                return;
2247            }
2248            if (preg_match('/[^\t\n\f\r ]/', $token->data) === 1) {
2249                $this->framesetOk = false;
2250            }
2251            $this->insertCharacter($token);
2252            return;
2253        }
2254        if ($token instanceof CommentToken) {
2255            $this->insertComment($token);
2256            return;
2257        }
2258        if ($token instanceof DoctypeToken) {
2259            return; // parse error, ignore
2260        }
2261        if ($token instanceof StartTagToken) {
2262            $tag = $token->tagName;
2263            // Break-out check.
2264            $isFontWithBreakoutAttr = false;
2265            if ($tag === 'font') {
2266                foreach ($token->attributes as $attr) {
2267                    if (in_array($attr['name'], ['color', 'face', 'size'], true)) {
2268                        $isFontWithBreakoutAttr = true;
2269                        break;
2270                    }
2271                }
2272            }
2273            if (in_array($tag, self::FOREIGN_BREAKOUT_TAGS, true) || $isFontWithBreakoutAttr) {
2274                // Pop until we're back in HTML or at a foreign-text-integration point.
2275                while (!$this->openElements->isEmpty()) {
2276                    $current = $this->openElements->currentNode();
2277                    if ($current === null
2278                        || $current->namespaceURI === Document::HTML_NS
2279                        || $this->isMathmlTextIntegrationPoint($current)
2280                        || $this->isHtmlIntegrationPoint($current)
2281                    ) {
2282                        break;
2283                    }
2284                    $this->openElements->pop();
2285                }
2286                $this->dispatch($token, $this->activeTokenizer ?? new Tokenizer(''));
2287                return;
2288            }
2289            $adjusted = $this->adjustedCurrentNode();
2290            if ($adjusted === null) {
2291                return;
2292            }
2293            $namespace = $adjusted->namespaceURI;
2294            $caseTable = $namespace === Document::SVG_NS ? self::SVG_TAG_CASE_CORRECTIONS : [];
2295            $this->insertForeignElement($token, $namespace, $caseTable);
2296            if ($token->selfClosing) {
2297                $this->openElements->pop();
2298            }
2299            return;
2300        }
2301        if ($token instanceof EndTagToken) {
2302            $tag = $token->tagName;
2303            $items = $this->openElements->items();
2304            $i = array_key_last($items);
2305            if ($i === null) {
2306                return;
2307            }
2308            // Per spec: if current node's local name (case-insensitive for
2309            // foreign) doesn't match the end tag, walk up looking for a match.
2310            $node = $items[$i];
2311            if (strcasecmp($node->localName, $tag) !== 0) {
2312                // parse error, but continue walking
2313            }
2314            for (; $i >= 0; $i--) {
2315                $node = $items[$i];
2316                if (strcasecmp($node->localName, $tag) === 0) {
2317                    while ($this->openElements->count() - 1 > $i) {
2318                        $this->openElements->pop();
2319                    }
2320                    $this->openElements->pop();
2321                    return;
2322                }
2323                if ($node->namespaceURI === Document::HTML_NS) {
2324                    // Process per regular insertion mode rules.
2325                    match ($this->insertionMode) {
2326                        InsertionMode::InBody => $this->modeInBody($token, $this->activeTokenizer ?? new Tokenizer('')),
2327                        default => null,
2328                    };
2329                    return;
2330                }
2331            }
2332        }
2333    }
2334
2335    /**
2336     * MathML text integration points per spec: mi, mo, mn, ms, mtext in the
2337     * MathML namespace. Phase 1B.3-bis ships this for the break-out check;
2338     * full integration-point handling (which lets HTML breach into mtext etc.)
2339     * lands in a follow-up.
2340     */
2341    private function isMathmlTextIntegrationPoint(Element $el): bool
2342    {
2343        return $el->namespaceURI === Document::MATHML_NS
2344            && in_array($el->localName, ['mi', 'mo', 'mn', 'ms', 'mtext'], true);
2345    }
2346
2347    /**
2348     * HTML integration points per spec: `<annotation-xml>` with encoding
2349     * text/html or application/xhtml+xml (MathML), and `<foreignObject>`,
2350     * `<desc>`, `<title>` in SVG.
2351     */
2352    private function isHtmlIntegrationPoint(Element $el): bool
2353    {
2354        if ($el->namespaceURI === Document::MATHML_NS && $el->localName === 'annotation-xml') {
2355            $enc = strtolower($el->getAttribute('encoding') ?? '');
2356            return $enc === 'text/html' || $enc === 'application/xhtml+xml';
2357        }
2358        if ($el->namespaceURI === Document::SVG_NS) {
2359            return in_array($el->localName, ['foreignObject', 'desc', 'title'], true);
2360        }
2361        return false;
2362    }
2363
2364    // ============================================================
2365    // InTemplate (§13.2.6.4.18)
2366    // ============================================================
2367    private function modeInTemplate(Token $token, Tokenizer $tokenizer): void
2368    {
2369        if ($token instanceof CharacterToken
2370            || $token instanceof CommentToken
2371            || $token instanceof DoctypeToken
2372        ) {
2373            $this->modeInBody($token, $tokenizer);
2374            return;
2375        }
2376        if ($token instanceof StartTagToken) {
2377            $tag = $token->tagName;
2378            if (in_array($tag, ['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'script', 'style', 'template', 'title'], true)) {
2379                $this->modeInHead($token, $tokenizer);
2380                return;
2381            }
2382            if (in_array($tag, ['caption', 'colgroup', 'tbody', 'tfoot', 'thead'], true)) {
2383                array_pop($this->templateInsertionModes);
2384                $this->templateInsertionModes[] = InsertionMode::InTable;
2385                $this->insertionMode = InsertionMode::InTable;
2386                $this->reprocess($token);
2387                return;
2388            }
2389            if ($tag === 'col') {
2390                array_pop($this->templateInsertionModes);
2391                $this->templateInsertionModes[] = InsertionMode::InColumnGroup;
2392                $this->insertionMode = InsertionMode::InColumnGroup;
2393                $this->reprocess($token);
2394                return;
2395            }
2396            if ($tag === 'tr') {
2397                array_pop($this->templateInsertionModes);
2398                $this->templateInsertionModes[] = InsertionMode::InTableBody;
2399                $this->insertionMode = InsertionMode::InTableBody;
2400                $this->reprocess($token);
2401                return;
2402            }
2403            if (in_array($tag, ['td', 'th'], true)) {
2404                array_pop($this->templateInsertionModes);
2405                $this->templateInsertionModes[] = InsertionMode::InRow;
2406                $this->insertionMode = InsertionMode::InRow;
2407                $this->reprocess($token);
2408                return;
2409            }
2410            // Any other start tag.
2411            array_pop($this->templateInsertionModes);
2412            $this->templateInsertionModes[] = InsertionMode::InBody;
2413            $this->insertionMode = InsertionMode::InBody;
2414            $this->reprocess($token);
2415            return;
2416        }
2417        if ($token instanceof EndTagToken) {
2418            if ($token->tagName === 'template') {
2419                $this->modeInHead($token, $tokenizer);
2420                return;
2421            }
2422            return; // parse error, ignore other end tags
2423        }
2424        if ($token instanceof EofToken) {
2425            if (!$this->openElements->containsLocalName('template')) {
2426                $this->done = true;
2427                return;
2428            }
2429            // Pop until template popped, clear AFE to marker, pop template
2430            // insertion mode, reset insertion mode, reprocess.
2431            $this->openElements->popUntilLocalName('template');
2432            $this->activeFormatting->clearToLastMarker();
2433            array_pop($this->templateInsertionModes);
2434            $this->resetInsertionModeAppropriately();
2435            $this->reprocess($token);
2436        }
2437    }
2438
2439    // ============================================================
2440    // InSelect (§13.2.6.4.16)
2441    // ============================================================
2442    private function modeInSelect(Token $token): void
2443    {
2444        if ($token instanceof CharacterToken) {
2445            if ($token->data === "\u{0000}") {
2446                return; // parse error, drop
2447            }
2448            $this->insertCharacter($token);
2449            return;
2450        }
2451        if ($token instanceof CommentToken) {
2452            $this->insertComment($token);
2453            return;
2454        }
2455        if ($token instanceof DoctypeToken) {
2456            return;
2457        }
2458        if ($token instanceof StartTagToken) {
2459            $tag = $token->tagName;
2460            if ($tag === 'html') {
2461                $this->processInBodyForStrayHtml($token);
2462                return;
2463            }
2464            if ($tag === 'option') {
2465                $current = $this->openElements->currentNode();
2466                if ($current !== null && $current->localName === 'option') {
2467                    $this->openElements->pop();
2468                }
2469                $this->insertHtmlElement($token);
2470                return;
2471            }
2472            if ($tag === 'optgroup') {
2473                $current = $this->openElements->currentNode();
2474                if ($current !== null && $current->localName === 'option') {
2475                    $this->openElements->pop();
2476                }
2477                $current = $this->openElements->currentNode();
2478                if ($current !== null && $current->localName === 'optgroup') {
2479                    $this->openElements->pop();
2480                }
2481                $this->insertHtmlElement($token);
2482                return;
2483            }
2484            if ($tag === 'select') {
2485                // Parse error: treat as </select>.
2486                if (!$this->openElements->hasInSelectScope('select')) {
2487                    return;
2488                }
2489                $this->openElements->popUntilLocalName('select');
2490                $this->resetInsertionModeAppropriately();
2491                return;
2492            }
2493            if (in_array($tag, ['input', 'keygen', 'textarea'], true)) {
2494                // Parse error: implicit </select>, then reprocess.
2495                if (!$this->openElements->hasInSelectScope('select')) {
2496                    return;
2497                }
2498                $this->openElements->popUntilLocalName('select');
2499                $this->resetInsertionModeAppropriately();
2500                $this->reprocess($token);
2501                return;
2502            }
2503            if (in_array($tag, ['script', 'template'], true)) {
2504                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
2505                return;
2506            }
2507            return; // parse error, ignore other start tags
2508        }
2509        if ($token instanceof EndTagToken) {
2510            $tag = $token->tagName;
2511            if ($tag === 'optgroup') {
2512                $items = $this->openElements->items();
2513                $top = $items[count($items) - 1] ?? null;
2514                $previous = $items[count($items) - 2] ?? null;
2515                if ($top !== null && $top->localName === 'option'
2516                    && $previous !== null && $previous->localName === 'optgroup'
2517                ) {
2518                    $this->openElements->pop();
2519                }
2520                $current = $this->openElements->currentNode();
2521                if ($current !== null && $current->localName === 'optgroup') {
2522                    $this->openElements->pop();
2523                }
2524                return;
2525            }
2526            if ($tag === 'option') {
2527                $current = $this->openElements->currentNode();
2528                if ($current !== null && $current->localName === 'option') {
2529                    $this->openElements->pop();
2530                }
2531                return;
2532            }
2533            if ($tag === 'select') {
2534                if (!$this->openElements->hasInSelectScope('select')) {
2535                    return; // parse error
2536                }
2537                $this->openElements->popUntilLocalName('select');
2538                $this->resetInsertionModeAppropriately();
2539                return;
2540            }
2541            if ($tag === 'template') {
2542                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
2543                return;
2544            }
2545            return; // parse error, ignore
2546        }
2547        if ($token instanceof EofToken) {
2548            $this->modeInBody($token, $this->activeTokenizer ?? new Tokenizer(''));
2549        }
2550    }
2551
2552    // ============================================================
2553    // InSelectInTable (§13.2.6.4.17)
2554    // ============================================================
2555    private function modeInSelectInTable(Token $token, Tokenizer $tokenizer): void
2556    {
2557        if ($token instanceof StartTagToken
2558            && in_array($token->tagName, ['caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th'], true)
2559        ) {
2560            // Implicit </select>, then reprocess in surrounding table mode.
2561            $this->openElements->popUntilLocalName('select');
2562            $this->resetInsertionModeAppropriately();
2563            $this->reprocess($token);
2564            return;
2565        }
2566        if ($token instanceof EndTagToken
2567            && in_array($token->tagName, ['caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th'], true)
2568        ) {
2569            if (!$this->openElements->hasInTableScope($token->tagName)) {
2570                return; // parse error
2571            }
2572            $this->openElements->popUntilLocalName('select');
2573            $this->resetInsertionModeAppropriately();
2574            $this->reprocess($token);
2575            return;
2576        }
2577        $this->modeInSelect($token);
2578    }
2579
2580    private function closeCell(): void
2581    {
2582        $cellName = $this->openElements->hasInTableScope('td') ? 'td' : 'th';
2583        $this->openElements->generateImpliedEndTags();
2584        $this->openElements->popUntilLocalName($cellName);
2585        $this->activeFormatting->clearToLastMarker();
2586        $this->insertionMode = InsertionMode::InRow;
2587    }
2588
2589    // ============================================================
2590    // Table helpers
2591    // ============================================================
2592    private function currentNodeIsTableContext(): bool
2593    {
2594        $current = $this->openElements->currentNode();
2595        if ($current === null || $current->namespaceURI !== Document::HTML_NS) {
2596            return false;
2597        }
2598        return in_array($current->localName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true);
2599    }
2600
2601    private function clearStackToTableContext(): void
2602    {
2603        while (true) {
2604            $current = $this->openElements->currentNode();
2605            if ($current === null) {
2606                return;
2607            }
2608            if (in_array($current->localName, ['table', 'template', 'html'], true)) {
2609                return;
2610            }
2611            $this->openElements->pop();
2612        }
2613    }
2614
2615    private function clearStackToTableBodyContext(): void
2616    {
2617        while (true) {
2618            $current = $this->openElements->currentNode();
2619            if ($current === null) {
2620                return;
2621            }
2622            if (in_array($current->localName, ['tbody', 'tfoot', 'thead', 'template', 'html'], true)) {
2623                return;
2624            }
2625            $this->openElements->pop();
2626        }
2627    }
2628
2629    private function clearStackToTableRowContext(): void
2630    {
2631        while (true) {
2632            $current = $this->openElements->currentNode();
2633            if ($current === null) {
2634                return;
2635            }
2636            if (in_array($current->localName, ['tr', 'template', 'html'], true)) {
2637                return;
2638            }
2639            $this->openElements->pop();
2640        }
2641    }
2642
2643    /**
2644     * Reset the insertion mode appropriately per Â§13.2.4.1. Walks the open
2645     * elements stack from the top down and picks the right mode based on
2646     * the deepest table-related ancestor (used after `</table>`, `</caption>`,
2647     * etc. where we exit a table sub-tree).
2648     */
2649    private function resetInsertionModeAppropriately(): void
2650    {
2651        $items = $this->openElements->items();
2652        $lastIdx = array_key_last($items);
2653        for ($i = $lastIdx; $i !== null && $i >= 0; $i--) {
2654            $node = $items[$i];
2655            $name = $node->localName;
2656            // Phase 1B.3 simplification: ignore the "last" flag from the
2657            // fragment-parsing case (no fragment parsing yet).
2658            if ($name === 'select') {
2659                $this->insertionMode = InsertionMode::InSelect;
2660                return;
2661            }
2662            if (in_array($name, ['td', 'th'], true) && $i !== 0) {
2663                $this->insertionMode = InsertionMode::InCell;
2664                return;
2665            }
2666            if ($name === 'tr') {
2667                $this->insertionMode = InsertionMode::InRow;
2668                return;
2669            }
2670            if (in_array($name, ['tbody', 'thead', 'tfoot'], true)) {
2671                $this->insertionMode = InsertionMode::InTableBody;
2672                return;
2673            }
2674            if ($name === 'caption') {
2675                $this->insertionMode = InsertionMode::InCaption;
2676                return;
2677            }
2678            if ($name === 'colgroup') {
2679                $this->insertionMode = InsertionMode::InColumnGroup;
2680                return;
2681            }
2682            if ($name === 'table') {
2683                $this->insertionMode = InsertionMode::InTable;
2684                return;
2685            }
2686            if ($name === 'template') {
2687                $top = $this->templateInsertionModes[count($this->templateInsertionModes) - 1] ?? null;
2688                $this->insertionMode = $top ?? InsertionMode::InTemplate;
2689                return;
2690            }
2691            if ($name === 'head' && $i !== 0) {
2692                $this->insertionMode = InsertionMode::InHead;
2693                return;
2694            }
2695            if ($name === 'body') {
2696                $this->insertionMode = InsertionMode::InBody;
2697                return;
2698            }
2699            if ($name === 'frameset') {
2700                $this->insertionMode = InsertionMode::InFrameset;
2701                return;
2702            }
2703            if ($name === 'html') {
2704                $this->insertionMode = $this->headElement === null
2705                    ? InsertionMode::BeforeHead
2706                    : InsertionMode::AfterHead;
2707                return;
2708            }
2709        }
2710        $this->insertionMode = InsertionMode::InBody;
2711    }
2712
2713    // ============================================================
2714    // Helpers
2715    // ============================================================
2716    private function isWhitespaceOnlyCharacter(Token $token): bool
2717    {
2718        if (!$token instanceof CharacterToken) {
2719            return false;
2720        }
2721        return preg_match('/^[\t\n\f\r ]+$/', $token->data) === 1;
2722    }
2723
2724    private function reprocess(Token $token): void
2725    {
2726        // Reprocess via the same active tokenizer so any state mutation
2727        // (RCDATA/RAWTEXT/ScriptData switches in InHead) takes effect on the
2728        // real stream rather than a throwaway instance.
2729        if ($this->activeTokenizer === null) {
2730            throw new \LogicException('reprocess called outside build()');
2731        }
2732        $this->dispatch($token, $this->activeTokenizer);
2733    }
2734
2735    /**
2736     * Resolve the `shadowrootmode` attribute on a `<template>` start tag into
2737     * a typed mode, or null if absent / invalid. The spec says only "open" and
2738     * "closed" are accepted; any other value is a missing-value state.
2739     */
2740    private function resolveShadowRootMode(StartTagToken $token): ?ShadowRootMode
2741    {
2742        foreach ($token->attributes as $attr) {
2743            if ($attr['name'] === 'shadowrootmode') {
2744                return match (strtolower($attr['value'])) {
2745                    'open' => ShadowRootMode::Open,
2746                    'closed' => ShadowRootMode::Closed,
2747                    default => null,
2748                };
2749            }
2750        }
2751        return null;
2752    }
2753
2754    private function tokenHasAttribute(StartTagToken $token, string $name): bool
2755    {
2756        foreach ($token->attributes as $attr) {
2757            if ($attr['name'] === $name) {
2758                return true;
2759            }
2760        }
2761        return false;
2762    }
2763
2764}