Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.16% covered (success)
96.16%
1779 / 1850
56.92% covered (warning)
56.92%
37 / 65
CRAP
0.00% covered (danger)
0.00%
0 / 1
TreeBuilder
96.16% covered (success)
96.16%
1779 / 1850
56.92% covered (warning)
56.92%
37 / 65
769
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
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 isCurrentNodeForeign
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dispatch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dispatchToInsertionMode
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
24
 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%
26 / 26
100.00% covered (success)
100.00%
1 / 1
18
 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%
50 / 50
100.00% covered (success)
100.00%
1 / 1
14
 modeInBody
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
 modeInBodyStartTag
98.57% covered (success)
98.57%
276 / 280
0.00% covered (danger)
0.00%
0 / 1
105
 modeInBodyEndTag
96.77% covered (success)
96.77%
90 / 93
0.00% covered (danger)
0.00%
0 / 1
30
 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
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 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.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
24.86
 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
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
9
 insertComment
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 adoptionAgency
93.68% covered (success)
93.68%
89 / 95
0.00% covered (danger)
0.00%
0 / 1
33.27
 processFormattingFallback
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 reconstructActiveFormatting
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
11
 modeInTable
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
 modeInTableStartTag
95.52% covered (success)
95.52%
64 / 67
0.00% covered (danger)
0.00%
0 / 1
20
 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%
36 / 36
100.00% covered (success)
100.00%
1 / 1
19
 modeInTableBody
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
18
 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%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 modeInForeignContent
95.89% covered (success)
95.89%
70 / 73
0.00% covered (danger)
0.00%
0 / 1
33
 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
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
14
 modeInSelect
95.10% covered (success)
95.10%
136 / 143
0.00% covered (danger)
0.00%
0 / 1
56
 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
90.74% covered (success)
90.74%
49 / 54
0.00% covered (danger)
0.00%
0 / 1
23.42
 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     * HTML 5 Â§13.2.6.4.7 â€” `<pre>` / `<listing>` / `<textarea>`
70     * start tags set this so the next character token's leading
71     * U+000A LF is silently dropped. The flag is single-shot:
72     * cleared on the next character insertion regardless of
73     * whether an LF was actually present.
74     */
75    private bool $skipLeadingNewlineOnNextChar = false;
76
77    /**
78     * Pending table character tokens: collected by InTableText, emitted as
79     * either text into the table (if all whitespace) or foster-parented out
80     * to the table's previous sibling (per Â§13.2.6.4.10).
81     *
82     * @var list<string>
83     */
84    private array $pendingTableCharacters = [];
85    private bool $pendingTableCharactersHaveNonWhitespace = false;
86
87    /**
88     * Foster-parenting flag. Toggled true only when an InTable / InTableBody /
89     * InRow handler falls through to "anything else" and dispatches into
90     * modeInBody â€” i.e. when the *content* (not the table's own row/cell
91     * structure) should land before the table.
92     */
93    private bool $fosterParenting = false;
94
95    /**
96     * Stack of template insertion modes. Pushed when a `<template>` start
97     * tag is encountered; popped on `</template>`. Used by InTemplate to
98     * recover the proper containing mode when nested templates close.
99     *
100     * @var list<InsertionMode>
101     */
102    private array $templateInsertionModes = [];
103
104    public function __construct(
105        public readonly ParserOptions $options = new ParserOptions(),
106        ?Document $document = null,
107    ) {
108        $this->document = $document ?? new Document();
109        $this->openElements = new OpenElementsStack();
110        $this->activeFormatting = new ActiveFormattingElements();
111    }
112
113    /**
114     * Parse an HTML fragment in the context of an element per WHATWG Â§13.4.
115     * Builds a synthetic `<html>` root, primes the open-elements stack and
116     * insertion mode based on the context, runs the parser, then returns the
117     * root's children wrapped in a fresh DocumentFragment.
118     */
119    public function buildFragment(Tokenizer $tokenizer, Element $context): \Phpdftk\Html\Dom\DocumentFragment
120    {
121        // Step 4: create the html root and push it.
122        $htmlRoot = $this->document->createElement('html');
123        $this->document->appendChild($htmlRoot);
124        $this->openElements->push($htmlRoot);
125
126        // Step 5: if context is a template, push InTemplate onto template-mode stack.
127        if ($context instanceof HTMLTemplateElement) {
128            $this->templateInsertionModes[] = InsertionMode::InTemplate;
129        }
130
131        // Step 6: reset insertion mode appropriately based on context.
132        $this->resetInsertionModeForFragment($context);
133
134        // Step 7: find the appropriate form element by walking up from context.
135        for ($node = $context; $node !== null; $node = $node->parentNode) {
136            if ($node instanceof Element
137                && $node->localName === 'form'
138                && $node->namespaceURI === Document::HTML_NS
139            ) {
140                $this->formElement = $node;
141                break;
142            }
143        }
144
145        // Step 8: run the parser.
146        $this->build($tokenizer);
147
148        // Step 9: move html root's children into a fragment owned by the
149        // CONTEXT's document (so the fragment is usable in that document).
150        $fragment = $context->ownerDocument->createDocumentFragment();
151        while ($htmlRoot->firstChild !== null) {
152            $child = $htmlRoot->firstChild;
153            $htmlRoot->removeChild($child);
154            // The child's ownerDocument is the synthetic doc; for the host's
155            // document to accept it cleanly we'd need to adopt it. For Phase
156            // 1B.4 we accept the cross-document linkage; consumers that need
157            // a strict same-document fragment should clone.
158            $fragment->appendChild($child);
159        }
160        return $fragment;
161    }
162
163    /**
164     * Reset insertion mode appropriately for fragment parsing â€” like the
165     * full algorithm but treats the context element as the implicit bottom
166     * of the open-elements stack. See WHATWG Â§13.2.4.1.
167     */
168    private function resetInsertionModeForFragment(Element $context): void
169    {
170        $name = $context->localName;
171        $ns = $context->namespaceURI;
172        if ($ns !== Document::HTML_NS) {
173            $this->insertionMode = InsertionMode::InBody;
174            return;
175        }
176        $this->insertionMode = match ($name) {
177            'select' => InsertionMode::InSelect,
178            'td', 'th' => InsertionMode::InCell,
179            'tr' => InsertionMode::InRow,
180            'tbody', 'thead', 'tfoot' => InsertionMode::InTableBody,
181            'caption' => InsertionMode::InCaption,
182            'colgroup' => InsertionMode::InColumnGroup,
183            'table' => InsertionMode::InTable,
184            'template' => InsertionMode::InTemplate,
185            'head' => InsertionMode::InHead,
186            'body' => InsertionMode::InBody,
187            'frameset' => InsertionMode::InFrameset,
188            'html' => InsertionMode::BeforeHead,
189            default => InsertionMode::InBody,
190        };
191    }
192
193    public function build(Tokenizer $tokenizer): Document
194    {
195        // Pull one token at a time so we can mutate the tokenizer state
196        // between tokens â€” e.g. switching to RCDATA when we see <title>,
197        // RAWTEXT for <style>, ScriptData for <script>. We also sync
198        // the tokenizer's `inForeignContent` flag at each step so the
199        // MarkupDeclarationOpen state knows when to treat `<![CDATA[`
200        // as a real CDATA section (foreign content) vs a bogus comment
201        // (HTML content).
202        $this->activeTokenizer = $tokenizer;
203        while (true) {
204            $tokenizer->inForeignContent = $this->isCurrentNodeForeign();
205            $token = $tokenizer->nextToken();
206            if ($token === null) {
207                break;
208            }
209            $this->dispatch($token, $tokenizer);
210            if ($this->done) {
211                break;
212            }
213        }
214        return $this->document;
215    }
216
217    /**
218     * True when the adjusted current node is in a non-HTML namespace
219     * (SVG or MathML). Used to keep the tokenizer's CDATA-recognition
220     * gate aligned with the tree builder's foreign-content state.
221     */
222    private function isCurrentNodeForeign(): bool
223    {
224        $current = $this->adjustedCurrentNode();
225        if ($current === null) {
226            return false;
227        }
228        return $current->namespaceURI !== Document::HTML_NS;
229    }
230
231    private function dispatch(Token $token, Tokenizer $tokenizer): void
232    {
233        // Foreign content (SVG / MathML) dispatch per WHATWG Â§13.2.6.5. If
234        // the adjusted current node is in a foreign namespace AND the token
235        // isn't an explicit "breakout" trigger, process via the foreign-
236        // content rules instead of the normal insertion mode.
237        if ($this->shouldDispatchInForeignContent($token)) {
238            $this->modeInForeignContent($token);
239            return;
240        }
241        $this->dispatchToInsertionMode($token);
242    }
243
244    /**
245     * Run the current insertion-mode handler directly, bypassing the
246     * foreign-content gate. Used both from `dispatch()` (after the
247     * gate has been checked) and from the foreign-content end-tag
248     * fallback where we know we want HTML processing even though the
249     * current node is still foreign.
250     */
251    private function dispatchToInsertionMode(Token $token): void
252    {
253        $tokenizer = $this->activeTokenizer ?? new Tokenizer('');
254        match ($this->insertionMode) {
255            InsertionMode::Initial => $this->modeInitial($token),
256            InsertionMode::BeforeHtml => $this->modeBeforeHtml($token),
257            InsertionMode::BeforeHead => $this->modeBeforeHead($token),
258            InsertionMode::InHead => $this->modeInHead($token, $tokenizer),
259            InsertionMode::AfterHead => $this->modeAfterHead($token),
260            InsertionMode::InBody => $this->modeInBody($token, $tokenizer),
261            InsertionMode::Text => $this->modeText($token),
262            InsertionMode::AfterBody => $this->modeAfterBody($token),
263            InsertionMode::AfterAfterBody => $this->modeAfterAfterBody($token),
264            InsertionMode::InTable => $this->modeInTable($token, $tokenizer),
265            InsertionMode::InTableText => $this->modeInTableText($token, $tokenizer),
266            InsertionMode::InCaption => $this->modeInCaption($token, $tokenizer),
267            InsertionMode::InColumnGroup => $this->modeInColumnGroup($token, $tokenizer),
268            InsertionMode::InTableBody => $this->modeInTableBody($token, $tokenizer),
269            InsertionMode::InRow => $this->modeInRow($token, $tokenizer),
270            InsertionMode::InCell => $this->modeInCell($token, $tokenizer),
271            InsertionMode::InSelect => $this->modeInSelect($token),
272            InsertionMode::InSelectInTable => $this->modeInSelectInTable($token, $tokenizer),
273            InsertionMode::InTemplate => $this->modeInTemplate($token, $tokenizer),
274            InsertionMode::InFrameset => $this->modeInFrameset($token),
275            InsertionMode::AfterFrameset => $this->modeAfterFrameset($token, $tokenizer),
276            InsertionMode::AfterAfterFrameset => $this->modeAfterAfterFrameset($token, $tokenizer),
277            InsertionMode::InHeadNoscript => $this->modeInHeadNoscript($token, $tokenizer),
278        };
279    }
280
281    // ============================================================
282    // Initial
283    // ============================================================
284    private function modeInitial(Token $token): void
285    {
286        if ($this->isWhitespaceOnlyCharacter($token)) {
287            return;
288        }
289        if ($token instanceof CommentToken) {
290            $this->document->appendChild($this->document->createComment($token->data));
291            return;
292        }
293        if ($token instanceof DoctypeToken) {
294            $name = $token->name ?? '';
295            $publicId = $token->publicId ?? '';
296            $systemId = $token->systemId ?? '';
297            $this->document->appendChild(new DocumentType($this->document, $name, $publicId, $systemId));
298            $this->document->mode = $this->resolveDocumentMode($token);
299            $this->insertionMode = InsertionMode::BeforeHtml;
300            return;
301        }
302        // No DOCTYPE â€” quirks mode.
303        $this->document->mode = DocumentMode::Quirks;
304        $this->insertionMode = InsertionMode::BeforeHtml;
305        $this->reprocess($token);
306    }
307
308    /**
309     * Public identifiers whose presence (anywhere, case-insensitively) forces
310     * quirks mode per WHATWG Â§13.2.6.2. Stored lowercase, compared with
311     * `str_starts_with` against `strtolower($publicId)`.
312     *
313     * @var list<string>
314     */
315    private const array QUIRKS_PUBLIC_ID_PREFIXES = [
316        '+//silmaril//dtd html pro v0r11 19970101//',
317        '-//as//dtd html 3.0 aswedit + extensions//',
318        '-//advasoft ltd//dtd html 3.0 aswedit + extensions//',
319        '-//ietf//dtd html 2.0 level 1//',
320        '-//ietf//dtd html 2.0 level 2//',
321        '-//ietf//dtd html 2.0 strict level 1//',
322        '-//ietf//dtd html 2.0 strict level 2//',
323        '-//ietf//dtd html 2.0 strict//',
324        '-//ietf//dtd html 2.0//',
325        '-//ietf//dtd html 2.1e//',
326        '-//ietf//dtd html 3.0//',
327        '-//ietf//dtd html 3.2 final//',
328        '-//ietf//dtd html 3.2//',
329        '-//ietf//dtd html 3//',
330        '-//ietf//dtd html level 0//',
331        '-//ietf//dtd html level 1//',
332        '-//ietf//dtd html level 2//',
333        '-//ietf//dtd html level 3//',
334        '-//ietf//dtd html strict level 0//',
335        '-//ietf//dtd html strict level 1//',
336        '-//ietf//dtd html strict level 2//',
337        '-//ietf//dtd html strict level 3//',
338        '-//ietf//dtd html strict//',
339        '-//ietf//dtd html//',
340        '-//metrius//dtd metrius presentational//',
341        '-//microsoft//dtd internet explorer 2.0 html strict//',
342        '-//microsoft//dtd internet explorer 2.0 html//',
343        '-//microsoft//dtd internet explorer 2.0 tables//',
344        '-//microsoft//dtd internet explorer 3.0 html strict//',
345        '-//microsoft//dtd internet explorer 3.0 html//',
346        '-//microsoft//dtd internet explorer 3.0 tables//',
347        '-//netscape comm. corp.//dtd html//',
348        '-//netscape comm. corp.//dtd strict html//',
349        '-//o\'reilly and associates//dtd html 2.0//',
350        '-//o\'reilly and associates//dtd html extended 1.0//',
351        '-//o\'reilly and associates//dtd html extended relaxed 1.0//',
352        '-//sq//dtd html 2.0 hotmetal + extensions//',
353        '-//softquad software//dtd hotmetal pro 6.0::19990601::extensions to html 4.0//',
354        '-//softquad//dtd hotmetal pro 4.0::19971010::extensions to html 4.0//',
355        '-//spyglass//dtd html 2.0 extended//',
356        '-//sun microsystems corp.//dtd hotjava html//',
357        '-//sun microsystems corp.//dtd hotjava strict html//',
358        '-//w3c//dtd html 3 1995-03-24//',
359        '-//w3c//dtd html 3.2 draft//',
360        '-//w3c//dtd html 3.2 final//',
361        '-//w3c//dtd html 3.2//',
362        '-//w3c//dtd html 3.2s draft//',
363        '-//w3c//dtd html 4.0 frameset//',
364        '-//w3c//dtd html 4.0 transitional//',
365        '-//w3c//dtd html experimental 19960712//',
366        '-//w3c//dtd html experimental 970421//',
367        '-//w3c//dtd w3 html//',
368        '-//w3o//dtd w3 html 3.0//',
369        '-//webtechs//dtd mozilla html 2.0//',
370        '-//webtechs//dtd mozilla html//',
371    ];
372
373    /** @var list<string> exact-match (case-insensitive) public IDs that force quirks. */
374    private const array QUIRKS_PUBLIC_ID_EXACT = [
375        '-//w3o//dtd w3 html strict 3.0//en//',
376        '-/w3c/dtd html 4.0 transitional/en',
377        'html',
378    ];
379
380    /**
381     * Public-ID prefixes whose presence puts the document into limited-
382     * quirks mode (or, if a system ID is set, into full quirks for the
383     * HTML 4.01 variants). Lowercase, compared via `str_starts_with`.
384     *
385     * @var list<string>
386     */
387    private const array LIMITED_QUIRKS_PUBLIC_ID_PREFIXES = [
388        '-//w3c//dtd xhtml 1.0 frameset//',
389        '-//w3c//dtd xhtml 1.0 transitional//',
390    ];
391
392    /** @var list<string> HTML 4.01 variants â€” limited-quirks when systemId missing, quirks when present. */
393    private const array CONDITIONAL_QUIRKS_PUBLIC_ID_PREFIXES = [
394        '-//w3c//dtd html 4.01 frameset//',
395        '-//w3c//dtd html 4.01 transitional//',
396    ];
397
398    private function resolveDocumentMode(DoctypeToken $token): DocumentMode
399    {
400        if ($token->forceQuirks) {
401            return DocumentMode::Quirks;
402        }
403        $name = $token->name ?? '';
404        if ($name !== 'html') {
405            return DocumentMode::Quirks;
406        }
407        $publicId = $token->publicId;
408        $systemId = $token->systemId;
409        $publicLower = $publicId !== null ? strtolower($publicId) : null;
410        $systemLower = $systemId !== null ? strtolower($systemId) : null;
411
412        // System-ID exact match forces quirks (WHATWG Â§13.2.6.2).
413        if ($systemLower === 'http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd') {
414            return DocumentMode::Quirks;
415        }
416
417        if ($publicLower !== null) {
418            if (in_array($publicLower, self::QUIRKS_PUBLIC_ID_EXACT, true)) {
419                return DocumentMode::Quirks;
420            }
421            foreach (self::QUIRKS_PUBLIC_ID_PREFIXES as $prefix) {
422                if (str_starts_with($publicLower, $prefix)) {
423                    return DocumentMode::Quirks;
424                }
425            }
426            // HTML 4.01 frameset/transitional: quirks if systemId present,
427            // limited-quirks if missing.
428            foreach (self::CONDITIONAL_QUIRKS_PUBLIC_ID_PREFIXES as $prefix) {
429                if (str_starts_with($publicLower, $prefix)) {
430                    return $systemId === null ? DocumentMode::Quirks : DocumentMode::LimitedQuirks;
431                }
432            }
433            foreach (self::LIMITED_QUIRKS_PUBLIC_ID_PREFIXES as $prefix) {
434                if (str_starts_with($publicLower, $prefix)) {
435                    return DocumentMode::LimitedQuirks;
436                }
437            }
438        }
439
440        if ($publicId === null && ($systemId === null || strcasecmp($systemId, 'about:legacy-compat') === 0)) {
441            return DocumentMode::NoQuirks;
442        }
443
444        return DocumentMode::NoQuirks;
445    }
446
447    // ============================================================
448    // BeforeHtml
449    // ============================================================
450    private function modeBeforeHtml(Token $token): void
451    {
452        if ($token instanceof DoctypeToken) {
453            return; // ignore
454        }
455        if ($token instanceof CommentToken) {
456            $this->document->appendChild($this->document->createComment($token->data));
457            return;
458        }
459        if ($this->isWhitespaceOnlyCharacter($token)) {
460            return;
461        }
462        if ($token instanceof StartTagToken && $token->tagName === 'html') {
463            $html = $this->createElementForToken($token);
464            $this->document->appendChild($html);
465            $this->openElements->push($html);
466            $this->insertionMode = InsertionMode::BeforeHead;
467            return;
468        }
469        // Anything else: create implicit <html>, then reprocess.
470        $html = $this->document->createElement('html');
471        $this->document->appendChild($html);
472        $this->openElements->push($html);
473        $this->insertionMode = InsertionMode::BeforeHead;
474        $this->reprocess($token);
475    }
476
477    // ============================================================
478    // BeforeHead
479    // ============================================================
480    private function modeBeforeHead(Token $token): void
481    {
482        if ($this->isWhitespaceOnlyCharacter($token)) {
483            return;
484        }
485        if ($token instanceof CommentToken) {
486            $this->insertComment($token);
487            return;
488        }
489        if ($token instanceof DoctypeToken) {
490            return; // ignore
491        }
492        if ($token instanceof StartTagToken && $token->tagName === 'html') {
493            $this->processInBodyForStrayHtml($token);
494            return;
495        }
496        if ($token instanceof StartTagToken && $token->tagName === 'head') {
497            $this->headElement = $this->insertHtmlElement($token);
498            $this->insertionMode = InsertionMode::InHead;
499            return;
500        }
501        if ($token instanceof EndTagToken && in_array($token->tagName, ['head', 'body', 'html', 'br'], true)) {
502            // Treat as anything-else (insert implicit head).
503            $this->insertImplicitHeadAndReprocess($token);
504            return;
505        }
506        if ($token instanceof EndTagToken) {
507            return; // parse error, ignore
508        }
509        $this->insertImplicitHeadAndReprocess($token);
510    }
511
512    private function insertImplicitHeadAndReprocess(Token $token): void
513    {
514        $head = $this->document->createElement('head');
515        $current = $this->openElements->currentNode();
516        ($current ?? $this->document)->appendChild($head);
517        $this->openElements->push($head);
518        $this->headElement = $head;
519        $this->insertionMode = InsertionMode::InHead;
520        $this->reprocess($token);
521    }
522
523    // ============================================================
524    // InHead
525    // ============================================================
526    private function modeInHead(Token $token, Tokenizer $tokenizer): void
527    {
528        if ($this->isWhitespaceOnlyCharacter($token)) {
529            $this->insertCharacter($token);
530            return;
531        }
532        if ($token instanceof CommentToken) {
533            $this->insertComment($token);
534            return;
535        }
536        if ($token instanceof DoctypeToken) {
537            return;
538        }
539        if ($token instanceof StartTagToken) {
540            if ($token->tagName === 'html') {
541                $this->processInBodyForStrayHtml($token);
542                return;
543            }
544            if (in_array($token->tagName, ['base', 'basefont', 'bgsound', 'link', 'meta'], true)) {
545                $el = $this->insertHtmlElement($token);
546                $this->openElements->pop();
547                if ($token->selfClosing) {
548                    // acknowledged
549                }
550                return;
551            }
552            if ($token->tagName === 'title') {
553                $this->insertHtmlElement($token);
554                $tokenizer->state = TokenizerState::Rcdata;
555                $this->originalInsertionMode = $this->insertionMode;
556                $this->insertionMode = InsertionMode::Text;
557                return;
558            }
559            if (in_array($token->tagName, ['style', 'noframes'], true)) {
560                $this->insertHtmlElement($token);
561                $tokenizer->state = TokenizerState::Rawtext;
562                $this->originalInsertionMode = $this->insertionMode;
563                $this->insertionMode = InsertionMode::Text;
564                return;
565            }
566            if ($token->tagName === 'script') {
567                $this->insertHtmlElement($token);
568                $tokenizer->state = TokenizerState::ScriptData;
569                $this->originalInsertionMode = $this->insertionMode;
570                $this->insertionMode = InsertionMode::Text;
571                return;
572            }
573            if ($token->tagName === 'noscript') {
574                if ($this->options->scriptingEnabled) {
575                    // Scripting enabled â†’ noscript content opaque (RAWTEXT + Text mode).
576                    $this->insertHtmlElement($token);
577                    $tokenizer->state = TokenizerState::Rawtext;
578                    $this->originalInsertionMode = $this->insertionMode;
579                    $this->insertionMode = InsertionMode::Text;
580                    return;
581                }
582                // Scripting disabled (default) â†’ InHeadNoscript mode gates which
583                // elements can appear inside (only head-like flow content).
584                $this->insertHtmlElement($token);
585                $this->insertionMode = InsertionMode::InHeadNoscript;
586                return;
587            }
588            if ($token->tagName === 'template') {
589                // Per spec: the intended parent is the current node at the
590                // time of the token, BEFORE the template is inserted onto
591                // the stack.
592                $intendedParent = $this->openElements->currentNode();
593
594                $template = $this->insertHtmlElement($token);
595                assert($template instanceof HTMLTemplateElement);
596
597                // DSD path: shadowrootmode attribute present + parent eligible
598                // + parent doesn't already have a shadow root.
599                $shadowMode = $this->resolveShadowRootMode($token);
600                if ($shadowMode !== null
601                    && $intendedParent instanceof Element
602                    && $intendedParent->shadowRoot === null
603                    && $intendedParent->isShadowHostEligible()
604                ) {
605                    $init = new ShadowRootInit(
606                        delegatesFocus: $this->tokenHasAttribute($token, 'shadowrootdelegatesfocus'),
607                        clonable: $this->tokenHasAttribute($token, 'shadowrootclonable'),
608                        serializable: $this->tokenHasAttribute($token, 'shadowrootserializable'),
609                    );
610                    try {
611                        $shadowRoot = $intendedParent->attachShadow($shadowMode, $init);
612                        $template->content = $shadowRoot;
613                        $template->isDeclarativeShadowRoot = true;
614                    } catch (\LogicException) {
615                        // Eligibility check passed but attach failed (race condition
616                        // with another DSD template, etc.) â€” fall back to normal.
617                        $template->content = $this->document->createDocumentFragment();
618                    }
619                } else {
620                    $template->content = $this->document->createDocumentFragment();
621                }
622
623                $this->activeFormatting->pushMarker();
624                $this->framesetOk = false;
625                $this->insertionMode = InsertionMode::InTemplate;
626                $this->templateInsertionModes[] = InsertionMode::InTemplate;
627                return;
628            }
629            if ($token->tagName === 'head') {
630                return; // parse error, ignore
631            }
632            // Anything else: pop <head>, switch to AfterHead, reprocess.
633            $this->openElements->pop();
634            $this->insertionMode = InsertionMode::AfterHead;
635            $this->reprocess($token);
636            return;
637        }
638        if ($token instanceof EndTagToken) {
639            if ($token->tagName === 'head') {
640                $this->openElements->pop();
641                $this->insertionMode = InsertionMode::AfterHead;
642                return;
643            }
644            if (in_array($token->tagName, ['body', 'html', 'br'], true)) {
645                $this->openElements->pop();
646                $this->insertionMode = InsertionMode::AfterHead;
647                $this->reprocess($token);
648                return;
649            }
650            if ($token->tagName === 'template') {
651                if (!$this->openElements->containsLocalName('template')) {
652                    return; // parse error, ignore
653                }
654                // Capture the topmost template to check the DSD flag after popping.
655                $template = null;
656                $items = $this->openElements->items();
657                for ($i = count($items) - 1; $i >= 0; $i--) {
658                    $el = $items[$i];
659                    if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
660                        $template = $el;
661                        break;
662                    }
663                }
664                $this->openElements->generateImpliedEndTagsThoroughly();
665                $this->openElements->popUntilLocalName('template');
666                $this->activeFormatting->clearToLastMarker();
667                array_pop($this->templateInsertionModes);
668                $this->resetInsertionModeAppropriately();
669
670                // Phase 1B.4: DSD templates are consumed during parse â€” remove
671                // the template element from the light DOM. The shadow root on
672                // the parent is the surviving artefact.
673                if ($template instanceof HTMLTemplateElement
674                    && $template->isDeclarativeShadowRoot
675                    && $template->parentNode !== null
676                ) {
677                    $template->parentNode->removeChild($template);
678                }
679                return;
680            }
681            return; // parse error, ignore other end tags
682        }
683        if ($token instanceof EofToken) {
684            $this->openElements->pop();
685            $this->insertionMode = InsertionMode::AfterHead;
686            $this->reprocess($token);
687            return;
688        }
689        // Character (non-whitespace): pop head, switch, reprocess.
690        $this->openElements->pop();
691        $this->insertionMode = InsertionMode::AfterHead;
692        $this->reprocess($token);
693    }
694
695    // ============================================================
696    // AfterHead
697    // ============================================================
698    private function modeAfterHead(Token $token): void
699    {
700        if ($this->isWhitespaceOnlyCharacter($token)) {
701            $this->insertCharacter($token);
702            return;
703        }
704        if ($token instanceof CommentToken) {
705            $this->insertComment($token);
706            return;
707        }
708        if ($token instanceof DoctypeToken) {
709            return;
710        }
711        if ($token instanceof StartTagToken) {
712            if ($token->tagName === 'html') {
713                $this->processInBodyForStrayHtml($token);
714                return;
715            }
716            if ($token->tagName === 'body') {
717                $this->insertHtmlElement($token);
718                $this->framesetOk = false;
719                $this->insertionMode = InsertionMode::InBody;
720                return;
721            }
722            if ($token->tagName === 'frameset') {
723                $this->insertHtmlElement($token);
724                $this->insertionMode = InsertionMode::InFrameset;
725                return;
726            }
727            if (in_array($token->tagName, [
728                'base', 'basefont', 'bgsound', 'link', 'meta', 'noframes',
729                'script', 'style', 'template', 'title',
730            ], true)) {
731                // WHATWG Â§13.2.6.4.6 â€” parse error. Push the head
732                // pointer back onto the stack, process the token in
733                // InHead, then pop the head pointer off (it may not
734                // be the current node by now if InHead pushed
735                // something on top). Crucially, we don't snap the
736                // insertion mode back to AfterHead afterwards â€”
737                // InHead may have switched to Text mode (for
738                // `<script>`/`<style>`/`<title>` RCDATA/RAWTEXT)
739                // and forcing AfterHead here would skip the
740                // matching end-tag handling for that content.
741                if ($this->headElement !== null) {
742                    $this->openElements->push($this->headElement);
743                }
744                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
745                if ($this->headElement !== null) {
746                    $this->openElements->remove($this->headElement);
747                }
748                return;
749            }
750            if ($token->tagName === 'head') {
751                return; // parse error, ignore
752            }
753            // Anything else: implicit <body>, switch, reprocess.
754            $this->insertImplicitBody();
755            $this->insertionMode = InsertionMode::InBody;
756            $this->reprocess($token);
757            return;
758        }
759        if ($token instanceof EndTagToken) {
760            if (in_array($token->tagName, ['body', 'html', 'br'], true)) {
761                $body = $this->document->createElement('body');
762                $current = $this->openElements->currentNode();
763                ($current ?? $this->document)->appendChild($body);
764                $this->openElements->push($body);
765                $this->insertionMode = InsertionMode::InBody;
766                $this->reprocess($token);
767                return;
768            }
769            return; // parse error, ignore
770        }
771        // Anything else (non-whitespace character, EOF, etc.): insert implicit
772        // <body>, switch to InBody, reprocess. Per WHATWG Â§13.2.6.4.7.
773        $this->insertImplicitBody();
774        $this->insertionMode = InsertionMode::InBody;
775        $this->reprocess($token);
776    }
777
778    // ============================================================
779    // InBody (foundation subset)
780    // ============================================================
781    private function modeInBody(Token $token, Tokenizer $tokenizer): void
782    {
783        if ($token instanceof CharacterToken) {
784            // WHATWG Â§13.2.6.4.7 â€” U+0000 NULL is a parse error and
785            // is dropped (NOT replaced with U+FFFD; that replacement
786            // is for foreign content only). Dropping here keeps the
787            // frameset-ok flag truthful and matches the spec's "ignore
788            // the token" rule. Without this, a leading NULL would
789            // pollute the body text node and flip frameset-ok off
790            // even though no real content was inserted.
791            if ($token->data === "\u{0000}") {
792                return;
793            }
794            $this->reconstructActiveFormatting();
795            $this->insertCharacter($token);
796            if (!$this->isWhitespaceOnlyCharacter($token)) {
797                $this->framesetOk = false;
798            }
799            return;
800        }
801        if ($token instanceof CommentToken) {
802            $this->insertComment($token);
803            return;
804        }
805        if ($token instanceof DoctypeToken) {
806            return;
807        }
808        if ($token instanceof StartTagToken) {
809            $this->modeInBodyStartTag($token, $tokenizer);
810            return;
811        }
812        if ($token instanceof EndTagToken) {
813            $this->modeInBodyEndTag($token);
814            return;
815        }
816        if ($token instanceof EofToken) {
817            // WHATWG Â§13.2.6.4.7 â€” if any template insertion modes
818            // are still active, EOF redirects to InTemplate (which
819            // unwinds templates, resets the insertion mode, and
820            // reprocesses). Without this hop, a document that ends
821            // mid-template like `<template><div>` stops here in
822            // InBody without ever inserting the implicit `<body>`.
823            if ($this->templateInsertionModes !== []) {
824                $this->modeInTemplate($token, $tokenizer);
825                return;
826            }
827            $this->done = true;
828        }
829    }
830
831    private function modeInBodyStartTag(StartTagToken $token, Tokenizer $tokenizer): void
832    {
833        $tag = $token->tagName;
834
835        if ($tag === 'html') {
836            $this->processInBodyForStrayHtml($token);
837            return;
838        }
839
840        // Head-like elements inside body â€” reprocess in InHead.
841        if (in_array($tag, ['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'script', 'style', 'template', 'title'], true)) {
842            $this->modeInHead($token, $tokenizer);
843            return;
844        }
845
846        // WHATWG Â§13.2.6.4.7 â€” `<noscript>` in InBody when scripting
847        // is enabled: insert + flip the tokenizer to RAWTEXT + push
848        // Text mode (so the noscript's children come through as one
849        // big character token instead of being parsed as HTML).
850        // When scripting is disabled, the spec says "treat the token
851        // as a regular element" â€” fall through to the formatting /
852        // any-other handlers and let the noscript nest its children
853        // as normal HTML.
854        if ($tag === 'noscript' && $this->options->scriptingEnabled) {
855            $this->insertHtmlElement($token);
856            $tokenizer->state = TokenizerState::Rawtext;
857            $this->originalInsertionMode = $this->insertionMode;
858            $this->insertionMode = InsertionMode::Text;
859            return;
860        }
861
862        if ($tag === 'body') {
863            // WHATWG Â§13.2.6.4.7 â€” parse error. If the second
864            // element on the stack isn't a body, or the stack has
865            // only the html root, or a `<template>` is on the
866            // stack, ignore the token. Otherwise flip frameset-ok
867            // off and merge any not-already-present attributes onto
868            // the existing body. The attribute merge is how
869            // `<body xlink:href=foo>` later in the document still
870            // contributes the namespaced attribute even though body
871            // was implicitly opened earlier.
872            $items = $this->openElements->items();
873            $bodyAtIndexOne = isset($items[1])
874                && $items[1]->localName === 'body'
875                && $items[1]->namespaceURI === Document::HTML_NS;
876            if (!$bodyAtIndexOne || count($items) < 2) {
877                return;
878            }
879            foreach ($items as $el) {
880                if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
881                    return;
882                }
883            }
884            $this->framesetOk = false;
885            $body = $items[1];
886            foreach ($token->attributes as $attr) {
887                if (!$body->hasAttribute($attr['name'])) {
888                    $body->setAttribute($attr['name'], $attr['value']);
889                }
890            }
891            return;
892        }
893        if ($tag === 'frameset') {
894            // Per spec: parse error unless framesetOk is true; the existing
895            // body must be replaceable. If conditions aren't met, ignore.
896            $items = $this->openElements->items();
897            $bodyAtIndexOne = isset($items[1])
898                && $items[1]->localName === 'body'
899                && $items[1]->namespaceURI === Document::HTML_NS;
900            if (!$bodyAtIndexOne || count($items) < 2 || !$this->framesetOk) {
901                return; // parse error, ignore
902            }
903            // Remove the existing body from its parent and from the stack.
904            $body = $items[1];
905            $body->parentNode?->removeChild($body);
906            while ($this->openElements->count() > 1) {
907                $this->openElements->pop();
908            }
909            $this->insertHtmlElement($token);
910            $this->insertionMode = InsertionMode::InFrameset;
911            return;
912        }
913
914        // WHATWG Â§13.2.6.4.7 â€” start tags that are only valid
915        // inside specific table / colgroup / head contexts are
916        // a parse error and dropped silently when seen in InBody.
917        // Without this, a stray `<col>` or `<tr>` outside its
918        // proper container would be inserted as a body-level
919        // element. (head is here because `<head>` is only ever
920        // valid as the very first element, and a stray reappearance
921        // mid-document is the spec's parse-error case.)
922        if (in_array($tag, [
923            'caption', 'col', 'colgroup', 'frame', 'head',
924            'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
925        ], true)) {
926            return;
927        }
928
929        // Block-level elements that close a currently open <p>.
930        $closesParagraph = [
931            'address', 'article', 'aside', 'blockquote', 'center', 'details', 'dialog',
932            'dir', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'header',
933            'hgroup', 'main', 'menu', 'nav', 'ol', 'p', 'search', 'section', 'summary', 'ul',
934        ];
935        if (in_array($tag, $closesParagraph, true)) {
936            if ($this->openElements->hasInButtonScope('p')) {
937                $this->closePElement();
938            }
939            $this->insertHtmlElement($token);
940            return;
941        }
942
943        // Headings: like the closes-paragraph set, plus pop any open heading.
944        if (in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
945            if ($this->openElements->hasInButtonScope('p')) {
946                $this->closePElement();
947            }
948            $current = $this->openElements->currentNode();
949            if ($current !== null && in_array($current->localName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
950                $this->openElements->pop();
951            }
952            $this->insertHtmlElement($token);
953            return;
954        }
955
956        if ($tag === 'pre' || $tag === 'listing') {
957            if ($this->openElements->hasInButtonScope('p')) {
958                $this->closePElement();
959            }
960            $this->insertHtmlElement($token);
961            $this->framesetOk = false;
962            // Spec: ignore a single leading LF in the next character
963            // token â€” authoring convenience for `<pre>\n…</pre>`.
964            $this->skipLeadingNewlineOnNextChar = true;
965            return;
966        }
967
968        if ($tag === 'form') {
969            // WHATWG Â§13.2.6.4.7 â€” if there is a form pointer set,
970            // ignore the token (unless a `<template>` is on the
971            // open-elements stack, in which case the form pointer
972            // is scoped to the template's content fragment and the
973            // outer document can still nest another form).
974            $hasTemplateOnStack = false;
975            foreach ($this->openElements->items() as $el) {
976                if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
977                    $hasTemplateOnStack = true;
978                    break;
979                }
980            }
981            if ($this->formElement !== null && !$hasTemplateOnStack) {
982                return; // parse error â€” second <form> at top level is ignored
983            }
984            if ($this->openElements->hasInButtonScope('p')) {
985                $this->closePElement();
986            }
987            $newForm = $this->insertHtmlElement($token);
988            if (!$hasTemplateOnStack) {
989                $this->formElement = $newForm;
990            }
991            return;
992        }
993
994        // WHATWG Â§13.2.6.4.7 â€” A start tag whose tag name is
995        // "button". If `<button>` is already in scope, the spec
996        // closes it (generate implied end tags, pop until popped)
997        // BEFORE inserting the new one. Without this, repeated
998        // `<button>` tags would nest, since `<button>` isn't on
999        // the special-element list that's auto-closed elsewhere.
1000        if ($tag === 'button') {
1001            if ($this->openElements->hasInScope('button')) {
1002                $this->openElements->generateImpliedEndTags();
1003                $this->openElements->popUntilLocalName('button');
1004            }
1005            $this->reconstructActiveFormatting();
1006            $this->insertHtmlElement($token);
1007            $this->framesetOk = false;
1008            return;
1009        }
1010
1011        // WHATWG Â§13.2.6.4.7 â€” `<option>` and `<optgroup>` in
1012        // InBody auto-close a preceding `<option>` (and `<optgroup>`
1013        // closes a preceding `<optgroup>` too). This is the InBody
1014        // mode flavour; InSelect has its own option/optgroup
1015        // handlers and is unaffected.
1016        if ($tag === 'optgroup') {
1017            $current = $this->openElements->currentNode();
1018            if ($current !== null && $current->localName === 'option'
1019                && $current->namespaceURI === Document::HTML_NS) {
1020                $this->openElements->pop();
1021            }
1022            $current = $this->openElements->currentNode();
1023            if ($current !== null && $current->localName === 'optgroup'
1024                && $current->namespaceURI === Document::HTML_NS) {
1025                $this->openElements->pop();
1026            }
1027            $this->reconstructActiveFormatting();
1028            $this->insertHtmlElement($token);
1029            return;
1030        }
1031        if ($tag === 'option') {
1032            $current = $this->openElements->currentNode();
1033            if ($current !== null && $current->localName === 'option'
1034                && $current->namespaceURI === Document::HTML_NS) {
1035                $this->openElements->pop();
1036            }
1037            $this->reconstructActiveFormatting();
1038            $this->insertHtmlElement($token);
1039            return;
1040        }
1041
1042        // WHATWG Â§13.2.6.4.7 â€” `<applet>`, `<marquee>`, `<object>`
1043        // are "scope-defining" formatting-ish elements: reconstruct
1044        // AFE, insert, push an AFE marker, and clear frameset-ok.
1045        // The AFE marker is what makes nested formatting elements
1046        // inside `<object>` etc. not leak out across the boundary
1047        // when the adoption agency runs.
1048        if (in_array($tag, ['applet', 'marquee', 'object'], true)) {
1049            $this->reconstructActiveFormatting();
1050            $this->insertHtmlElement($token);
1051            $this->activeFormatting->pushMarker();
1052            $this->framesetOk = false;
1053            return;
1054        }
1055
1056        if ($tag === 'li') {
1057            $this->framesetOk = false;
1058            for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1059                $node = $this->openElements->items()[$i];
1060                if ($node->localName === 'li') {
1061                    $this->openElements->generateImpliedEndTags('li');
1062                    $this->openElements->popUntilLocalName('li');
1063                    break;
1064                }
1065                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
1066                    && !in_array($node->localName, ['address', 'div', 'p'], true)) {
1067                    break;
1068                }
1069            }
1070            if ($this->openElements->hasInButtonScope('p')) {
1071                $this->closePElement();
1072            }
1073            $this->insertHtmlElement($token);
1074            return;
1075        }
1076
1077        if (in_array($tag, ['dd', 'dt'], true)) {
1078            $this->framesetOk = false;
1079            for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1080                $node = $this->openElements->items()[$i];
1081                if (in_array($node->localName, ['dd', 'dt'], true)) {
1082                    $this->openElements->generateImpliedEndTags($node->localName);
1083                    $this->openElements->popUntilLocalName($node->localName);
1084                    break;
1085                }
1086                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
1087                    && !in_array($node->localName, ['address', 'div', 'p'], true)) {
1088                    break;
1089                }
1090            }
1091            if ($this->openElements->hasInButtonScope('p')) {
1092                $this->closePElement();
1093            }
1094            $this->insertHtmlElement($token);
1095            return;
1096        }
1097
1098        // <a> has its own clause: if AFE already has an <a>, run AAA first.
1099        if ($tag === 'a') {
1100            $existing = $this->activeFormatting->findLastBetweenMarkerAnd('a');
1101            if ($existing !== null) {
1102                $this->adoptionAgency('a');
1103                $this->activeFormatting->remove($existing);
1104                $this->openElements->remove($existing);
1105            }
1106            $this->reconstructActiveFormatting();
1107            $el = $this->insertHtmlElement($token);
1108            $this->activeFormatting->push($el);
1109            return;
1110        }
1111
1112        // <nobr> has special handling: if a nobr is in scope, run AAA first.
1113        if ($tag === 'nobr') {
1114            $this->reconstructActiveFormatting();
1115            if ($this->openElements->hasInScope('nobr')) {
1116                $this->adoptionAgency('nobr');
1117                $this->reconstructActiveFormatting();
1118            }
1119            $el = $this->insertHtmlElement($token);
1120            $this->activeFormatting->push($el);
1121            return;
1122        }
1123
1124        // Other formatting elements â€” push onto AFE list after reconstruction.
1125        $formatting = ['b', 'big', 'code', 'em', 'font', 'i', 's', 'small', 'strike', 'strong', 'tt', 'u'];
1126        if (in_array($tag, $formatting, true)) {
1127            $this->reconstructActiveFormatting();
1128            $el = $this->insertHtmlElement($token);
1129            $this->activeFormatting->push($el);
1130            return;
1131        }
1132
1133        // WHATWG Â§13.2.6.4.7 â€” "A start tag whose tag name is
1134        // 'image' â€” Parse error. Change the token's tag name to
1135        // 'img' and reprocess it." The legacy alias predates the
1136        // `<img>` standardisation and is the only such rewrite in
1137        // tree construction (the spec's parenthetical is literally
1138        // "Don't ask.").
1139        if ($tag === 'image') {
1140            $token->tagName = 'img';
1141            $tag = 'img';
1142        }
1143
1144        // Void / self-closing-ish elements that clear framesetOk.
1145        if (in_array($tag, ['area', 'br', 'embed', 'img', 'keygen', 'wbr'], true)) {
1146            $this->reconstructActiveFormatting();
1147            $this->insertHtmlElement($token);
1148            $this->openElements->pop();
1149            $this->framesetOk = false;
1150            return;
1151        }
1152        // `source`, `track`, `param` are HTML 5 void elements that the
1153        // spec inserts + pops in InBody but doesn't clear framesetOk for
1154        // (see WHATWG Â§13.2.6.4.7 "A start tag whose tag name is one of:
1155        // param, source, track"). Practical reason for treating them as
1156        // void here: without it `<picture><source ...><img></picture>`
1157        // ends up nesting the `<img>` inside the `<source>`.
1158        if (in_array($tag, ['source', 'track', 'param'], true)) {
1159            $this->insertHtmlElement($token);
1160            $this->openElements->pop();
1161            return;
1162        }
1163        if ($tag === 'hr') {
1164            if ($this->openElements->hasInButtonScope('p')) {
1165                $this->closePElement();
1166            }
1167            $this->insertHtmlElement($token);
1168            $this->openElements->pop();
1169            $this->framesetOk = false;
1170            return;
1171        }
1172        if ($tag === 'input') {
1173            $this->reconstructActiveFormatting();
1174            $this->insertHtmlElement($token);
1175            $this->openElements->pop();
1176            // Only "type=hidden" preserves frameset-ok; anything else flips it.
1177            $hasHiddenType = false;
1178            foreach ($token->attributes as $attr) {
1179                if ($attr['name'] === 'type' && strcasecmp($attr['value'], 'hidden') === 0) {
1180                    $hasHiddenType = true;
1181                    break;
1182                }
1183            }
1184            if (!$hasHiddenType) {
1185                $this->framesetOk = false;
1186            }
1187            return;
1188        }
1189
1190        if ($tag === 'table') {
1191            if ($this->document->mode !== DocumentMode::Quirks
1192                && $this->openElements->hasInButtonScope('p')) {
1193                $this->closePElement();
1194            }
1195            $this->insertHtmlElement($token);
1196            $this->framesetOk = false;
1197            $this->insertionMode = InsertionMode::InTable;
1198            return;
1199        }
1200
1201        if ($tag === 'select') {
1202            $this->reconstructActiveFormatting();
1203            $this->insertHtmlElement($token);
1204            $this->framesetOk = false;
1205            // If we're already inside a table-related mode, enter InSelectInTable.
1206            $previousMode = $this->insertionMode;
1207            $this->insertionMode = in_array($previousMode, [
1208                InsertionMode::InTable, InsertionMode::InCaption, InsertionMode::InTableBody,
1209                InsertionMode::InRow, InsertionMode::InCell,
1210            ], true) ? InsertionMode::InSelectInTable : InsertionMode::InSelect;
1211            return;
1212        }
1213
1214        if ($tag === 'textarea') {
1215            $this->insertHtmlElement($token);
1216            // Spec: ignore a single leading LF in the next character
1217            // token â€” same authoring convenience as `<pre>`.
1218            $this->skipLeadingNewlineOnNextChar = true;
1219            $tokenizer->state = TokenizerState::Rcdata;
1220            $this->originalInsertionMode = $this->insertionMode;
1221            $this->framesetOk = false;
1222            $this->insertionMode = InsertionMode::Text;
1223            return;
1224        }
1225
1226        if ($tag === 'xmp') {
1227            if ($this->openElements->hasInButtonScope('p')) {
1228                $this->closePElement();
1229            }
1230            $this->reconstructActiveFormatting();
1231            $this->framesetOk = false;
1232            $this->insertHtmlElement($token);
1233            $tokenizer->state = TokenizerState::Rawtext;
1234            $this->originalInsertionMode = $this->insertionMode;
1235            $this->insertionMode = InsertionMode::Text;
1236            return;
1237        }
1238
1239        if ($tag === 'svg') {
1240            $this->reconstructActiveFormatting();
1241            $this->insertForeignElement($token, Document::SVG_NS, self::SVG_TAG_CASE_CORRECTIONS);
1242            if ($token->selfClosing) {
1243                $this->openElements->pop();
1244            }
1245            return;
1246        }
1247        if ($tag === 'math') {
1248            $this->reconstructActiveFormatting();
1249            $this->insertForeignElement($token, Document::MATHML_NS, []);
1250            if ($token->selfClosing) {
1251                $this->openElements->pop();
1252            }
1253            return;
1254        }
1255
1256        if ($tag === 'iframe' || $tag === 'noembed') {
1257            $this->insertHtmlElement($token);
1258            $tokenizer->state = TokenizerState::Rawtext;
1259            $this->originalInsertionMode = $this->insertionMode;
1260            $this->framesetOk = false;
1261            $this->insertionMode = InsertionMode::Text;
1262            return;
1263        }
1264
1265        // HTML Living Standard Â§13.2.5.32 â€” `<plaintext>` start tag.
1266        // Closes a `<p>` in button scope, inserts the element, and
1267        // switches the tokenizer into PLAINTEXT state. Everything
1268        // after `<plaintext>` is character data until EOF â€” no
1269        // `</plaintext>` end tag exists.
1270        if ($tag === 'plaintext') {
1271            if ($this->openElements->hasInButtonScope('p')) {
1272                $this->closePElement();
1273            }
1274            $this->insertHtmlElement($token);
1275            $tokenizer->state = TokenizerState::Plaintext;
1276            return;
1277        }
1278
1279        // HTML Living Standard Â§13.2.6.4.7 â€” ruby base / container.
1280        // `<rb>` and `<rtc>` close any unclosed nested element down to
1281        // the open `<ruby>` if one is in scope.
1282        if ($tag === 'rb' || $tag === 'rtc') {
1283            if ($this->openElements->hasInScope('ruby')) {
1284                $this->openElements->generateImpliedEndTags();
1285                // Parse error if current node isn't `<ruby>`, but we
1286                // proceed (lenient mode) and insert anyway.
1287            }
1288            $this->insertHtmlElement($token);
1289            return;
1290        }
1291
1292        // `<rt>` and `<rp>` close any unclosed nested element down to
1293        // the open `<ruby>` or `<rtc>` if a `<ruby>` is in scope â€”
1294        // the `<rtc>` exception lets `rt` annotations live inside a
1295        // shared container.
1296        if ($tag === 'rt' || $tag === 'rp') {
1297            if ($this->openElements->hasInScope('ruby')) {
1298                $this->openElements->generateImpliedEndTags('rtc');
1299                // Parse error if current node isn't `<ruby>` or `<rtc>`
1300                // â€” proceed in lenient mode.
1301            }
1302            $this->insertHtmlElement($token);
1303            return;
1304        }
1305
1306        // Default: just insert (any other start tag).
1307        $this->reconstructActiveFormatting();
1308        $this->insertHtmlElement($token);
1309    }
1310
1311    private function modeInBodyEndTag(EndTagToken $token): void
1312    {
1313        $tag = $token->tagName;
1314
1315        if ($tag === 'template') {
1316            $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
1317            return;
1318        }
1319
1320        if ($tag === 'body') {
1321            if (!$this->openElements->hasInScope('body')) {
1322                return; // parse error
1323            }
1324            $this->insertionMode = InsertionMode::AfterBody;
1325            return;
1326        }
1327        if ($tag === 'html') {
1328            if (!$this->openElements->hasInScope('body')) {
1329                return;
1330            }
1331            $this->insertionMode = InsertionMode::AfterBody;
1332            $this->reprocess($token);
1333            return;
1334        }
1335
1336        $blockLike = [
1337            'address', 'article', 'aside', 'blockquote', 'button', 'center',
1338            'details', 'dialog', 'dir', 'div', 'dl', 'fieldset', 'figcaption',
1339            'figure', 'footer', 'header', 'hgroup', 'listing', 'main', 'menu',
1340            'nav', 'ol', 'pre', 'search', 'section', 'summary', 'ul',
1341        ];
1342        if (in_array($tag, $blockLike, true)) {
1343            if (!$this->openElements->hasInScope($tag)) {
1344                return; // parse error
1345            }
1346            $this->openElements->generateImpliedEndTags();
1347            $this->openElements->popUntilLocalName($tag);
1348            return;
1349        }
1350
1351        if ($tag === 'form') {
1352            $node = $this->formElement;
1353            $this->formElement = null;
1354            if ($node === null || !$this->openElements->contains($node)) {
1355                return;
1356            }
1357            $this->openElements->generateImpliedEndTags();
1358            $this->openElements->remove($node);
1359            return;
1360        }
1361
1362        if ($tag === 'p') {
1363            if (!$this->openElements->hasInButtonScope('p')) {
1364                // Per spec: synthesise an empty `<p>` start tag and
1365                // reprocess it. Route through `insertHtmlElement`
1366                // (with a synthetic StartTagToken) so the insertion
1367                // honours `appropriatePlaceForInserting` â€” this is
1368                // what makes `<p><table></p>` foster-parent the
1369                // synthesised `<p>` next to (rather than inside)
1370                // the table. Previously this appended directly to
1371                // the current node, which buried the new `<p>`
1372                // inside the table.
1373                $synthetic = new StartTagToken();
1374                $synthetic->tagName = 'p';
1375                $this->insertHtmlElement($synthetic);
1376            }
1377            $this->closePElement();
1378            return;
1379        }
1380
1381        if ($tag === 'li') {
1382            if (!$this->openElements->hasInListItemScope('li')) {
1383                return;
1384            }
1385            $this->openElements->generateImpliedEndTags('li');
1386            $this->openElements->popUntilLocalName('li');
1387            return;
1388        }
1389
1390        if (in_array($tag, ['dd', 'dt'], true)) {
1391            if (!$this->openElements->hasInScope($tag)) {
1392                return;
1393            }
1394            $this->openElements->generateImpliedEndTags($tag);
1395            $this->openElements->popUntilLocalName($tag);
1396            return;
1397        }
1398
1399        if (in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true)) {
1400            $headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
1401            $hasAny = false;
1402            foreach ($headings as $h) {
1403                if ($this->openElements->hasInScope($h)) {
1404                    $hasAny = true;
1405                    break;
1406                }
1407            }
1408            if (!$hasAny) {
1409                return;
1410            }
1411            $this->openElements->generateImpliedEndTags();
1412            $this->openElements->popUntilLocalName(...$headings);
1413            return;
1414        }
1415
1416        // WHATWG Â§13.2.6.4.7 â€” `</applet>` / `</marquee>` /
1417        // `</object>` close back to the matching scoped element
1418        // and clear AFE up to the marker pushed when the start
1419        // tag was inserted.
1420        if (in_array($tag, ['applet', 'marquee', 'object'], true)) {
1421            if (!$this->openElements->hasInScope($tag)) {
1422                return;
1423            }
1424            $this->openElements->generateImpliedEndTags();
1425            $this->openElements->popUntilLocalName($tag);
1426            $this->activeFormatting->clearToLastMarker();
1427            return;
1428        }
1429
1430        // WHATWG Â§13.2.6.4.7 â€” end tag whose tag name is "br":
1431        // parse error. Drop the attributes from the token and act
1432        // as if a `<br>` start tag with no attributes had been
1433        // seen (which is the standard void-element insertion).
1434        // Without this, `</br foo=bar>` falls through to the
1435        // generic any-other-end-tag walk which never finds a
1436        // `<br>` on the stack (br is void) and the tag silently
1437        // vanishes from the tree.
1438        if ($tag === 'br') {
1439            $synthetic = new StartTagToken();
1440            $synthetic->tagName = 'br';
1441            $this->reconstructActiveFormatting();
1442            $this->insertHtmlElement($synthetic);
1443            $this->openElements->pop();
1444            $this->framesetOk = false;
1445            return;
1446        }
1447
1448        // Formatting elements â€” run the adoption agency algorithm
1449        // (WHATWG Â§13.2.6.4.7 "any other end tag" / formatting tags subset).
1450        $formatting = ['a', 'b', 'big', 'code', 'em', 'font', 'i', 'nobr', 's', 'small', 'strike', 'strong', 'tt', 'u'];
1451        if (in_array($tag, $formatting, true)) {
1452            $this->adoptionAgency($tag);
1453            return;
1454        }
1455
1456        // "Any other end tag" per spec â€” search up the open elements stack
1457        // for a matching element, generating implied end tags as we go.
1458        for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1459            $node = $this->openElements->items()[$i];
1460            if ($node->localName === $tag && $node->namespaceURI === Document::HTML_NS) {
1461                $this->openElements->generateImpliedEndTags($tag);
1462                $this->openElements->popUntilElement($node);
1463                return;
1464            }
1465            if (OpenElementsStack::isSpecialHtmlElement($node->localName)) {
1466                return; // parse error, ignore
1467            }
1468        }
1469    }
1470
1471    private function closePElement(): void
1472    {
1473        $this->openElements->generateImpliedEndTags('p');
1474        $this->openElements->popUntilLocalName('p');
1475    }
1476
1477    private function insertImplicitBody(): Element
1478    {
1479        $body = $this->document->createElement('body');
1480        $current = $this->openElements->currentNode();
1481        ($current ?? $this->document)->appendChild($body);
1482        $this->openElements->push($body);
1483        return $body;
1484    }
1485
1486    private function processInBodyForStrayHtml(StartTagToken $token): void
1487    {
1488        // WHATWG Â§13.2.6.4.7 â€” A start tag whose tag name is "html"
1489        // outside InBody's normal slot is a parse error. If there
1490        // is a `<template>` on the stack, ignore the token; else
1491        // copy any not-already-present attributes onto the root
1492        // html element (the bottom of the stack). This is how
1493        // `<html a=b>` later in the document still contributes
1494        // its attributes after the initial `<html>` was inserted.
1495        foreach ($this->openElements->items() as $el) {
1496            if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
1497                return;
1498            }
1499        }
1500        $root = $this->openElements->items()[0] ?? null;
1501        if ($root === null) {
1502            return;
1503        }
1504        foreach ($token->attributes as $attr) {
1505            if (!$root->hasAttribute($attr['name'])) {
1506                $root->setAttribute($attr['name'], $attr['value']);
1507            }
1508        }
1509    }
1510
1511    // ============================================================
1512    // Text mode (RCDATA / RAWTEXT / Script)
1513    // ============================================================
1514    private function modeText(Token $token): void
1515    {
1516        if ($token instanceof CharacterToken) {
1517            $this->insertCharacter($token);
1518            return;
1519        }
1520        if ($token instanceof EofToken) {
1521            $this->openElements->pop();
1522            $this->insertionMode = $this->originalInsertionMode;
1523            $this->reprocess($token);
1524            return;
1525        }
1526        if ($token instanceof EndTagToken) {
1527            // Pop and return to original insertion mode.
1528            $this->openElements->pop();
1529            $this->insertionMode = $this->originalInsertionMode;
1530            return;
1531        }
1532    }
1533
1534    // ============================================================
1535    // AfterBody / AfterAfterBody
1536    // ============================================================
1537    private function modeAfterBody(Token $token): void
1538    {
1539        if ($this->isWhitespaceOnlyCharacter($token)) {
1540            // Process as if InBody â€” text gets inserted into <body>.
1541            $this->insertCharacter($token);
1542            return;
1543        }
1544        if ($token instanceof CommentToken) {
1545            // Append to <html>.
1546            $items = $this->openElements->items();
1547            $html = $items[0] ?? null;
1548            ($html ?? $this->document)->appendChild($this->document->createComment($token->data));
1549            return;
1550        }
1551        if ($token instanceof DoctypeToken) {
1552            return;
1553        }
1554        if ($token instanceof StartTagToken && $token->tagName === 'html') {
1555            $this->processInBodyForStrayHtml($token);
1556            return;
1557        }
1558        if ($token instanceof EndTagToken && $token->tagName === 'html') {
1559            $this->insertionMode = InsertionMode::AfterAfterBody;
1560            return;
1561        }
1562        if ($token instanceof EofToken) {
1563            $this->done = true;
1564            return;
1565        }
1566        // Parse error: switch back to InBody and reprocess.
1567        $this->insertionMode = InsertionMode::InBody;
1568        $this->reprocess($token);
1569    }
1570
1571    private function modeAfterAfterBody(Token $token): void
1572    {
1573        if ($token instanceof CommentToken) {
1574            $this->document->appendChild($this->document->createComment($token->data));
1575            return;
1576        }
1577        if ($token instanceof DoctypeToken) {
1578            return;
1579        }
1580        if ($this->isWhitespaceOnlyCharacter($token)) {
1581            $this->insertCharacter($token);
1582            return;
1583        }
1584        if ($token instanceof StartTagToken && $token->tagName === 'html') {
1585            $this->processInBodyForStrayHtml($token);
1586            return;
1587        }
1588        if ($token instanceof EofToken) {
1589            $this->done = true;
1590            return;
1591        }
1592        $this->insertionMode = InsertionMode::InBody;
1593        $this->reprocess($token);
1594    }
1595
1596    // ============================================================
1597    // Insertion algorithms
1598    // ============================================================
1599    private function insertHtmlElement(StartTagToken $token): Element
1600    {
1601        $element = $this->createElementForToken($token);
1602        [$parent, $before] = $this->appropriatePlaceForInserting();
1603        if ($before !== null) {
1604            $parent->insertBefore($element, $before);
1605        } else {
1606            $parent->appendChild($element);
1607        }
1608        $this->openElements->push($element);
1609        return $element;
1610    }
1611
1612    /**
1613     * "Appropriate place for inserting a node" per WHATWG Â§13.2.6.1. Foster
1614     * parenting applies when the current node is a table/tbody/tfoot/thead/tr
1615     * AND we're in a table-related insertion mode; in that case content is
1616     * inserted *before* the table rather than inside its element children.
1617     *
1618     * @return array{0: Node, 1: ?Node} parent + optional reference sibling
1619     */
1620    private function appropriatePlaceForInserting(?Element $overrideTarget = null): array
1621    {
1622        $target = $overrideTarget ?? ($this->openElements->currentNode() ?? $this->document);
1623        // Template redirection: inserted children flow into the template's
1624        // content fragment (DocumentFragment for normal templates, ShadowRoot
1625        // for declarative shadow DOM) rather than into the template element
1626        // itself, per WHATWG Â§13.2.6.1 + DSD.
1627        if ($target instanceof HTMLTemplateElement && $target->content !== null) {
1628            return [$target->content, null];
1629        }
1630        if (!$this->fosterParenting) {
1631            return [$target, null];
1632        }
1633        if (!$target instanceof Element
1634            || !in_array($target->localName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true)
1635            || $target->namespaceURI !== Document::HTML_NS
1636        ) {
1637            return [$target, null];
1638        }
1639        // Foster-parent target: find last template AND last table on
1640        // the stack. If a template is more recent (deeper) than any
1641        // table, the foster-parent target is the template's content
1642        // fragment â€” that's how `<template><tr><div>` lands its `<div>`
1643        // as a sibling of `<tr>` inside the template's content rather
1644        // than nested inside the still-open `<tr>`. Otherwise insert
1645        // before the last table on the stack (normal foster parent).
1646        $items = $this->openElements->items();
1647        $lastTableIdx = null;
1648        $lastTemplateIdx = null;
1649        for ($i = array_key_last($items); $i !== null && $i >= 0; $i--) {
1650            $el = $items[$i];
1651            if ($el->namespaceURI !== Document::HTML_NS) {
1652                continue;
1653            }
1654            if ($lastTemplateIdx === null && $el->localName === 'template') {
1655                $lastTemplateIdx = $i;
1656            }
1657            if ($lastTableIdx === null && $el->localName === 'table') {
1658                $lastTableIdx = $i;
1659            }
1660            if ($lastTemplateIdx !== null && $lastTableIdx !== null) {
1661                break;
1662            }
1663        }
1664        if ($lastTemplateIdx !== null
1665            && ($lastTableIdx === null || $lastTemplateIdx > $lastTableIdx)
1666        ) {
1667            $template = $items[$lastTemplateIdx];
1668            if ($template instanceof HTMLTemplateElement && $template->content !== null) {
1669                return [$template->content, null];
1670            }
1671        }
1672        if ($lastTableIdx !== null) {
1673            $tableEl = $items[$lastTableIdx];
1674            $tableParent = $tableEl->parentNode;
1675            if ($tableParent !== null) {
1676                return [$tableParent, $tableEl];
1677            }
1678            if ($lastTableIdx > 0) {
1679                return [$items[$lastTableIdx - 1], null];
1680            }
1681        }
1682        return [$target, null];
1683    }
1684
1685    private function processAsInBodyWithFosterParenting(Token $token, Tokenizer $tokenizer): void
1686    {
1687        $previous = $this->fosterParenting;
1688        $this->fosterParenting = true;
1689        try {
1690            $this->modeInBody($token, $tokenizer);
1691        } finally {
1692            $this->fosterParenting = $previous;
1693        }
1694    }
1695
1696    private function createElementForToken(StartTagToken $token): Element
1697    {
1698        $element = $this->document->createElement($token->tagName);
1699        foreach ($token->attributes as $attr) {
1700            // First-attribute-wins per WHATWG; dedup already done by tokenizer.
1701            if (!$element->hasAttribute($attr['name'])) {
1702                $element->setAttribute($attr['name'], $attr['value']);
1703            }
1704        }
1705        return $element;
1706    }
1707
1708    private function insertCharacter(Token $token): void
1709    {
1710        if (!$token instanceof CharacterToken) {
1711            return;
1712        }
1713        $data = $token->data;
1714        // HTML 5 Â§13.2.6.4.7 â€” after a `<pre>` / `<listing>` /
1715        // `<textarea>` start tag, the parser ignores a single
1716        // leading U+000A LF in the next character token. Same
1717        // authoring convenience that lets `<pre>\n…</pre>` write
1718        // the opening LF immediately after the tag without it
1719        // showing up in the rendered text.
1720        if ($this->skipLeadingNewlineOnNextChar) {
1721            $this->skipLeadingNewlineOnNextChar = false;
1722            if (isset($data[0]) && $data[0] === "\n") {
1723                $data = substr($data, 1);
1724                if ($data === '') {
1725                    return;
1726                }
1727            }
1728        }
1729        [$parent, $before] = $this->appropriatePlaceForInserting();
1730        if ($before !== null) {
1731            // Foster-parented text: merge with the immediately-preceding text node if any.
1732            $prev = $before->previousSibling;
1733            if ($prev instanceof Text) {
1734                $prev->data .= $data;
1735                return;
1736            }
1737            $parent->insertBefore($this->document->createTextNode($data), $before);
1738            return;
1739        }
1740        $last = $parent->lastChild;
1741        if ($last instanceof Text) {
1742            $last->data .= $data;
1743            return;
1744        }
1745        $parent->appendChild($this->document->createTextNode($data));
1746    }
1747
1748    private function insertComment(CommentToken $token): void
1749    {
1750        // Route through `appropriatePlaceForInserting` so template
1751        // redirection (and foster parenting where it applies) wraps
1752        // the comment correctly. Previously this used the current
1753        // node directly, which meant comments inside `<template>`
1754        // landed as children of the template element rather than in
1755        // its content fragment.
1756        [$parent, $before] = $this->appropriatePlaceForInserting();
1757        $comment = $this->document->createComment($token->data);
1758        if ($before !== null) {
1759            $parent->insertBefore($comment, $before);
1760        } else {
1761            $parent->appendChild($comment);
1762        }
1763    }
1764
1765    /**
1766     * Adoption agency algorithm per WHATWG Â§13.2.6.4.7.
1767     *
1768     * The famous "Algorithm A" â€” recovers gracefully from misnested
1769     * formatting like `<b><i></b></i>`. Called from end tags for
1770     * `a`, `b`, `big`, `code`, `em`, `font`, `i`, `nobr`, `s`, `small`,
1771     * `strike`, `strong`, `tt`, `u`, and from start tags for `<a>` and
1772     * `<nobr>` when those elements are already on the active formatting
1773     * elements list / open elements stack.
1774     */
1775    private function adoptionAgency(string $subject): void
1776    {
1777        // Step 2: current node is a non-AFE HTML element with matching name.
1778        $current = $this->openElements->currentNode();
1779        if ($current !== null
1780            && $current->namespaceURI === Document::HTML_NS
1781            && $current->localName === $subject
1782            && !$this->activeFormatting->contains($current)
1783        ) {
1784            $this->openElements->pop();
1785            return;
1786        }
1787
1788        // Step 3-4: outer loop, max 8 iterations.
1789        for ($outerLoop = 0; $outerLoop < 8; $outerLoop++) {
1790            // 4c: find the formatting element.
1791            $formattingElement = $this->activeFormatting->findLastBetweenMarkerAnd($subject);
1792
1793            // 4d: no such element â€” "any other end tag" path.
1794            if ($formattingElement === null) {
1795                $this->processFormattingFallback($subject);
1796                return;
1797            }
1798
1799            // 4e: formatting element not on open stack â€” parse error, drop from AFE.
1800            if (!$this->openElements->contains($formattingElement)) {
1801                $this->activeFormatting->remove($formattingElement);
1802                return;
1803            }
1804
1805            // 4f: on stack but not in scope â€” parse error, return.
1806            if (!$this->openElements->hasInScope($formattingElement->localName)) {
1807                return;
1808            }
1809
1810            // 4g: not the current node is a parse error but doesn't stop us.
1811
1812            // 4h: furthest block â€” topmost special element below formatting element.
1813            $formattingIdx = $this->openElements->indexOf($formattingElement);
1814            if ($formattingIdx === null) {
1815                return;
1816            }
1817            $furthestBlock = null;
1818            $furthestBlockIdx = null;
1819            for ($i = $formattingIdx + 1; $i < $this->openElements->count(); $i++) {
1820                $node = $this->openElements->items()[$i];
1821                if (OpenElementsStack::isSpecialHtmlElement($node->localName)
1822                    && $node->namespaceURI === Document::HTML_NS) {
1823                    $furthestBlock = $node;
1824                    $furthestBlockIdx = $i;
1825                    break;
1826                }
1827            }
1828
1829            // 4i: no furthest block â€” pop everything down to formatting element, drop from AFE.
1830            if ($furthestBlock === null || $furthestBlockIdx === null) {
1831                while ($this->openElements->currentNode() !== $formattingElement) {
1832                    $this->openElements->pop();
1833                }
1834                $this->openElements->pop();
1835                $this->activeFormatting->remove($formattingElement);
1836                return;
1837            }
1838
1839            // 4j: common ancestor = element immediately above formatting element.
1840            $commonAncestor = $this->openElements->items()[$formattingIdx - 1] ?? null;
1841            if ($commonAncestor === null) {
1842                return;
1843            }
1844
1845            // 4k: bookmark in AFE at formatting element's position.
1846            $bookmark = $this->activeFormatting->indexOf($formattingElement);
1847            if ($bookmark === null) {
1848                return;
1849            }
1850
1851            // 4l-m: setup inner loop variables.
1852            $node = $furthestBlock;
1853            $nodeIdx = $furthestBlockIdx;
1854            $lastNode = $furthestBlock;
1855
1856            for ($innerLoop = 1; $innerLoop < 20; $innerLoop++) {
1857                // 4n.ii: node = element immediately above the current node in the stack.
1858                $nodeIdx--;
1859                if ($nodeIdx < 0) {
1860                    break;
1861                }
1862                $node = $this->openElements->items()[$nodeIdx];
1863
1864                // 4n.iii: stop when we reach formatting element.
1865                if ($node === $formattingElement) {
1866                    break;
1867                }
1868
1869                // 4n.iv: kick out from AFE after 3 iterations.
1870                if ($innerLoop > 3 && $this->activeFormatting->contains($node)) {
1871                    $this->activeFormatting->remove($node);
1872                }
1873
1874                // 4n.v: not in AFE â€” remove from open elements, continue (idx already adjusted).
1875                if (!$this->activeFormatting->contains($node)) {
1876                    $this->openElements->removeAt($nodeIdx);
1877                    // furthestBlockIdx and formattingIdx shift down by 1.
1878                    $furthestBlockIdx--;
1879                    continue;
1880                }
1881
1882                // 4n.vi: clone node, replace in both AFE and open elements.
1883                $newNode = $this->document->createElement($node->localName);
1884                foreach ($node->attributes() as $attr) {
1885                    $newNode->setAttributeNode($attr);
1886                }
1887                $this->activeFormatting->replace($node, $newNode);
1888                $this->openElements->replaceAt($nodeIdx, $newNode);
1889
1890                // 4n.vii: if last node was furthest block, move bookmark to after newNode in AFE.
1891                if ($lastNode === $furthestBlock) {
1892                    $newIdx = $this->activeFormatting->indexOf($newNode);
1893                    if ($newIdx !== null) {
1894                        $bookmark = $newIdx + 1;
1895                    }
1896                }
1897
1898                // 4n.viii: detach lastNode and append it to newNode.
1899                if ($lastNode->parentNode !== null) {
1900                    $lastNode->parentNode->removeChild($lastNode);
1901                }
1902                $newNode->appendChild($lastNode);
1903
1904                // 4n.ix: lastNode = node (after replacement, that's newNode).
1905                $lastNode = $newNode;
1906                $node = $newNode;
1907            }
1908
1909            // 4o: insert lastNode under common ancestor â€” but route
1910            // through `appropriatePlaceForInserting` with commonAncestor
1911            // as the override target so foster parenting fires when
1912            // commonAncestor is itself a table-context element. Without
1913            // this, `<table><a>1<p>2</a>` keeps the cloned `<a>` (with
1914            // "2") inside the still-open `<table>` instead of foster-
1915            // parenting it before the table (where the spec puts it
1916            // because the override target is a table).
1917            if ($lastNode->parentNode !== null) {
1918                $lastNode->parentNode->removeChild($lastNode);
1919            }
1920            $previousFoster = $this->fosterParenting;
1921            $this->fosterParenting = true;
1922            [$lnParent, $lnBefore] = $this->appropriatePlaceForInserting($commonAncestor);
1923            $this->fosterParenting = $previousFoster;
1924            if ($lnBefore !== null) {
1925                $lnParent->insertBefore($lastNode, $lnBefore);
1926            } else {
1927                $lnParent->appendChild($lastNode);
1928            }
1929
1930            // 4p: create new element for formatting element's token.
1931            $newFormatting = $this->document->createElement($formattingElement->localName);
1932            foreach ($formattingElement->attributes() as $attr) {
1933                $newFormatting->setAttributeNode($attr);
1934            }
1935
1936            // 4q: move children of furthest block to new formatting element.
1937            while ($furthestBlock->firstChild !== null) {
1938                $newFormatting->appendChild($furthestBlock->firstChild);
1939            }
1940
1941            // 4r: append new formatting element to furthest block.
1942            $furthestBlock->appendChild($newFormatting);
1943
1944            // 4s: replace formatting element in AFE with new formatting at bookmark.
1945            $this->activeFormatting->remove($formattingElement);
1946            $afeCount = count($this->activeFormatting->entries());
1947            $bookmark = max(0, min($bookmark, $afeCount));
1948            $this->activeFormatting->insertAt($bookmark, $newFormatting);
1949
1950            // 4t: remove formatting from open stack, insert new immediately after furthest block.
1951            $this->openElements->remove($formattingElement);
1952            $furthestBlockIdx = $this->openElements->indexOf($furthestBlock);
1953            if ($furthestBlockIdx === null) {
1954                return;
1955            }
1956            $this->openElements->insertAt($furthestBlockIdx + 1, $newFormatting);
1957        }
1958    }
1959
1960    /**
1961     * AAA's "any other end tag" fallback (step 4d): search the open elements
1962     * stack for a matching element, generate implied end tags, pop. Mirrors
1963     * the same logic in modeInBodyEndTag's catch-all.
1964     */
1965    private function processFormattingFallback(string $subject): void
1966    {
1967        for ($i = array_key_last($this->openElements->items()); $i !== null && $i >= 0; $i--) {
1968            $node = $this->openElements->items()[$i];
1969            if ($node->localName === $subject && $node->namespaceURI === Document::HTML_NS) {
1970                $this->openElements->generateImpliedEndTags($subject);
1971                $this->openElements->popUntilElement($node);
1972                return;
1973            }
1974            if (OpenElementsStack::isSpecialHtmlElement($node->localName)) {
1975                return; // parse error, ignore
1976            }
1977        }
1978    }
1979
1980    /**
1981     * Reconstruct the active formatting elements per Â§13.2.4.3. After certain
1982     * elements close, formatting elements that should still be active need
1983     * to be re-opened (e.g. text after a `</p>` that's still inside `<b>`).
1984     */
1985    private function reconstructActiveFormatting(): void
1986    {
1987        $entries = $this->activeFormatting->entries();
1988        if ($entries === []) {
1989            return;
1990        }
1991        $last = $entries[count($entries) - 1] ?? null;
1992        if ($last === null) {
1993            return; // marker â€” nothing to reconstruct
1994        }
1995        if ($this->openElements->contains($last)) {
1996            return; // already on the stack
1997        }
1998
1999        // Walk back to find the first entry that's still on the stack or a marker.
2000        $i = count($entries) - 1;
2001        while ($i > 0) {
2002            $i--;
2003            $entry = $entries[$i];
2004            if ($entry === null) {
2005                $i++;
2006                break;
2007            }
2008            if ($this->openElements->contains($entry)) {
2009                $i++;
2010                break;
2011            }
2012        }
2013
2014        // From i forward: clone and re-insert each entry. Route
2015        // through `appropriatePlaceForInserting` so foster parenting
2016        // applies â€” when reconstruction fires during table-context
2017        // character buffering (`InTableText` flush) the clone needs
2018        // to land before the table, not inside the still-current
2019        // tbody/tr/td that survived adoption-agency cleanup.
2020        for (; $i < count($entries); $i++) {
2021            $entry = $entries[$i];
2022            if ($entry === null) {
2023                continue;
2024            }
2025            $clone = $this->document->createElement($entry->localName);
2026            foreach ($entry->attributes() as $attr) {
2027                $clone->setAttributeNode($attr);
2028            }
2029            [$parent, $before] = $this->appropriatePlaceForInserting();
2030            if ($before !== null) {
2031                $parent->insertBefore($clone, $before);
2032            } else {
2033                $parent->appendChild($clone);
2034            }
2035            $this->openElements->push($clone);
2036            $this->activeFormatting->replace($entry, $clone);
2037        }
2038    }
2039
2040    // ============================================================
2041    // InTable (§13.2.6.4.9)
2042    // ============================================================
2043    private function modeInTable(Token $token, Tokenizer $tokenizer): void
2044    {
2045        if ($token instanceof CharacterToken) {
2046            if ($this->currentNodeIsTableContext()) {
2047                $this->pendingTableCharacters = [];
2048                $this->pendingTableCharactersHaveNonWhitespace = false;
2049                $this->originalInsertionMode = $this->insertionMode;
2050                $this->insertionMode = InsertionMode::InTableText;
2051                $this->reprocess($token);
2052                return;
2053            }
2054            // Per Â§13.2.6.4.9 "Anything else": when current node isn't
2055            // a table-context element (e.g. we've nested into a
2056            // MathML/SVG integration point inside the table), foster-
2057            // parent the character through InBody. Otherwise the
2058            // character silently drops.
2059            $this->processAsInBodyWithFosterParenting($token, $tokenizer);
2060            return;
2061        }
2062        if ($token instanceof CommentToken) {
2063            $this->insertComment($token);
2064            return;
2065        }
2066        if ($token instanceof DoctypeToken) {
2067            return;
2068        }
2069        if ($token instanceof StartTagToken) {
2070            $this->modeInTableStartTag($token, $tokenizer);
2071            return;
2072        }
2073        if ($token instanceof EndTagToken) {
2074            $this->modeInTableEndTag($token);
2075            return;
2076        }
2077        if ($token instanceof EofToken) {
2078            $this->modeInBody($token, $tokenizer);
2079        }
2080    }
2081
2082    private function modeInTableStartTag(StartTagToken $token, Tokenizer $tokenizer): void
2083    {
2084        $tag = $token->tagName;
2085        if ($tag === 'caption') {
2086            $this->clearStackToTableContext();
2087            $this->activeFormatting->pushMarker();
2088            $this->insertHtmlElement($token);
2089            $this->insertionMode = InsertionMode::InCaption;
2090            return;
2091        }
2092        if ($tag === 'colgroup') {
2093            $this->clearStackToTableContext();
2094            $this->insertHtmlElement($token);
2095            $this->insertionMode = InsertionMode::InColumnGroup;
2096            return;
2097        }
2098        if ($tag === 'col') {
2099            $this->clearStackToTableContext();
2100            $colgroup = $this->document->createElement('colgroup');
2101            $current = $this->openElements->currentNode();
2102            ($current ?? $this->document)->appendChild($colgroup);
2103            $this->openElements->push($colgroup);
2104            $this->insertionMode = InsertionMode::InColumnGroup;
2105            $this->reprocess($token);
2106            return;
2107        }
2108        if (in_array($tag, ['tbody', 'tfoot', 'thead'], true)) {
2109            $this->clearStackToTableContext();
2110            $this->insertHtmlElement($token);
2111            $this->insertionMode = InsertionMode::InTableBody;
2112            return;
2113        }
2114        if (in_array($tag, ['td', 'th', 'tr'], true)) {
2115            // Synthesise the implicit `<tbody>` via
2116            // `appropriatePlaceForInserting` so template-content
2117            // redirection fires when this runs inside a `<template>`.
2118            $this->clearStackToTableContext();
2119            $synthetic = $this->document->createElement('tbody');
2120            [$parent, $before] = $this->appropriatePlaceForInserting();
2121            if ($before !== null) {
2122                $parent->insertBefore($synthetic, $before);
2123            } else {
2124                $parent->appendChild($synthetic);
2125            }
2126            $this->openElements->push($synthetic);
2127            $this->insertionMode = InsertionMode::InTableBody;
2128            $this->reprocess($token);
2129            return;
2130        }
2131        if ($tag === 'table') {
2132            // Parse error: implicit </table>, then reprocess.
2133            if (!$this->openElements->hasInTableScope('table')) {
2134                return;
2135            }
2136            $this->openElements->popUntilLocalName('table');
2137            $this->resetInsertionModeAppropriately();
2138            $this->reprocess($token);
2139            return;
2140        }
2141        if (in_array($tag, ['style', 'script', 'template'], true)) {
2142            // Process via InHead (which knows how to switch tokenizer states).
2143            $this->modeInHead($token, $tokenizer);
2144            return;
2145        }
2146        if ($tag === 'input') {
2147            // Per spec: if type="hidden", insert normally; otherwise fall through to "anything else".
2148            $isHidden = false;
2149            foreach ($token->attributes as $attr) {
2150                if ($attr['name'] === 'type' && strcasecmp($attr['value'], 'hidden') === 0) {
2151                    $isHidden = true;
2152                    break;
2153                }
2154            }
2155            if ($isHidden) {
2156                $this->insertHtmlElement($token);
2157                $this->openElements->pop();
2158                return;
2159            }
2160        }
2161        if ($tag === 'form') {
2162            // WHATWG Â§13.2.6.4.9 â€” InTable's `<form>` is a parse
2163            // error. If a `<template>` is on the stack OR the form
2164            // pointer is already set, ignore the token. Otherwise
2165            // insert the form (without foster parenting â€” the
2166            // form lands inside the table, since the spec routes
2167            // through the normal current-node insert), set the
2168            // form pointer to it, then immediately pop. The pop
2169            // is what stops subsequent siblings from nesting
2170            // inside the form element.
2171            foreach ($this->openElements->items() as $el) {
2172                if ($el->localName === 'template' && $el->namespaceURI === Document::HTML_NS) {
2173                    return;
2174                }
2175            }
2176            if ($this->formElement !== null) {
2177                return;
2178            }
2179            $this->formElement = $this->insertHtmlElement($token);
2180            $this->openElements->pop();
2181            return;
2182        }
2183        // "Anything else" â€” parse error; process the token under InBody with
2184        // foster-parenting enabled (handled by appropriatePlaceForInserting).
2185        $this->processAsInBodyWithFosterParenting($token, $tokenizer);
2186    }
2187
2188    private function modeInTableEndTag(EndTagToken $token): void
2189    {
2190        $tag = $token->tagName;
2191        if ($tag === 'table') {
2192            if (!$this->openElements->hasInTableScope('table')) {
2193                return; // parse error
2194            }
2195            $this->openElements->popUntilLocalName('table');
2196            $this->resetInsertionModeAppropriately();
2197            return;
2198        }
2199        if (in_array($tag, ['body', 'caption', 'col', 'colgroup', 'html', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)) {
2200            return; // parse error, ignore
2201        }
2202        if (in_array($tag, ['style', 'script', 'template'], true)) {
2203            $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
2204            return;
2205        }
2206        // "Anything else" â€” process under InBody with foster parenting.
2207        $this->processAsInBodyWithFosterParenting($token, $this->activeTokenizer ?? new Tokenizer(''));
2208    }
2209
2210    // ============================================================
2211    // InTableText (§13.2.6.4.10)
2212    // ============================================================
2213    private function modeInTableText(Token $token, Tokenizer $tokenizer): void
2214    {
2215        if ($token instanceof CharacterToken) {
2216            if ($token->data === "\u{0000}") {
2217                return; // parse error, drop NUL
2218            }
2219            $this->pendingTableCharacters[] = $token->data;
2220            if (preg_match('/[^\t\n\f\r ]/', $token->data) === 1) {
2221                $this->pendingTableCharactersHaveNonWhitespace = true;
2222            }
2223            return;
2224        }
2225        // Any other token: flush the buffered characters and return to original mode.
2226        $this->flushPendingTableCharacters();
2227        $this->insertionMode = $this->originalInsertionMode;
2228        $this->reprocess($token);
2229    }
2230
2231    private function flushPendingTableCharacters(): void
2232    {
2233        if ($this->pendingTableCharacters === []) {
2234            return;
2235        }
2236        if ($this->pendingTableCharactersHaveNonWhitespace) {
2237            // Per spec: process each character via InBody rules with foster
2238            // parenting enabled.
2239            $combined = implode('', $this->pendingTableCharacters);
2240            $previous = $this->fosterParenting;
2241            $this->fosterParenting = true;
2242            try {
2243                $this->reconstructActiveFormatting();
2244                $this->insertCharacter(new CharacterToken($combined));
2245            } finally {
2246                $this->fosterParenting = $previous;
2247            }
2248            $this->framesetOk = false;
2249        } else {
2250            // All-whitespace: insert verbatim into table context.
2251            foreach ($this->pendingTableCharacters as $chunk) {
2252                $this->insertCharacter(new CharacterToken($chunk));
2253            }
2254        }
2255        $this->pendingTableCharacters = [];
2256        $this->pendingTableCharactersHaveNonWhitespace = false;
2257    }
2258
2259    // ============================================================
2260    // InCaption (§13.2.6.4.11)
2261    // ============================================================
2262    private function modeInCaption(Token $token, Tokenizer $tokenizer): void
2263    {
2264        if ($token instanceof EndTagToken && $token->tagName === 'caption') {
2265            if (!$this->openElements->hasInTableScope('caption')) {
2266                return; // parse error
2267            }
2268            $this->openElements->generateImpliedEndTags();
2269            $this->openElements->popUntilLocalName('caption');
2270            $this->activeFormatting->clearToLastMarker();
2271            $this->insertionMode = InsertionMode::InTable;
2272            return;
2273        }
2274        if ($token instanceof StartTagToken
2275            && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
2276        ) {
2277            // Implicit </caption>, then reprocess in InTable.
2278            if (!$this->openElements->hasInTableScope('caption')) {
2279                return;
2280            }
2281            $this->openElements->generateImpliedEndTags();
2282            $this->openElements->popUntilLocalName('caption');
2283            $this->activeFormatting->clearToLastMarker();
2284            $this->insertionMode = InsertionMode::InTable;
2285            $this->reprocess($token);
2286            return;
2287        }
2288        if ($token instanceof EndTagToken && $token->tagName === 'table') {
2289            if (!$this->openElements->hasInTableScope('caption')) {
2290                return;
2291            }
2292            $this->openElements->generateImpliedEndTags();
2293            $this->openElements->popUntilLocalName('caption');
2294            $this->activeFormatting->clearToLastMarker();
2295            $this->insertionMode = InsertionMode::InTable;
2296            $this->reprocess($token);
2297            return;
2298        }
2299        if ($token instanceof EndTagToken
2300            && in_array($token->tagName, ['body', 'col', 'colgroup', 'html', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
2301        ) {
2302            return; // parse error
2303        }
2304        // Anything else: process under InBody.
2305        $this->modeInBody($token, $tokenizer);
2306    }
2307
2308    // ============================================================
2309    // InColumnGroup (§13.2.6.4.12)
2310    // ============================================================
2311    private function modeInColumnGroup(Token $token, Tokenizer $tokenizer): void
2312    {
2313        if ($this->isWhitespaceOnlyCharacter($token)) {
2314            $this->insertCharacter($token);
2315            return;
2316        }
2317        if ($token instanceof CommentToken) {
2318            $this->insertComment($token);
2319            return;
2320        }
2321        if ($token instanceof DoctypeToken) {
2322            return;
2323        }
2324        if ($token instanceof StartTagToken && $token->tagName === 'col') {
2325            $this->insertHtmlElement($token);
2326            $this->openElements->pop();
2327            return;
2328        }
2329        if ($token instanceof EndTagToken && $token->tagName === 'colgroup') {
2330            $current = $this->openElements->currentNode();
2331            if ($current === null || $current->localName !== 'colgroup') {
2332                return; // parse error
2333            }
2334            $this->openElements->pop();
2335            $this->insertionMode = InsertionMode::InTable;
2336            return;
2337        }
2338        if ($token instanceof EndTagToken && $token->tagName === 'col') {
2339            return; // parse error
2340        }
2341        if ($token instanceof StartTagToken && $token->tagName === 'template') {
2342            $this->modeInHead($token, $tokenizer);
2343            return;
2344        }
2345        if ($token instanceof EndTagToken && $token->tagName === 'template') {
2346            $this->modeInHead($token, $tokenizer);
2347            return;
2348        }
2349        // EOF in InColumnGroup is processed under InBody â€” that's
2350        // the spec's explicit rule. Without this hop the EOF gets
2351        // dropped at the parse-error guard below and the document
2352        // never reaches the AfterBody→AfterAfterBody chain that
2353        // creates the implicit body element.
2354        if ($token instanceof EofToken) {
2355            $this->modeInBody($token, $tokenizer);
2356            return;
2357        }
2358        // "Anything else" â€” implicit </colgroup>, then reprocess in InTable.
2359        $current = $this->openElements->currentNode();
2360        if ($current === null || $current->localName !== 'colgroup') {
2361            return; // parse error
2362        }
2363        $this->openElements->pop();
2364        $this->insertionMode = InsertionMode::InTable;
2365        $this->reprocess($token);
2366    }
2367
2368    // ============================================================
2369    // InTableBody (§13.2.6.4.13)
2370    // ============================================================
2371    private function modeInTableBody(Token $token, Tokenizer $tokenizer): void
2372    {
2373        if ($token instanceof StartTagToken && $token->tagName === 'tr') {
2374            $this->clearStackToTableBodyContext();
2375            $this->insertHtmlElement($token);
2376            $this->insertionMode = InsertionMode::InRow;
2377            return;
2378        }
2379        if ($token instanceof StartTagToken && in_array($token->tagName, ['th', 'td'], true)) {
2380            // Implicit `<tr>`, then reprocess. Route the synthetic
2381            // insertion through `appropriatePlaceForInserting` so
2382            // template-content redirection fires â€” without it, a
2383            // `<td>` arriving in template-content's table body lands
2384            // its synthetic `<tr>` on the template element rather
2385            // than inside template.content.
2386            $this->clearStackToTableBodyContext();
2387            $synthetic = $this->document->createElement('tr');
2388            [$parent, $before] = $this->appropriatePlaceForInserting();
2389            if ($before !== null) {
2390                $parent->insertBefore($synthetic, $before);
2391            } else {
2392                $parent->appendChild($synthetic);
2393            }
2394            $this->openElements->push($synthetic);
2395            $this->insertionMode = InsertionMode::InRow;
2396            $this->reprocess($token);
2397            return;
2398        }
2399        if ($token instanceof EndTagToken
2400            && in_array($token->tagName, ['tbody', 'tfoot', 'thead'], true)
2401        ) {
2402            if (!$this->openElements->hasInTableScope($token->tagName)) {
2403                return;
2404            }
2405            $this->clearStackToTableBodyContext();
2406            $this->openElements->pop();
2407            $this->insertionMode = InsertionMode::InTable;
2408            return;
2409        }
2410        if (($token instanceof StartTagToken
2411                && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'tfoot', 'thead'], true))
2412            || ($token instanceof EndTagToken && $token->tagName === 'table')
2413        ) {
2414            $tbodyScope = $this->openElements->hasInTableScope('tbody')
2415                || $this->openElements->hasInTableScope('tfoot')
2416                || $this->openElements->hasInTableScope('thead');
2417            if (!$tbodyScope) {
2418                return; // parse error
2419            }
2420            $this->clearStackToTableBodyContext();
2421            $this->openElements->pop();
2422            $this->insertionMode = InsertionMode::InTable;
2423            $this->reprocess($token);
2424            return;
2425        }
2426        if ($token instanceof EndTagToken
2427            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html', 'td', 'th', 'tr'], true)
2428        ) {
2429            return; // parse error
2430        }
2431        $this->modeInTable($token, $tokenizer);
2432    }
2433
2434    // ============================================================
2435    // InRow (§13.2.6.4.14)
2436    // ============================================================
2437    private function modeInRow(Token $token, Tokenizer $tokenizer): void
2438    {
2439        if ($token instanceof StartTagToken && in_array($token->tagName, ['th', 'td'], true)) {
2440            $this->clearStackToTableRowContext();
2441            $this->insertHtmlElement($token);
2442            $this->insertionMode = InsertionMode::InCell;
2443            $this->activeFormatting->pushMarker();
2444            return;
2445        }
2446        if ($token instanceof EndTagToken && $token->tagName === 'tr') {
2447            if (!$this->openElements->hasInTableScope('tr')) {
2448                return;
2449            }
2450            $this->clearStackToTableRowContext();
2451            $this->openElements->pop();
2452            $this->insertionMode = InsertionMode::InTableBody;
2453            return;
2454        }
2455        if (($token instanceof StartTagToken
2456                && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'tfoot', 'thead', 'tr'], true))
2457            || ($token instanceof EndTagToken && $token->tagName === 'table')
2458        ) {
2459            if (!$this->openElements->hasInTableScope('tr')) {
2460                return;
2461            }
2462            $this->clearStackToTableRowContext();
2463            $this->openElements->pop();
2464            $this->insertionMode = InsertionMode::InTableBody;
2465            $this->reprocess($token);
2466            return;
2467        }
2468        if ($token instanceof EndTagToken && in_array($token->tagName, ['tbody', 'tfoot', 'thead'], true)) {
2469            if (!$this->openElements->hasInTableScope($token->tagName)) {
2470                return; // parse error
2471            }
2472            if (!$this->openElements->hasInTableScope('tr')) {
2473                return;
2474            }
2475            $this->clearStackToTableRowContext();
2476            $this->openElements->pop();
2477            $this->insertionMode = InsertionMode::InTableBody;
2478            $this->reprocess($token);
2479            return;
2480        }
2481        if ($token instanceof EndTagToken
2482            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html', 'td', 'th'], true)
2483        ) {
2484            return; // parse error
2485        }
2486        $this->modeInTable($token, $tokenizer);
2487    }
2488
2489    // ============================================================
2490    // InCell (§13.2.6.4.15)
2491    // ============================================================
2492    private function modeInCell(Token $token, Tokenizer $tokenizer): void
2493    {
2494        if ($token instanceof EndTagToken && in_array($token->tagName, ['td', 'th'], true)) {
2495            if (!$this->openElements->hasInTableScope($token->tagName)) {
2496                return;
2497            }
2498            $this->openElements->generateImpliedEndTags();
2499            $this->openElements->popUntilLocalName($token->tagName);
2500            $this->activeFormatting->clearToLastMarker();
2501            $this->insertionMode = InsertionMode::InRow;
2502            return;
2503        }
2504        if ($token instanceof StartTagToken
2505            && in_array($token->tagName, ['caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], true)
2506        ) {
2507            if (!$this->openElements->hasInTableScope('td')
2508                && !$this->openElements->hasInTableScope('th')
2509            ) {
2510                return; // parse error
2511            }
2512            $this->closeCell();
2513            $this->reprocess($token);
2514            return;
2515        }
2516        if ($token instanceof EndTagToken
2517            && in_array($token->tagName, ['body', 'caption', 'col', 'colgroup', 'html'], true)
2518        ) {
2519            return; // parse error
2520        }
2521        if ($token instanceof EndTagToken
2522            && in_array($token->tagName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true)
2523        ) {
2524            if (!$this->openElements->hasInTableScope($token->tagName)) {
2525                return;
2526            }
2527            $this->closeCell();
2528            $this->reprocess($token);
2529            return;
2530        }
2531        $this->modeInBody($token, $tokenizer);
2532    }
2533
2534    // ============================================================
2535    // InFrameset / AfterFrameset / AfterAfterFrameset (§13.2.6.4.19–21)
2536    // ============================================================
2537    private function modeInFrameset(Token $token): void
2538    {
2539        if ($this->isWhitespaceOnlyCharacter($token)) {
2540            $this->insertCharacter($token);
2541            return;
2542        }
2543        if ($token instanceof CommentToken) {
2544            $this->insertComment($token);
2545            return;
2546        }
2547        if ($token instanceof DoctypeToken) {
2548            return;
2549        }
2550        if ($token instanceof StartTagToken) {
2551            $tag = $token->tagName;
2552            if ($tag === 'html') {
2553                $this->processInBodyForStrayHtml($token);
2554                return;
2555            }
2556            if ($tag === 'frameset') {
2557                $this->insertHtmlElement($token);
2558                return;
2559            }
2560            if ($tag === 'frame') {
2561                $this->insertHtmlElement($token);
2562                $this->openElements->pop(); // void
2563                return;
2564            }
2565            if ($tag === 'noframes') {
2566                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
2567                return;
2568            }
2569            return; // parse error, ignore
2570        }
2571        if ($token instanceof EndTagToken) {
2572            if ($token->tagName === 'frameset') {
2573                $current = $this->openElements->currentNode();
2574                if ($current === null || ($current->localName === 'html' && $current->namespaceURI === Document::HTML_NS)) {
2575                    return; // parse error in fragment mode
2576                }
2577                $this->openElements->pop();
2578                // If not in fragment mode and current node is no longer a frameset, switch to AfterFrameset.
2579                $current = $this->openElements->currentNode();
2580                if ($current === null || $current->localName !== 'frameset') {
2581                    $this->insertionMode = InsertionMode::AfterFrameset;
2582                }
2583                return;
2584            }
2585            return; // parse error, ignore
2586        }
2587        if ($token instanceof EofToken) {
2588            // Parse error if current node isn't html.
2589            $this->done = true;
2590        }
2591    }
2592
2593    private function modeAfterFrameset(Token $token, Tokenizer $tokenizer): void
2594    {
2595        if ($this->isWhitespaceOnlyCharacter($token)) {
2596            $this->insertCharacter($token);
2597            return;
2598        }
2599        if ($token instanceof CommentToken) {
2600            $this->insertComment($token);
2601            return;
2602        }
2603        if ($token instanceof DoctypeToken) {
2604            return;
2605        }
2606        if ($token instanceof StartTagToken) {
2607            if ($token->tagName === 'html') {
2608                $this->processInBodyForStrayHtml($token);
2609                return;
2610            }
2611            if ($token->tagName === 'noframes') {
2612                $this->modeInHead($token, $tokenizer);
2613                return;
2614            }
2615            return; // parse error, ignore
2616        }
2617        if ($token instanceof EndTagToken && $token->tagName === 'html') {
2618            $this->insertionMode = InsertionMode::AfterAfterFrameset;
2619            return;
2620        }
2621        if ($token instanceof EofToken) {
2622            $this->done = true;
2623        }
2624    }
2625
2626    private function modeAfterAfterFrameset(Token $token, Tokenizer $tokenizer): void
2627    {
2628        if ($token instanceof CommentToken) {
2629            $this->document->appendChild($this->document->createComment($token->data));
2630            return;
2631        }
2632        if ($token instanceof DoctypeToken) {
2633            return;
2634        }
2635        if ($this->isWhitespaceOnlyCharacter($token)) {
2636            $this->insertCharacter($token);
2637            return;
2638        }
2639        if ($token instanceof StartTagToken && $token->tagName === 'html') {
2640            $this->processInBodyForStrayHtml($token);
2641            return;
2642        }
2643        if ($token instanceof StartTagToken && $token->tagName === 'noframes') {
2644            $this->modeInHead($token, $tokenizer);
2645            return;
2646        }
2647        if ($token instanceof EofToken) {
2648            $this->done = true;
2649        }
2650    }
2651
2652    // ============================================================
2653    // InHeadNoscript (§13.2.6.4.5) â€” only used when scripting is enabled
2654    // ============================================================
2655    private function modeInHeadNoscript(Token $token, Tokenizer $tokenizer): void
2656    {
2657        if ($token instanceof DoctypeToken) {
2658            return; // parse error
2659        }
2660        if ($token instanceof StartTagToken && $token->tagName === 'html') {
2661            $this->processInBodyForStrayHtml($token);
2662            return;
2663        }
2664        if ($token instanceof EndTagToken && $token->tagName === 'noscript') {
2665            $this->openElements->pop();
2666            $this->insertionMode = InsertionMode::InHead;
2667            return;
2668        }
2669        if ($this->isWhitespaceOnlyCharacter($token)
2670            || $token instanceof CommentToken
2671            || ($token instanceof StartTagToken && in_array($token->tagName, [
2672                'basefont', 'bgsound', 'link', 'meta', 'noframes', 'style',
2673            ], true))
2674        ) {
2675            $this->modeInHead($token, $tokenizer);
2676            return;
2677        }
2678        if ($token instanceof EndTagToken && $token->tagName === 'br') {
2679            // "Any other end tag" fallthrough â€” handled below.
2680        } elseif ($token instanceof EndTagToken) {
2681            return; // parse error, ignore
2682        }
2683        if ($token instanceof StartTagToken && in_array($token->tagName, ['head', 'noscript'], true)) {
2684            return; // parse error, ignore
2685        }
2686        // Anything else: parse error. Pop noscript, back to InHead, reprocess.
2687        $this->openElements->pop();
2688        $this->insertionMode = InsertionMode::InHead;
2689        $this->reprocess($token);
2690    }
2691
2692    /**
2693     * SVG element name case corrections per WHATWG Â§13.2.6.5. The tokenizer
2694     * lower-cases tag names; SVG uses camelCase for several elements and
2695     * the parser is required to restore the canonical form.
2696     */
2697    private const array SVG_TAG_CASE_CORRECTIONS = [
2698        'altglyph' => 'altGlyph', 'altglyphdef' => 'altGlyphDef',
2699        'altglyphitem' => 'altGlyphItem', 'animatecolor' => 'animateColor',
2700        'animatemotion' => 'animateMotion', 'animatetransform' => 'animateTransform',
2701        'clippath' => 'clipPath', 'feblend' => 'feBlend',
2702        'fecolormatrix' => 'feColorMatrix', 'fecomponenttransfer' => 'feComponentTransfer',
2703        'fecomposite' => 'feComposite', 'feconvolvematrix' => 'feConvolveMatrix',
2704        'fediffuselighting' => 'feDiffuseLighting', 'fedisplacementmap' => 'feDisplacementMap',
2705        'fedistantlight' => 'feDistantLight', 'fedropshadow' => 'feDropShadow',
2706        'feflood' => 'feFlood', 'fefunca' => 'feFuncA', 'fefuncb' => 'feFuncB',
2707        'fefuncg' => 'feFuncG', 'fefuncr' => 'feFuncR', 'fegaussianblur' => 'feGaussianBlur',
2708        'feimage' => 'feImage', 'femerge' => 'feMerge', 'femergenode' => 'feMergeNode',
2709        'femorphology' => 'feMorphology', 'feoffset' => 'feOffset',
2710        'fepointlight' => 'fePointLight', 'fespecularlighting' => 'feSpecularLighting',
2711        'fespotlight' => 'feSpotLight', 'fetile' => 'feTile', 'feturbulence' => 'feTurbulence',
2712        'foreignobject' => 'foreignObject', 'glyphref' => 'glyphRef',
2713        'lineargradient' => 'linearGradient', 'radialgradient' => 'radialGradient',
2714        'textpath' => 'textPath',
2715    ];
2716
2717    /**
2718     * MathML attribute case adjustments per HTML 5 Â§13.2.6.1.
2719     * Tokenizer lower-cases attribute names; for MathML, the canonical
2720     * spelling has a single uppercase URL.
2721     */
2722    private const array MATHML_ATTR_CASE_CORRECTIONS = [
2723        'definitionurl' => 'definitionURL',
2724    ];
2725
2726    /**
2727     * SVG attribute case adjustments per HTML 5 Â§13.2.6.1. The
2728     * tokenizer lower-cases all attribute names; this restores the
2729     * canonical camelCase that SVG attributes use (gradientUnits,
2730     * preserveAspectRatio, viewBox, etc.).
2731     */
2732    private const array SVG_ATTR_CASE_CORRECTIONS = [
2733        'attributename' => 'attributeName', 'attributetype' => 'attributeType',
2734        'basefrequency' => 'baseFrequency', 'baseprofile' => 'baseProfile',
2735        'calcmode' => 'calcMode', 'clippathunits' => 'clipPathUnits',
2736        'diffuseconstant' => 'diffuseConstant', 'edgemode' => 'edgeMode',
2737        'filterunits' => 'filterUnits', 'glyphref' => 'glyphRef',
2738        'gradienttransform' => 'gradientTransform', 'gradientunits' => 'gradientUnits',
2739        'kernelmatrix' => 'kernelMatrix', 'kernelunitlength' => 'kernelUnitLength',
2740        'keypoints' => 'keyPoints', 'keysplines' => 'keySplines',
2741        'keytimes' => 'keyTimes', 'lengthadjust' => 'lengthAdjust',
2742        'limitingconeangle' => 'limitingConeAngle', 'markerheight' => 'markerHeight',
2743        'markerunits' => 'markerUnits', 'markerwidth' => 'markerWidth',
2744        'maskcontentunits' => 'maskContentUnits', 'maskunits' => 'maskUnits',
2745        'numoctaves' => 'numOctaves', 'pathlength' => 'pathLength',
2746        'patterncontentunits' => 'patternContentUnits',
2747        'patterntransform' => 'patternTransform', 'patternunits' => 'patternUnits',
2748        'pointsatx' => 'pointsAtX', 'pointsaty' => 'pointsAtY',
2749        'pointsatz' => 'pointsAtZ', 'preservealpha' => 'preserveAlpha',
2750        'preserveaspectratio' => 'preserveAspectRatio',
2751        'primitiveunits' => 'primitiveUnits', 'refx' => 'refX', 'refy' => 'refY',
2752        'repeatcount' => 'repeatCount', 'repeatdur' => 'repeatDur',
2753        'requiredextensions' => 'requiredExtensions',
2754        'requiredfeatures' => 'requiredFeatures',
2755        'specularconstant' => 'specularConstant',
2756        'specularexponent' => 'specularExponent', 'spreadmethod' => 'spreadMethod',
2757        'startoffset' => 'startOffset', 'stddeviation' => 'stdDeviation',
2758        'stitchtiles' => 'stitchTiles', 'surfacescale' => 'surfaceScale',
2759        'systemlanguage' => 'systemLanguage', 'tablevalues' => 'tableValues',
2760        'targetx' => 'targetX', 'targety' => 'targetY',
2761        'textlength' => 'textLength', 'viewbox' => 'viewBox',
2762        'viewtarget' => 'viewTarget', 'xchannelselector' => 'xChannelSelector',
2763        'ychannelselector' => 'yChannelSelector', 'zoomandpan' => 'zoomAndPan',
2764    ];
2765
2766    /**
2767     * Foreign-attribute namespace map per HTML 5 Â§13.2.6.1.
2768     * Tokenized attribute names like `xlink:href` get the
2769     * corresponding `[namespace, prefix, localName]` triple so the
2770     * Attr node ends up in the right namespace for the serializer
2771     * (and any downstream consumers that introspect namespaces).
2772     *
2773     * @var array<string, array{0: string, 1: ?string, 2: string}>
2774     */
2775    private const array FOREIGN_ATTR_NAMESPACES = [
2776        'xlink:actuate' => [Document::XLINK_NS, 'xlink', 'actuate'],
2777        'xlink:arcrole' => [Document::XLINK_NS, 'xlink', 'arcrole'],
2778        'xlink:href' => [Document::XLINK_NS, 'xlink', 'href'],
2779        'xlink:role' => [Document::XLINK_NS, 'xlink', 'role'],
2780        'xlink:show' => [Document::XLINK_NS, 'xlink', 'show'],
2781        'xlink:title' => [Document::XLINK_NS, 'xlink', 'title'],
2782        'xlink:type' => [Document::XLINK_NS, 'xlink', 'type'],
2783        // `xml:base` is intentionally NOT in this table â€” WHATWG
2784        // Â§13.2.6.1 "adjust foreign attributes" only namespaces
2785        // `xml:lang` and `xml:space`. Other `xml:*` attributes (and
2786        // arbitrary `xml:foo` colon names) stay as flat HTML-style
2787        // attribute names.
2788        'xml:lang' => [Document::XML_NS, 'xml', 'lang'],
2789        'xml:space' => [Document::XML_NS, 'xml', 'space'],
2790        'xmlns' => [Document::XMLNS_NS, null, 'xmlns'],
2791        'xmlns:xlink' => [Document::XMLNS_NS, 'xmlns', 'xlink'],
2792    ];
2793
2794    /**
2795     * HTML-element names that "break out" of foreign content per Â§13.2.6.5
2796     * "Any start tag whose tag name is one of: ..." â€” encountering one of
2797     * these in foreign content pops back to HTML.
2798     */
2799    private const array FOREIGN_BREAKOUT_TAGS = [
2800        'b', 'big', 'blockquote', 'body', 'br', 'center', 'code', 'dd', 'div',
2801        'dl', 'dt', 'em', 'embed', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head',
2802        'hr', 'i', 'img', 'li', 'listing', 'menu', 'meta', 'nobr', 'ol', 'p',
2803        'pre', 'ruby', 's', 'small', 'span', 'strong', 'strike', 'sub', 'sup',
2804        'table', 'tt', 'u', 'ul', 'var',
2805    ];
2806
2807    private function shouldDispatchInForeignContent(Token $token): bool
2808    {
2809        $adjustedCurrent = $this->adjustedCurrentNode();
2810        if ($adjustedCurrent === null || $adjustedCurrent->namespaceURI === Document::HTML_NS) {
2811            return false;
2812        }
2813        // EOF: handled by the regular mode.
2814        if ($token instanceof EofToken) {
2815            return false;
2816        }
2817        // Per WHATWG Â§13.2.6.1 tree-construction dispatcher: at integration
2818        // points HTML rules win over foreign-content rules. Without these
2819        // gates, a break-out tag inside (say) <foreignObject> would pop back
2820        // to foreignObject, re-dispatch, and bounce into foreign content
2821        // again â€” infinite loop.
2822        if ($this->isMathmlTextIntegrationPoint($adjustedCurrent)) {
2823            if ($token instanceof CharacterToken) {
2824                return false;
2825            }
2826            if ($token instanceof StartTagToken
2827                && $token->tagName !== 'mglyph'
2828                && $token->tagName !== 'malignmark'
2829            ) {
2830                return false;
2831            }
2832        }
2833        if ($adjustedCurrent->namespaceURI === Document::MATHML_NS
2834            && $adjustedCurrent->localName === 'annotation-xml'
2835            && $token instanceof StartTagToken
2836            && $token->tagName === 'svg'
2837        ) {
2838            return false;
2839        }
2840        if ($this->isHtmlIntegrationPoint($adjustedCurrent)) {
2841            if ($token instanceof StartTagToken || $token instanceof CharacterToken) {
2842                return false;
2843            }
2844        }
2845        return true;
2846    }
2847
2848    private function adjustedCurrentNode(): ?Element
2849    {
2850        // Fragment parsing isn't wired in yet; adjusted current node === current node.
2851        return $this->openElements->currentNode();
2852    }
2853
2854    /**
2855     * Insert a foreign element (SVG or MathML) onto the stack with namespace
2856     * applied. $caseTable optionally remaps lower-case tokenizer names back
2857     * to canonical camelCase (used for SVG element names).
2858     *
2859     * @param array<string, string> $caseTable
2860     */
2861    private function insertForeignElement(StartTagToken $token, string $namespace, array $caseTable): Element
2862    {
2863        $localName = $caseTable[$token->tagName] ?? $token->tagName;
2864        $element = $this->document->createElement($localName, $namespace);
2865        // HTML 5 Â§13.2.6.5 step 7.4.3 â€” adjust MathML / SVG attribute
2866        // casing, then adjust foreign attributes (xlink:*, xml:*,
2867        // xmlns) by attaching the proper namespace URI.
2868        $svgAdjust = $namespace === Document::SVG_NS
2869            ? self::SVG_ATTR_CASE_CORRECTIONS
2870            : [];
2871        $mathmlAdjust = $namespace === Document::MATHML_NS
2872            ? self::MATHML_ATTR_CASE_CORRECTIONS
2873            : [];
2874        foreach ($token->attributes as $attr) {
2875            $name = $attr['name'];
2876            $name = $svgAdjust[$name] ?? $name;
2877            $name = $mathmlAdjust[$name] ?? $name;
2878            $foreign = self::FOREIGN_ATTR_NAMESPACES[$name] ?? null;
2879            if ($foreign !== null) {
2880                $element->setAttributeNode(new \Phpdftk\Html\Dom\Attr(
2881                    localName: $foreign[2],
2882                    value: $attr['value'],
2883                    namespaceURI: $foreign[0],
2884                    prefix: $foreign[1],
2885                ));
2886                continue;
2887            }
2888            $element->setAttribute($name, $attr['value']);
2889        }
2890        [$parent, $before] = $this->appropriatePlaceForInserting();
2891        if ($before !== null) {
2892            $parent->insertBefore($element, $before);
2893        } else {
2894            $parent->appendChild($element);
2895        }
2896        $this->openElements->push($element);
2897        return $element;
2898    }
2899
2900    /**
2901     * Foreign content processing per Â§13.2.6.5. Phase 1B.3-bis implements the
2902     * common path: namespaced element insertion, character data, comments,
2903     * end-tag matching, and the "break out" tags that pop back to HTML.
2904     */
2905    private function modeInForeignContent(Token $token): void
2906    {
2907        if ($token instanceof CharacterToken) {
2908            if ($token->data === "\u{0000}") {
2909                $this->insertCharacter(new CharacterToken("\u{FFFD}"));
2910                return;
2911            }
2912            if (preg_match('/[^\t\n\f\r ]/', $token->data) === 1) {
2913                $this->framesetOk = false;
2914            }
2915            $this->insertCharacter($token);
2916            return;
2917        }
2918        if ($token instanceof CommentToken) {
2919            $this->insertComment($token);
2920            return;
2921        }
2922        if ($token instanceof DoctypeToken) {
2923            return; // parse error, ignore
2924        }
2925        if ($token instanceof StartTagToken) {
2926            $tag = $token->tagName;
2927            // Break-out check.
2928            $isFontWithBreakoutAttr = false;
2929            if ($tag === 'font') {
2930                foreach ($token->attributes as $attr) {
2931                    if (in_array($attr['name'], ['color', 'face', 'size'], true)) {
2932                        $isFontWithBreakoutAttr = true;
2933                        break;
2934                    }
2935                }
2936            }
2937            if (in_array($tag, self::FOREIGN_BREAKOUT_TAGS, true) || $isFontWithBreakoutAttr) {
2938                // Pop until we're back in HTML or at a foreign-text-integration point.
2939                while (!$this->openElements->isEmpty()) {
2940                    $current = $this->openElements->currentNode();
2941                    if ($current === null
2942                        || $current->namespaceURI === Document::HTML_NS
2943                        || $this->isMathmlTextIntegrationPoint($current)
2944                        || $this->isHtmlIntegrationPoint($current)
2945                    ) {
2946                        break;
2947                    }
2948                    $this->openElements->pop();
2949                }
2950                $this->dispatch($token, $this->activeTokenizer ?? new Tokenizer(''));
2951                return;
2952            }
2953            $adjusted = $this->adjustedCurrentNode();
2954            if ($adjusted === null) {
2955                return;
2956            }
2957            $namespace = $adjusted->namespaceURI;
2958            $caseTable = $namespace === Document::SVG_NS ? self::SVG_TAG_CASE_CORRECTIONS : [];
2959            $this->insertForeignElement($token, $namespace, $caseTable);
2960            if ($token->selfClosing) {
2961                $this->openElements->pop();
2962            }
2963            return;
2964        }
2965        if ($token instanceof EndTagToken) {
2966            $tag = $token->tagName;
2967            // WHATWG Â§13.2.6.5 â€” `</br>` and `</p>` in foreign
2968            // content are special: pop foreign elements until the
2969            // current node is HTML / an integration point, then
2970            // reprocess the token. `</br>` additionally runs as
2971            // a synthesised `<br>` start tag in HTML.
2972            if ($tag === 'br' || $tag === 'p') {
2973                while (!$this->openElements->isEmpty()) {
2974                    $top = $this->openElements->currentNode();
2975                    if ($top === null) {
2976                        break;
2977                    }
2978                    if ($top->namespaceURI === Document::HTML_NS) {
2979                        break;
2980                    }
2981                    $this->openElements->pop();
2982                }
2983                if ($tag === 'br') {
2984                    $synthetic = new StartTagToken();
2985                    $synthetic->tagName = 'br';
2986                    $this->dispatch($synthetic, $this->activeTokenizer ?? new Tokenizer(''));
2987                } else {
2988                    $this->dispatch($token, $this->activeTokenizer ?? new Tokenizer(''));
2989                }
2990                return;
2991            }
2992            $items = $this->openElements->items();
2993            $i = array_key_last($items);
2994            if ($i === null) {
2995                return;
2996            }
2997            // Per spec: if current node's local name (case-insensitive for
2998            // foreign) doesn't match the end tag, walk up looking for a match.
2999            $node = $items[$i];
3000            if (strcasecmp($node->localName, $tag) !== 0) {
3001                // parse error, but continue walking
3002            }
3003            // Â§13.2.6.5 "An end tag whose tag name is neither 'br'
3004            // nor 'p'" â€” iterate foreign-namespace ancestors top-
3005            // down, comparing case-insensitively. If a foreign node
3006            // matches the tag, pop down to and including it. If the
3007            // walk reaches an HTML-namespace ancestor without
3008            // matching, hand the token to the current insertion
3009            // mode (step 7).
3010            //
3011            // Order matters: check namespace FIRST so a literal HTML
3012            // `<div>` on the stack doesn't get popped by `</div>`
3013            // arriving from foreign content. Per the spec, step 4's
3014            // name-match runs against the foreign-content chain,
3015            // not against HTML elements; HTML elements are handled
3016            // by step 7's reprocess.
3017            for (; $i >= 0; $i--) {
3018                $node = $items[$i];
3019                if ($node->namespaceURI === Document::HTML_NS) {
3020                    // Can't go through dispatch() â€” the current node
3021                    // is still foreign, dispatch() would re-route the
3022                    // same token back to foreign-content (infinite
3023                    // loop). Hand off to the mode handler directly.
3024                    $this->dispatchToInsertionMode($token);
3025                    return;
3026                }
3027                if (strcasecmp($node->localName, $tag) === 0) {
3028                    while ($this->openElements->count() - 1 > $i) {
3029                        $this->openElements->pop();
3030                    }
3031                    $this->openElements->pop();
3032                    return;
3033                }
3034            }
3035        }
3036    }
3037
3038    /**
3039     * MathML text integration points per spec: mi, mo, mn, ms, mtext in the
3040     * MathML namespace. Phase 1B.3-bis ships this for the break-out check;
3041     * full integration-point handling (which lets HTML breach into mtext etc.)
3042     * lands in a follow-up.
3043     */
3044    private function isMathmlTextIntegrationPoint(Element $el): bool
3045    {
3046        return $el->namespaceURI === Document::MATHML_NS
3047            && in_array($el->localName, ['mi', 'mo', 'mn', 'ms', 'mtext'], true);
3048    }
3049
3050    /**
3051     * HTML integration points per spec: `<annotation-xml>` with encoding
3052     * text/html or application/xhtml+xml (MathML), and `<foreignObject>`,
3053     * `<desc>`, `<title>` in SVG.
3054     */
3055    private function isHtmlIntegrationPoint(Element $el): bool
3056    {
3057        if ($el->namespaceURI === Document::MATHML_NS && $el->localName === 'annotation-xml') {
3058            $enc = strtolower($el->getAttribute('encoding') ?? '');
3059            return $enc === 'text/html' || $enc === 'application/xhtml+xml';
3060        }
3061        if ($el->namespaceURI === Document::SVG_NS) {
3062            return in_array($el->localName, ['foreignObject', 'desc', 'title'], true);
3063        }
3064        return false;
3065    }
3066
3067    // ============================================================
3068    // InTemplate (§13.2.6.4.18)
3069    // ============================================================
3070    private function modeInTemplate(Token $token, Tokenizer $tokenizer): void
3071    {
3072        if ($token instanceof CharacterToken
3073            || $token instanceof CommentToken
3074            || $token instanceof DoctypeToken
3075        ) {
3076            $this->modeInBody($token, $tokenizer);
3077            return;
3078        }
3079        if ($token instanceof StartTagToken) {
3080            $tag = $token->tagName;
3081            if (in_array($tag, ['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'script', 'style', 'template', 'title'], true)) {
3082                $this->modeInHead($token, $tokenizer);
3083                return;
3084            }
3085            if (in_array($tag, ['caption', 'colgroup', 'tbody', 'tfoot', 'thead'], true)) {
3086                array_pop($this->templateInsertionModes);
3087                $this->templateInsertionModes[] = InsertionMode::InTable;
3088                $this->insertionMode = InsertionMode::InTable;
3089                $this->reprocess($token);
3090                return;
3091            }
3092            if ($tag === 'col') {
3093                array_pop($this->templateInsertionModes);
3094                $this->templateInsertionModes[] = InsertionMode::InColumnGroup;
3095                $this->insertionMode = InsertionMode::InColumnGroup;
3096                $this->reprocess($token);
3097                return;
3098            }
3099            if ($tag === 'tr') {
3100                array_pop($this->templateInsertionModes);
3101                $this->templateInsertionModes[] = InsertionMode::InTableBody;
3102                $this->insertionMode = InsertionMode::InTableBody;
3103                $this->reprocess($token);
3104                return;
3105            }
3106            if (in_array($tag, ['td', 'th'], true)) {
3107                array_pop($this->templateInsertionModes);
3108                $this->templateInsertionModes[] = InsertionMode::InRow;
3109                $this->insertionMode = InsertionMode::InRow;
3110                $this->reprocess($token);
3111                return;
3112            }
3113            // Any other start tag.
3114            array_pop($this->templateInsertionModes);
3115            $this->templateInsertionModes[] = InsertionMode::InBody;
3116            $this->insertionMode = InsertionMode::InBody;
3117            $this->reprocess($token);
3118            return;
3119        }
3120        if ($token instanceof EndTagToken) {
3121            if ($token->tagName === 'template') {
3122                $this->modeInHead($token, $tokenizer);
3123                return;
3124            }
3125            return; // parse error, ignore other end tags
3126        }
3127        if ($token instanceof EofToken) {
3128            if (!$this->openElements->containsLocalName('template')) {
3129                $this->done = true;
3130                return;
3131            }
3132            // Pop until template popped, clear AFE to marker, pop template
3133            // insertion mode, reset insertion mode, reprocess.
3134            $this->openElements->popUntilLocalName('template');
3135            $this->activeFormatting->clearToLastMarker();
3136            array_pop($this->templateInsertionModes);
3137            $this->resetInsertionModeAppropriately();
3138            $this->reprocess($token);
3139        }
3140    }
3141
3142    // ============================================================
3143    // InSelect (§13.2.6.4.16)
3144    // ============================================================
3145    private function modeInSelect(Token $token): void
3146    {
3147        if ($token instanceof CharacterToken) {
3148            if ($token->data === "\u{0000}") {
3149                return; // parse error, drop
3150            }
3151            // Reconstruct AFE first â€” under customizable-select a
3152            // formatting element that was opened then unwound (e.g.
3153            // by an end tag) needs to be re-instantiated around
3154            // subsequent text. Without this, `<select><option><i>i<b>ib</i>b`
3155            // drops "b" as bare text instead of wrapping it in the
3156            // surviving `<b>` from AFE.
3157            $this->reconstructActiveFormatting();
3158            $this->insertCharacter($token);
3159            return;
3160        }
3161        if ($token instanceof CommentToken) {
3162            $this->insertComment($token);
3163            return;
3164        }
3165        if ($token instanceof DoctypeToken) {
3166            return;
3167        }
3168        if ($token instanceof StartTagToken) {
3169            $tag = $token->tagName;
3170            if ($tag === 'html') {
3171                $this->processInBodyForStrayHtml($token);
3172                return;
3173            }
3174            if ($tag === 'option') {
3175                $current = $this->openElements->currentNode();
3176                if ($current !== null && $current->localName === 'option') {
3177                    $this->openElements->pop();
3178                }
3179                // Reconstruct AFE first â€” under customizable-select
3180                // a formatting element that was opened inside the
3181                // select but unwound by an end tag (e.g. `</div>`
3182                // implicitly closing an enclosing `<i>`) needs to
3183                // be re-instantiated around the upcoming option.
3184                $this->reconstructActiveFormatting();
3185                $this->insertHtmlElement($token);
3186                return;
3187            }
3188            if ($tag === 'optgroup') {
3189                $current = $this->openElements->currentNode();
3190                if ($current !== null && $current->localName === 'option') {
3191                    $this->openElements->pop();
3192                }
3193                $current = $this->openElements->currentNode();
3194                if ($current !== null && $current->localName === 'optgroup') {
3195                    $this->openElements->pop();
3196                }
3197                $this->reconstructActiveFormatting();
3198                $this->insertHtmlElement($token);
3199                return;
3200            }
3201            if ($tag === 'select') {
3202                // Parse error â€” under customizable-select the second
3203                // `<select>` pops everything back through the open
3204                // select even if intermediate non-option elements
3205                // (e.g. `<b>`) are on the stack. The older "select
3206                // scope" check rejected this case because `<b>` is a
3207                // boundary; html5lib-tests expect the unwind.
3208                if (!$this->openElements->containsLocalName('select')) {
3209                    return;
3210                }
3211                $this->openElements->popUntilLocalName('select');
3212                $this->resetInsertionModeAppropriately();
3213                return;
3214            }
3215            if (in_array($tag, ['input', 'textarea'], true)) {
3216                // Parse error: implicit </select>, then reprocess.
3217                // `<keygen>` was historically in this set but the
3218                // customizable-select work routes it through the
3219                // void-element branch below â€” html5lib-tests expect
3220                // `<select><keygen>` to nest the keygen inside the
3221                // select rather than break out of it.
3222                if (!$this->openElements->hasInSelectScope('select')) {
3223                    return;
3224                }
3225                $this->openElements->popUntilLocalName('select');
3226                $this->resetInsertionModeAppropriately();
3227                $this->reprocess($token);
3228                return;
3229            }
3230            if (in_array($tag, ['script', 'template'], true)) {
3231                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
3232                return;
3233            }
3234            // HTML Living Standard customizable-select (§13.2.6.4.16) â€”
3235            // `<svg>` / `<math>` in InSelect insert the foreign element
3236            // directly into the current node (which may be an
3237            // `<option>` or `<optgroup>`) and then continue with
3238            // foreign-content dispatch for descendants. The html5lib
3239            // expectations for `<select><option><svg>` show the SVG
3240            // root INSIDE the option, so we keep option/optgroup on
3241            // the stack rather than popping them as the older "in
3242            // select" rules did.
3243            if ($tag === 'svg') {
3244                $this->insertForeignElement($token, Document::SVG_NS, self::SVG_TAG_CASE_CORRECTIONS);
3245                if ($token->selfClosing) {
3246                    $this->openElements->pop();
3247                }
3248                return;
3249            }
3250            if ($tag === 'math') {
3251                $this->insertForeignElement($token, Document::MATHML_NS, []);
3252                if ($token->selfClosing) {
3253                    $this->openElements->pop();
3254                }
3255                return;
3256            }
3257            // HTML Living Standard Â§13.2.6.4.16 â€” `<hr>` inside a
3258            // `<select>` pops any open `<option>` / `<optgroup>` and
3259            // inserts the hr as a void element. Lets authors break
3260            // an option list into sections.
3261            if ($tag === 'hr') {
3262                $current = $this->openElements->currentNode();
3263                if ($current !== null && $current->localName === 'option') {
3264                    $this->openElements->pop();
3265                }
3266                $current = $this->openElements->currentNode();
3267                if ($current !== null && $current->localName === 'optgroup') {
3268                    $this->openElements->pop();
3269                }
3270                $this->insertHtmlElement($token);
3271                // Void element â€” pop immediately.
3272                $this->openElements->pop();
3273                return;
3274            }
3275            // Customizable-select: a small set of legacy form-control
3276            // elements (`<menuitem>`, `<keygen>`) and the
3277            // `<plaintext>` raw-text trapdoor are inserted directly
3278            // into the select rather than triggering an implicit
3279            // `</select>` close. html5lib-tests expectations match
3280            // this â€” keygen / plaintext / menuitem land as children
3281            // of the current node (which may be option / optgroup).
3282            if ($tag === 'menuitem') {
3283                $this->insertHtmlElement($token);
3284                return;
3285            }
3286            if ($tag === 'keygen') {
3287                $this->insertHtmlElement($token);
3288                $this->openElements->pop();
3289                return;
3290            }
3291            if ($tag === 'plaintext') {
3292                $this->insertHtmlElement($token);
3293                $tokenizer = $this->activeTokenizer ?? new Tokenizer('');
3294                $tokenizer->state = TokenizerState::Plaintext;
3295                return;
3296            }
3297            // Table-context start tags are still rejected outright
3298            // even under the customizable-select rules â€” a `<tr>` /
3299            // `<td>` / `<caption>` etc. inside a stray `<select>`
3300            // is a parse error and the token is dropped (it doesn't
3301            // get nested as content). Otherwise the html5lib tests17
3302            // cases would regress.
3303            if (in_array($tag, [
3304                'caption', 'col', 'colgroup', 'frame', 'head',
3305                'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
3306            ], true)) {
3307                return;
3308            }
3309            // Customizable-select fallback: any other element nests
3310            // inside the `<select>` rather than being dropped. The
3311            // historical "in select" mode was parse-error / ignore
3312            // for "any other start tag", but the WHATWG customizable-
3313            // select work and the html5lib-tests expectations show
3314            // generic elements (`<div>`, `<button>`, `<datalist>`,
3315            // `<i>`, `<img>`, etc.) landing as descendants of the
3316            // select. Void elements pop themselves after insertion;
3317            // formatting elements push onto the active formatting
3318            // elements list as they would in InBody.
3319            $void = ['area', 'br', 'embed', 'img', 'wbr'];
3320            $formatting = ['a', 'b', 'big', 'code', 'em', 'font', 'i', 'nobr', 's', 'small', 'strike', 'strong', 'tt', 'u'];
3321            if (in_array($tag, $formatting, true)) {
3322                $this->reconstructActiveFormatting();
3323                $el = $this->insertHtmlElement($token);
3324                $this->activeFormatting->push($el);
3325                return;
3326            }
3327            if (in_array($tag, $void, true)) {
3328                $this->insertHtmlElement($token);
3329                $this->openElements->pop();
3330                return;
3331            }
3332            $this->insertHtmlElement($token);
3333            return;
3334        }
3335        if ($token instanceof EndTagToken) {
3336            $tag = $token->tagName;
3337            if ($tag === 'optgroup') {
3338                $items = $this->openElements->items();
3339                $top = $items[count($items) - 1] ?? null;
3340                $previous = $items[count($items) - 2] ?? null;
3341                if ($top !== null && $top->localName === 'option'
3342                    && $previous !== null && $previous->localName === 'optgroup'
3343                ) {
3344                    $this->openElements->pop();
3345                }
3346                $current = $this->openElements->currentNode();
3347                if ($current !== null && $current->localName === 'optgroup') {
3348                    $this->openElements->pop();
3349                }
3350                return;
3351            }
3352            if ($tag === 'option') {
3353                $current = $this->openElements->currentNode();
3354                if ($current !== null && $current->localName === 'option') {
3355                    $this->openElements->pop();
3356                }
3357                return;
3358            }
3359            if ($tag === 'select') {
3360                if (!$this->openElements->hasInSelectScope('select')) {
3361                    return; // parse error
3362                }
3363                $this->openElements->popUntilLocalName('select');
3364                $this->resetInsertionModeAppropriately();
3365                return;
3366            }
3367            if ($tag === 'template') {
3368                $this->modeInHead($token, $this->activeTokenizer ?? new Tokenizer(''));
3369                return;
3370            }
3371            // Customizable-select fallback: end tags for elements
3372            // that the expanded "any other start tag" rule now nests
3373            // inside the select need to close their matching open
3374            // element. Restrict the InBody handoff to cases where
3375            // the matching element is between the top of the stack
3376            // and the open `<select>` (i.e. inside the select). For
3377            // elements OUTSIDE the select (e.g. `</font>` from
3378            // `<font><select>...</font>`), keep the historical
3379            // parse-error / ignore behaviour â€” running adoption
3380            // agency across a select boundary would re-wrap the
3381            // whole subtree.
3382            $items = $this->openElements->items();
3383            $matchInsideSelect = false;
3384            for ($i = array_key_last($items); $i !== null && $i >= 0; $i--) {
3385                $node = $items[$i];
3386                if ($node->namespaceURI !== Document::HTML_NS) {
3387                    continue;
3388                }
3389                if ($node->localName === 'select') {
3390                    break;
3391                }
3392                if ($node->localName === $tag) {
3393                    $matchInsideSelect = true;
3394                    break;
3395                }
3396            }
3397            if (!$matchInsideSelect) {
3398                return; // parse error, ignore
3399            }
3400            $this->modeInBodyEndTag($token);
3401            return;
3402        }
3403        if ($token instanceof EofToken) {
3404            $this->modeInBody($token, $this->activeTokenizer ?? new Tokenizer(''));
3405        }
3406    }
3407
3408    // ============================================================
3409    // InSelectInTable (§13.2.6.4.17)
3410    // ============================================================
3411    private function modeInSelectInTable(Token $token, Tokenizer $tokenizer): void
3412    {
3413        if ($token instanceof StartTagToken
3414            && in_array($token->tagName, ['caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th'], true)
3415        ) {
3416            // Implicit </select>, then reprocess in surrounding table mode.
3417            $this->openElements->popUntilLocalName('select');
3418            $this->resetInsertionModeAppropriately();
3419            $this->reprocess($token);
3420            return;
3421        }
3422        if ($token instanceof EndTagToken
3423            && in_array($token->tagName, ['caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th'], true)
3424        ) {
3425            if (!$this->openElements->hasInTableScope($token->tagName)) {
3426                return; // parse error
3427            }
3428            $this->openElements->popUntilLocalName('select');
3429            $this->resetInsertionModeAppropriately();
3430            $this->reprocess($token);
3431            return;
3432        }
3433        $this->modeInSelect($token);
3434    }
3435
3436    private function closeCell(): void
3437    {
3438        $cellName = $this->openElements->hasInTableScope('td') ? 'td' : 'th';
3439        $this->openElements->generateImpliedEndTags();
3440        $this->openElements->popUntilLocalName($cellName);
3441        $this->activeFormatting->clearToLastMarker();
3442        $this->insertionMode = InsertionMode::InRow;
3443    }
3444
3445    // ============================================================
3446    // Table helpers
3447    // ============================================================
3448    private function currentNodeIsTableContext(): bool
3449    {
3450        $current = $this->openElements->currentNode();
3451        if ($current === null || $current->namespaceURI !== Document::HTML_NS) {
3452            return false;
3453        }
3454        return in_array($current->localName, ['table', 'tbody', 'tfoot', 'thead', 'tr'], true);
3455    }
3456
3457    private function clearStackToTableContext(): void
3458    {
3459        while (true) {
3460            $current = $this->openElements->currentNode();
3461            if ($current === null) {
3462                return;
3463            }
3464            if (in_array($current->localName, ['table', 'template', 'html'], true)) {
3465                return;
3466            }
3467            $this->openElements->pop();
3468        }
3469    }
3470
3471    private function clearStackToTableBodyContext(): void
3472    {
3473        while (true) {
3474            $current = $this->openElements->currentNode();
3475            if ($current === null) {
3476                return;
3477            }
3478            if (in_array($current->localName, ['tbody', 'tfoot', 'thead', 'template', 'html'], true)) {
3479                return;
3480            }
3481            $this->openElements->pop();
3482        }
3483    }
3484
3485    private function clearStackToTableRowContext(): void
3486    {
3487        while (true) {
3488            $current = $this->openElements->currentNode();
3489            if ($current === null) {
3490                return;
3491            }
3492            if (in_array($current->localName, ['tr', 'template', 'html'], true)) {
3493                return;
3494            }
3495            $this->openElements->pop();
3496        }
3497    }
3498
3499    /**
3500     * Reset the insertion mode appropriately per Â§13.2.4.1. Walks the open
3501     * elements stack from the top down and picks the right mode based on
3502     * the deepest table-related ancestor (used after `</table>`, `</caption>`,
3503     * etc. where we exit a table sub-tree).
3504     */
3505    private function resetInsertionModeAppropriately(): void
3506    {
3507        $items = $this->openElements->items();
3508        $lastIdx = array_key_last($items);
3509        for ($i = $lastIdx; $i !== null && $i >= 0; $i--) {
3510            $node = $items[$i];
3511            $name = $node->localName;
3512            // Phase 1B.3 simplification: ignore the "last" flag from the
3513            // fragment-parsing case (no fragment parsing yet).
3514            if ($name === 'select') {
3515                // Per WHATWG Â§13.2.4.1 step 4 â€” walk ancestors of
3516                // the select. If a `<table>` ancestor is found
3517                // before hitting a `<template>` (which would limit
3518                // the search to the template content), use the
3519                // table-aware select mode.
3520                for ($j = $i - 1; $j >= 0; $j--) {
3521                    $ancestor = $items[$j];
3522                    if ($ancestor->localName === 'template'
3523                        && $ancestor->namespaceURI === Document::HTML_NS
3524                    ) {
3525                        break;
3526                    }
3527                    if ($ancestor->localName === 'table'
3528                        && $ancestor->namespaceURI === Document::HTML_NS
3529                    ) {
3530                        $this->insertionMode = InsertionMode::InSelectInTable;
3531                        return;
3532                    }
3533                }
3534                $this->insertionMode = InsertionMode::InSelect;
3535                return;
3536            }
3537            if (in_array($name, ['td', 'th'], true) && $i !== 0) {
3538                $this->insertionMode = InsertionMode::InCell;
3539                return;
3540            }
3541            if ($name === 'tr') {
3542                $this->insertionMode = InsertionMode::InRow;
3543                return;
3544            }
3545            if (in_array($name, ['tbody', 'thead', 'tfoot'], true)) {
3546                $this->insertionMode = InsertionMode::InTableBody;
3547                return;
3548            }
3549            if ($name === 'caption') {
3550                $this->insertionMode = InsertionMode::InCaption;
3551                return;
3552            }
3553            if ($name === 'colgroup') {
3554                $this->insertionMode = InsertionMode::InColumnGroup;
3555                return;
3556            }
3557            if ($name === 'table') {
3558                $this->insertionMode = InsertionMode::InTable;
3559                return;
3560            }
3561            if ($name === 'template') {
3562                $top = $this->templateInsertionModes[count($this->templateInsertionModes) - 1] ?? null;
3563                $this->insertionMode = $top ?? InsertionMode::InTemplate;
3564                return;
3565            }
3566            if ($name === 'head' && $i !== 0) {
3567                $this->insertionMode = InsertionMode::InHead;
3568                return;
3569            }
3570            if ($name === 'body') {
3571                $this->insertionMode = InsertionMode::InBody;
3572                return;
3573            }
3574            if ($name === 'frameset') {
3575                $this->insertionMode = InsertionMode::InFrameset;
3576                return;
3577            }
3578            if ($name === 'html') {
3579                $this->insertionMode = $this->headElement === null
3580                    ? InsertionMode::BeforeHead
3581                    : InsertionMode::AfterHead;
3582                return;
3583            }
3584        }
3585        $this->insertionMode = InsertionMode::InBody;
3586    }
3587
3588    // ============================================================
3589    // Helpers
3590    // ============================================================
3591    private function isWhitespaceOnlyCharacter(Token $token): bool
3592    {
3593        if (!$token instanceof CharacterToken) {
3594            return false;
3595        }
3596        return preg_match('/^[\t\n\f\r ]+$/', $token->data) === 1;
3597    }
3598
3599    private function reprocess(Token $token): void
3600    {
3601        // Reprocess via the same active tokenizer so any state mutation
3602        // (RCDATA/RAWTEXT/ScriptData switches in InHead) takes effect on the
3603        // real stream rather than a throwaway instance.
3604        if ($this->activeTokenizer === null) {
3605            throw new \LogicException('reprocess called outside build()');
3606        }
3607        $this->dispatch($token, $this->activeTokenizer);
3608    }
3609
3610    /**
3611     * Resolve the `shadowrootmode` attribute on a `<template>` start tag into
3612     * a typed mode, or null if absent / invalid. The spec says only "open" and
3613     * "closed" are accepted; any other value is a missing-value state.
3614     */
3615    private function resolveShadowRootMode(StartTagToken $token): ?ShadowRootMode
3616    {
3617        foreach ($token->attributes as $attr) {
3618            if ($attr['name'] === 'shadowrootmode') {
3619                return match (strtolower($attr['value'])) {
3620                    'open' => ShadowRootMode::Open,
3621                    'closed' => ShadowRootMode::Closed,
3622                    default => null,
3623                };
3624            }
3625        }
3626        return null;
3627    }
3628
3629    private function tokenHasAttribute(StartTagToken $token, string $name): bool
3630    {
3631        foreach ($token->attributes as $attr) {
3632            if ($attr['name'] === $name) {
3633                return true;
3634            }
3635        }
3636        return false;
3637    }
3638
3639}