Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.90% covered (warning)
86.90%
1187 / 1366
62.50% covered (warning)
62.50%
70 / 112
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tokenizer
86.90% covered (warning)
86.90%
1187 / 1366
62.50% covered (warning)
62.50%
70 / 112
1365.97
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
3
 tokenize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 nextToken
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 step
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
1 / 1
81
 preprocess
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 consume
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 reconsumeIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 peekRemaining
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 advance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 emit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 emitChar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 currentTokenAsEnd
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 currentTokenAsTag
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 currentTokenAsComment
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 currentTokenAsDoctype
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 startNewAttribute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 appendToCurrentAttributeName
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 appendToCurrentAttributeValue
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 dedupAttributes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 isAppropriateEndTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 stateData
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 stateRcdata
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
5.16
 stateRawtext
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 stateScriptData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 stateScriptDataLessThanSign
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 stateScriptDataEndTagOpen
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 stateScriptDataEndTagName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateScriptDataEscapeStart
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 stateScriptDataEscapeStartDash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 stateScriptDataEscaped
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 stateScriptDataEscapedDash
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 stateScriptDataEscapedDashDash
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 stateScriptDataEscapedLessThanSign
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 stateScriptDataEscapedEndTagOpen
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 stateScriptDataEscapedEndTagName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateScriptDataDoubleEscapeStart
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
 stateScriptDataDoubleEscaped
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 stateScriptDataDoubleEscapedDash
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 stateScriptDataDoubleEscapedDashDash
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 stateScriptDataDoubleEscapedLessThanSign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 stateScriptDataDoubleEscapeEnd
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
 statePlaintext
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 stateTagOpen
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 stateEndTagOpen
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 stateTagName
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
11.24
 stateRcdataLessThanSign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 stateRcdataEndTagOpen
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 stateRcdataEndTagName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateRawtextLessThanSign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 stateRawtextEndTagOpen
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 stateRawtextEndTagName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 endTagNameAlternativeReturn
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
14
 emitFakeOpeningChars
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 stateBeforeAttributeName
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 stateAttributeName
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
14.77
 stateAfterAttributeName
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 stateBeforeAttributeValue
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
10.37
 stateAttributeValueDoubleQuoted
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
5.14
 stateAttributeValueSingleQuoted
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
6.10
 stateAttributeValueUnquoted
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
17.48
 stateAfterAttributeValueQuoted
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
10.81
 stateSelfClosingStartTag
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 stateBogusComment
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 stateMarkupDeclarationOpen
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 stateCommentStart
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 stateCommentStartDash
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
4.25
 stateComment
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 stateCommentLessThanSign
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 stateCommentLessThanSignBang
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 stateCommentLessThanSignBangDash
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 stateCommentLessThanSignBangDashDash
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 stateCdataSection
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 stateCdataSectionBracket
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 stateCdataSectionEnd
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 stateCommentEndDash
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
3.43
 stateCommentEnd
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 stateCommentEndBang
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
4.94
 stateDoctype
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
9.58
 stateBeforeDoctypeName
67.86% covered (warning)
67.86%
19 / 28
0.00% covered (danger)
0.00%
0 / 1
13.32
 stateDoctypeName
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
15.27
 stateAfterDoctypeName
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
10.51
 stateAfterDoctypePublicKeyword
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
9.37
 stateBeforeDoctypePublicIdentifier
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
9.51
 stateDoctypePublicIdentifierDoubleQuoted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateDoctypePublicIdentifierSingleQuoted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateAfterDoctypePublicIdentifier
82.14% covered (warning)
82.14%
23 / 28
0.00% covered (danger)
0.00%
0 / 1
9.46
 stateBetweenDoctypePublicAndSystemIdentifiers
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
11.65
 stateAfterDoctypeSystemKeyword
56.67% covered (warning)
56.67%
17 / 30
0.00% covered (danger)
0.00%
0 / 1
15.59
 stateBeforeDoctypeSystemIdentifier
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
9.51
 stateDoctypeSystemIdentifierDoubleQuoted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateDoctypeSystemIdentifierSingleQuoted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stateAfterDoctypeSystemIdentifier
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
15.72
 doctypeQuotedIdentifier
62.07% covered (warning)
62.07%
18 / 29
0.00% covered (danger)
0.00%
0 / 1
11.49
 stateBogusDoctype
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 stateCharacterReference
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 stateNamedCharacterReference
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
15
 stateAmbiguousAmpersand
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 stateNumericCharacterReference
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 stateHexadecimalCharacterReferenceStart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 stateDecimalCharacterReferenceStart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 stateHexadecimalCharacterReference
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
8.51
 stateDecimalCharacterReference
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 stateNumericCharacterReferenceEnd
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 flushTempBufferToCharOrAttribute
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 finalizeAndEmitTag
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isAsciiAlpha
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 isAsciiUpperAlpha
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isAsciiLowerAlpha
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isAsciiAlphanumeric
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isAsciiHexDigit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html\Tokenizer;
6
7/**
8 * WHATWG HTML Â§13.2.5 tokenizer.
9 *
10 * Phase 1B.2 + 1B.2-bis: all ~80 spec states implemented. Covers DOCTYPE
11 * (including PUBLIC/SYSTEM identifiers), tags with every attribute form,
12 * script-data with full escape/double-escape recovery, comments (including
13 * nested-comment recovery), CDATA sections (entered when {@see self::$inForeignContent}
14 * is true), and character references (numeric + named).
15 *
16 * Named character reference table (see {@see NamedCharacterReferences}) ships
17 * the high-frequency subset of the spec's ~2200 entries. Generation of the
18 * full table from the spec's `entities.json` is a separate deliverable
19 * tracked in the rendering roadmap.
20 *
21 * Input preprocessing per WHATWG Â§13.2.3.5: CR/CRLF normalised to LF before
22 * tokenizing. NULL handling is per-state (some emit U+FFFD, some emit raw,
23 * all with parse-error tracking).
24 */
25final class Tokenizer
26{
27    public TokenizerState $state = TokenizerState::Data;
28    public ?string $lastStartTagName = null; // for appropriate-end-tag check in RCDATA/RAWTEXT
29
30    /**
31     * Set to true by tree construction when the "adjusted current node" is
32     * not in the HTML namespace (e.g. inside SVG or MathML). Affects the
33     * MarkupDeclarationOpen state's handling of `[CDATA[`: in foreign content
34     * we enter the CdataSection state; in HTML content it's a bogus comment.
35     */
36    public bool $inForeignContent = false;
37
38    /** @var list<string> input as an array of UTF-8 single-codepoint strings */
39    private array $chars;
40    private int $length;
41    private int $pos = 0;
42    private bool $reconsume = false;
43    private string $currentChar = '';
44
45    private ?Token $currentToken = null;
46    private TokenizerState $returnState = TokenizerState::Data;
47    private string $tempBuffer = '';
48    private int $characterReferenceCode = 0;
49
50    /** @var list<Token> */
51    private array $emitted = [];
52    private int $emittedCursor = 0;
53    /** @var list<ParseError> */
54    private array $errors = [];
55    private bool $done = false;
56
57    public function __construct(string $input)
58    {
59        $normalised = $this->preprocess($input);
60        $this->chars = $normalised === '' ? [] : (mb_str_split($normalised, 1, 'UTF-8') ?: []);
61        $this->length = count($this->chars);
62    }
63
64    /**
65     * Run the state machine to completion and return all tokens emitted, in
66     * order, ending with an EofToken. Convenience for callers that don't need
67     * mid-stream state interaction; tree construction uses {@see self::nextToken()}.
68     *
69     * @return list<Token>
70     */
71    public function tokenize(): array
72    {
73        while (!$this->done) {
74            $this->step();
75        }
76        return $this->emitted;
77    }
78
79    /**
80     * Pull the next token, advancing the state machine until at least one
81     * token is emitted (or EOF). Tree construction drives this iteratively so
82     * it can mutate {@see self::$state} (e.g. switching to RCDATA when
83     * encountering `<title>`) between tokens.
84     */
85    public function nextToken(): ?Token
86    {
87        while ($this->emittedCursor >= count($this->emitted) && !$this->done) {
88            $this->step();
89        }
90        if ($this->emittedCursor < count($this->emitted)) {
91            return $this->emitted[$this->emittedCursor++];
92        }
93        return null;
94    }
95
96    /** @return list<ParseError> */
97    public function errors(): array
98    {
99        return $this->errors;
100    }
101
102    private function step(): void
103    {
104        match ($this->state) {
105            TokenizerState::Data => $this->stateData(),
106            TokenizerState::Rcdata => $this->stateRcdata(),
107            TokenizerState::Rawtext => $this->stateRawtext(),
108            TokenizerState::ScriptData => $this->stateScriptData(),
109            TokenizerState::Plaintext => $this->statePlaintext(),
110            TokenizerState::TagOpen => $this->stateTagOpen(),
111            TokenizerState::EndTagOpen => $this->stateEndTagOpen(),
112            TokenizerState::TagName => $this->stateTagName(),
113            TokenizerState::RcdataLessThanSign => $this->stateRcdataLessThanSign(),
114            TokenizerState::RcdataEndTagOpen => $this->stateRcdataEndTagOpen(),
115            TokenizerState::RcdataEndTagName => $this->stateRcdataEndTagName(),
116            TokenizerState::RawtextLessThanSign => $this->stateRawtextLessThanSign(),
117            TokenizerState::RawtextEndTagOpen => $this->stateRawtextEndTagOpen(),
118            TokenizerState::RawtextEndTagName => $this->stateRawtextEndTagName(),
119            TokenizerState::ScriptDataLessThanSign => $this->stateScriptDataLessThanSign(),
120            TokenizerState::ScriptDataEndTagOpen => $this->stateScriptDataEndTagOpen(),
121            TokenizerState::ScriptDataEndTagName => $this->stateScriptDataEndTagName(),
122            TokenizerState::ScriptDataEscapeStart => $this->stateScriptDataEscapeStart(),
123            TokenizerState::ScriptDataEscapeStartDash => $this->stateScriptDataEscapeStartDash(),
124            TokenizerState::ScriptDataEscaped => $this->stateScriptDataEscaped(),
125            TokenizerState::ScriptDataEscapedDash => $this->stateScriptDataEscapedDash(),
126            TokenizerState::ScriptDataEscapedDashDash => $this->stateScriptDataEscapedDashDash(),
127            TokenizerState::ScriptDataEscapedLessThanSign => $this->stateScriptDataEscapedLessThanSign(),
128            TokenizerState::ScriptDataEscapedEndTagOpen => $this->stateScriptDataEscapedEndTagOpen(),
129            TokenizerState::ScriptDataEscapedEndTagName => $this->stateScriptDataEscapedEndTagName(),
130            TokenizerState::ScriptDataDoubleEscapeStart => $this->stateScriptDataDoubleEscapeStart(),
131            TokenizerState::ScriptDataDoubleEscaped => $this->stateScriptDataDoubleEscaped(),
132            TokenizerState::ScriptDataDoubleEscapedDash => $this->stateScriptDataDoubleEscapedDash(),
133            TokenizerState::ScriptDataDoubleEscapedDashDash => $this->stateScriptDataDoubleEscapedDashDash(),
134            TokenizerState::ScriptDataDoubleEscapedLessThanSign => $this->stateScriptDataDoubleEscapedLessThanSign(),
135            TokenizerState::ScriptDataDoubleEscapeEnd => $this->stateScriptDataDoubleEscapeEnd(),
136            TokenizerState::BeforeAttributeName => $this->stateBeforeAttributeName(),
137            TokenizerState::AttributeName => $this->stateAttributeName(),
138            TokenizerState::AfterAttributeName => $this->stateAfterAttributeName(),
139            TokenizerState::BeforeAttributeValue => $this->stateBeforeAttributeValue(),
140            TokenizerState::AttributeValueDoubleQuoted => $this->stateAttributeValueDoubleQuoted(),
141            TokenizerState::AttributeValueSingleQuoted => $this->stateAttributeValueSingleQuoted(),
142            TokenizerState::AttributeValueUnquoted => $this->stateAttributeValueUnquoted(),
143            TokenizerState::AfterAttributeValueQuoted => $this->stateAfterAttributeValueQuoted(),
144            TokenizerState::SelfClosingStartTag => $this->stateSelfClosingStartTag(),
145            TokenizerState::BogusComment => $this->stateBogusComment(),
146            TokenizerState::MarkupDeclarationOpen => $this->stateMarkupDeclarationOpen(),
147            TokenizerState::CommentStart => $this->stateCommentStart(),
148            TokenizerState::CommentStartDash => $this->stateCommentStartDash(),
149            TokenizerState::Comment => $this->stateComment(),
150            TokenizerState::CommentLessThanSign => $this->stateCommentLessThanSign(),
151            TokenizerState::CommentLessThanSignBang => $this->stateCommentLessThanSignBang(),
152            TokenizerState::CommentLessThanSignBangDash => $this->stateCommentLessThanSignBangDash(),
153            TokenizerState::CommentLessThanSignBangDashDash => $this->stateCommentLessThanSignBangDashDash(),
154            TokenizerState::CommentEndDash => $this->stateCommentEndDash(),
155            TokenizerState::CommentEnd => $this->stateCommentEnd(),
156            TokenizerState::CommentEndBang => $this->stateCommentEndBang(),
157            TokenizerState::Doctype => $this->stateDoctype(),
158            TokenizerState::BeforeDoctypeName => $this->stateBeforeDoctypeName(),
159            TokenizerState::DoctypeName => $this->stateDoctypeName(),
160            TokenizerState::AfterDoctypeName => $this->stateAfterDoctypeName(),
161            TokenizerState::AfterDoctypePublicKeyword => $this->stateAfterDoctypePublicKeyword(),
162            TokenizerState::BeforeDoctypePublicIdentifier => $this->stateBeforeDoctypePublicIdentifier(),
163            TokenizerState::DoctypePublicIdentifierDoubleQuoted => $this->stateDoctypePublicIdentifierDoubleQuoted(),
164            TokenizerState::DoctypePublicIdentifierSingleQuoted => $this->stateDoctypePublicIdentifierSingleQuoted(),
165            TokenizerState::AfterDoctypePublicIdentifier => $this->stateAfterDoctypePublicIdentifier(),
166            TokenizerState::BetweenDoctypePublicAndSystemIdentifiers => $this->stateBetweenDoctypePublicAndSystemIdentifiers(),
167            TokenizerState::AfterDoctypeSystemKeyword => $this->stateAfterDoctypeSystemKeyword(),
168            TokenizerState::BeforeDoctypeSystemIdentifier => $this->stateBeforeDoctypeSystemIdentifier(),
169            TokenizerState::DoctypeSystemIdentifierDoubleQuoted => $this->stateDoctypeSystemIdentifierDoubleQuoted(),
170            TokenizerState::DoctypeSystemIdentifierSingleQuoted => $this->stateDoctypeSystemIdentifierSingleQuoted(),
171            TokenizerState::AfterDoctypeSystemIdentifier => $this->stateAfterDoctypeSystemIdentifier(),
172            TokenizerState::BogusDoctype => $this->stateBogusDoctype(),
173            TokenizerState::CdataSection => $this->stateCdataSection(),
174            TokenizerState::CdataSectionBracket => $this->stateCdataSectionBracket(),
175            TokenizerState::CdataSectionEnd => $this->stateCdataSectionEnd(),
176            TokenizerState::CharacterReference => $this->stateCharacterReference(),
177            TokenizerState::NamedCharacterReference => $this->stateNamedCharacterReference(),
178            TokenizerState::AmbiguousAmpersand => $this->stateAmbiguousAmpersand(),
179            TokenizerState::NumericCharacterReference => $this->stateNumericCharacterReference(),
180            TokenizerState::HexadecimalCharacterReferenceStart => $this->stateHexadecimalCharacterReferenceStart(),
181            TokenizerState::DecimalCharacterReferenceStart => $this->stateDecimalCharacterReferenceStart(),
182            TokenizerState::HexadecimalCharacterReference => $this->stateHexadecimalCharacterReference(),
183            TokenizerState::DecimalCharacterReference => $this->stateDecimalCharacterReference(),
184            TokenizerState::NumericCharacterReferenceEnd => $this->stateNumericCharacterReferenceEnd(),
185        };
186    }
187
188    // ============================================================
189    // Input / output helpers
190    // ============================================================
191
192    private function preprocess(string $input): string
193    {
194        // WHATWG Â§13.2.3.5 â€” strip a single leading U+FEFF byte order
195        // mark (UTF-8 encoded as `\xEF\xBB\xBF`) before tokenising.
196        // Without this the BOM tokenises as a text node and the first
197        // following block child is pushed down by one line height.
198        if (str_starts_with($input, "\xEF\xBB\xBF")) {
199            $input = substr($input, 3);
200        }
201        // CRLF â†’ LF, then CR â†’ LF per WHATWG Â§13.2.3.5.
202        $input = str_replace("\r\n", "\n", $input);
203        $input = str_replace("\r", "\n", $input);
204        return $input;
205    }
206
207    private function consume(): ?string
208    {
209        if ($this->reconsume) {
210            $this->reconsume = false;
211            return $this->currentChar === '' ? null : $this->currentChar;
212        }
213        if ($this->pos >= $this->length) {
214            $this->currentChar = '';
215            return null;
216        }
217        $this->currentChar = $this->chars[$this->pos++];
218        return $this->currentChar;
219    }
220
221    private function reconsumeIn(TokenizerState $next): void
222    {
223        $this->state = $next;
224        $this->reconsume = true;
225    }
226
227    private function peekRemaining(int $count): string
228    {
229        $start = $this->reconsume ? $this->pos - 1 : $this->pos;
230        if ($start >= $this->length) {
231            return '';
232        }
233        return implode('', array_slice($this->chars, $start, $count));
234    }
235
236    private function advance(int $count): void
237    {
238        // When reconsume is set, `pos` sits one past the reconsume char, so
239        // the effective "start" for the advance is pos - 1. Compute from there
240        // to keep the next consume() pointed at the correct character.
241        $effectiveStart = $this->reconsume ? $this->pos - 1 : $this->pos;
242        $this->pos = $effectiveStart + $count;
243        $this->reconsume = false;
244    }
245
246    private function emit(Token $t): void
247    {
248        if ($t instanceof StartTagToken) {
249            $this->lastStartTagName = $t->tagName;
250        }
251        $this->emitted[] = $t;
252        if ($t instanceof EofToken) {
253            $this->done = true;
254        }
255    }
256
257    private function emitChar(string $data): void
258    {
259        $this->emit(new CharacterToken($data));
260    }
261
262    private function error(ParseErrorCode $code): void
263    {
264        $this->errors[] = new ParseError($code, $this->reconsume ? $this->pos - 1 : $this->pos);
265    }
266
267    private function currentTokenAsEnd(): EndTagToken
268    {
269        assert($this->currentToken instanceof EndTagToken);
270        return $this->currentToken;
271    }
272
273    private function currentTokenAsTag(): StartTagToken|EndTagToken
274    {
275        assert($this->currentToken instanceof StartTagToken || $this->currentToken instanceof EndTagToken);
276        return $this->currentToken;
277    }
278
279    private function currentTokenAsComment(): CommentToken
280    {
281        assert($this->currentToken instanceof CommentToken);
282        return $this->currentToken;
283    }
284
285    private function currentTokenAsDoctype(): DoctypeToken
286    {
287        assert($this->currentToken instanceof DoctypeToken);
288        return $this->currentToken;
289    }
290
291    private function startNewAttribute(StartTagToken|EndTagToken $tag): void
292    {
293        $tag->attributes[] = ['name' => '', 'value' => ''];
294        $tag->currentAttribute = count($tag->attributes) - 1;
295    }
296
297    private function appendToCurrentAttributeName(StartTagToken|EndTagToken $tag, string $chars): void
298    {
299        assert($tag->currentAttribute !== null);
300        $tag->attributes[$tag->currentAttribute]['name'] .= $chars;
301    }
302
303    private function appendToCurrentAttributeValue(StartTagToken|EndTagToken $tag, string $chars): void
304    {
305        assert($tag->currentAttribute !== null);
306        $tag->attributes[$tag->currentAttribute]['value'] .= $chars;
307    }
308
309    /**
310     * Per WHATWG: after a tag's attribute list is built, drop duplicate
311     * attribute names (keep the first; emit unexpected-character-in-
312     * attribute-name parse error for subsequent duplicates). Called when the
313     * tag token is finalised.
314     */
315    private function dedupAttributes(StartTagToken|EndTagToken $tag): void
316    {
317        $seen = [];
318        $out = [];
319        foreach ($tag->attributes as $attr) {
320            if (isset($seen[$attr['name']])) {
321                $this->error(ParseErrorCode::UnexpectedCharacterInAttributeName);
322                continue;
323            }
324            $seen[$attr['name']] = true;
325            $out[] = $attr;
326        }
327        $tag->attributes = $out;
328    }
329
330    private function isAppropriateEndTag(EndTagToken $tag): bool
331    {
332        return $this->lastStartTagName !== null && $tag->tagName === $this->lastStartTagName;
333    }
334
335    // ============================================================
336    // 13.2.5.1 Data state
337    // ============================================================
338    private function stateData(): void
339    {
340        $c = $this->consume();
341        if ($c === '&') {
342            $this->returnState = TokenizerState::Data;
343            $this->state = TokenizerState::CharacterReference;
344            return;
345        }
346        if ($c === '<') {
347            $this->state = TokenizerState::TagOpen;
348            return;
349        }
350        if ($c === null) {
351            $this->emit(new EofToken());
352            return;
353        }
354        if ($c === "\u{0000}") {
355            $this->error(ParseErrorCode::UnexpectedNullCharacter);
356        }
357        $this->emitChar($c);
358    }
359
360    // ============================================================
361    // 13.2.5.2 RCDATA state
362    // ============================================================
363    private function stateRcdata(): void
364    {
365        $c = $this->consume();
366        if ($c === '&') {
367            $this->returnState = TokenizerState::Rcdata;
368            $this->state = TokenizerState::CharacterReference;
369            return;
370        }
371        if ($c === '<') {
372            $this->state = TokenizerState::RcdataLessThanSign;
373            return;
374        }
375        if ($c === null) {
376            $this->emit(new EofToken());
377            return;
378        }
379        if ($c === "\u{0000}") {
380            $this->error(ParseErrorCode::UnexpectedNullCharacter);
381            $this->emitChar("\u{FFFD}");
382            return;
383        }
384        $this->emitChar($c);
385    }
386
387    // ============================================================
388    // 13.2.5.3 RAWTEXT state
389    // ============================================================
390    private function stateRawtext(): void
391    {
392        $c = $this->consume();
393        if ($c === '<') {
394            $this->state = TokenizerState::RawtextLessThanSign;
395            return;
396        }
397        if ($c === null) {
398            $this->emit(new EofToken());
399            return;
400        }
401        if ($c === "\u{0000}") {
402            $this->error(ParseErrorCode::UnexpectedNullCharacter);
403            $this->emitChar("\u{FFFD}");
404            return;
405        }
406        $this->emitChar($c);
407    }
408
409    // ============================================================
410    // 13.2.5.4 Script data state
411    // ============================================================
412    private function stateScriptData(): void
413    {
414        $c = $this->consume();
415        if ($c === '<') {
416            $this->state = TokenizerState::ScriptDataLessThanSign;
417            return;
418        }
419        if ($c === null) {
420            $this->emit(new EofToken());
421            return;
422        }
423        if ($c === "\u{0000}") {
424            $this->error(ParseErrorCode::UnexpectedNullCharacter);
425            $this->emitChar("\u{FFFD}");
426            return;
427        }
428        $this->emitChar($c);
429    }
430
431    // ============================================================
432    // 13.2.5.15–17 Script data less-than / end-tag states
433    // ============================================================
434    private function stateScriptDataLessThanSign(): void
435    {
436        $c = $this->consume();
437        if ($c === '/') {
438            $this->tempBuffer = '';
439            $this->state = TokenizerState::ScriptDataEndTagOpen;
440            return;
441        }
442        if ($c === '!') {
443            $this->state = TokenizerState::ScriptDataEscapeStart;
444            $this->emitChar('<');
445            $this->emitChar('!');
446            return;
447        }
448        $this->emitChar('<');
449        $this->reconsumeIn(TokenizerState::ScriptData);
450    }
451
452    private function stateScriptDataEndTagOpen(): void
453    {
454        $c = $this->consume();
455        if ($c !== null && self::isAsciiAlpha($c)) {
456            $this->currentToken = new EndTagToken();
457            $this->reconsumeIn(TokenizerState::ScriptDataEndTagName);
458            return;
459        }
460        $this->emitChar('<');
461        $this->emitChar('/');
462        $this->reconsumeIn(TokenizerState::ScriptData);
463    }
464
465    private function stateScriptDataEndTagName(): void
466    {
467        $this->endTagNameAlternativeReturn(TokenizerState::ScriptData);
468    }
469
470    // ============================================================
471    // 13.2.5.18–19 Script data escape start states
472    // ============================================================
473    private function stateScriptDataEscapeStart(): void
474    {
475        $c = $this->consume();
476        if ($c === '-') {
477            $this->state = TokenizerState::ScriptDataEscapeStartDash;
478            $this->emitChar('-');
479            return;
480        }
481        $this->reconsumeIn(TokenizerState::ScriptData);
482    }
483
484    private function stateScriptDataEscapeStartDash(): void
485    {
486        $c = $this->consume();
487        if ($c === '-') {
488            $this->state = TokenizerState::ScriptDataEscapedDashDash;
489            $this->emitChar('-');
490            return;
491        }
492        $this->reconsumeIn(TokenizerState::ScriptData);
493    }
494
495    // ============================================================
496    // 13.2.5.20–22 Script data escaped states
497    // ============================================================
498    private function stateScriptDataEscaped(): void
499    {
500        $c = $this->consume();
501        if ($c === '-') {
502            $this->state = TokenizerState::ScriptDataEscapedDash;
503            $this->emitChar('-');
504            return;
505        }
506        if ($c === '<') {
507            $this->state = TokenizerState::ScriptDataEscapedLessThanSign;
508            return;
509        }
510        if ($c === "\u{0000}") {
511            $this->error(ParseErrorCode::UnexpectedNullCharacter);
512            $this->emitChar("\u{FFFD}");
513            return;
514        }
515        if ($c === null) {
516            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
517            $this->emit(new EofToken());
518            return;
519        }
520        $this->emitChar($c);
521    }
522
523    private function stateScriptDataEscapedDash(): void
524    {
525        $c = $this->consume();
526        if ($c === '-') {
527            $this->state = TokenizerState::ScriptDataEscapedDashDash;
528            $this->emitChar('-');
529            return;
530        }
531        if ($c === '<') {
532            $this->state = TokenizerState::ScriptDataEscapedLessThanSign;
533            return;
534        }
535        if ($c === "\u{0000}") {
536            $this->error(ParseErrorCode::UnexpectedNullCharacter);
537            $this->state = TokenizerState::ScriptDataEscaped;
538            $this->emitChar("\u{FFFD}");
539            return;
540        }
541        if ($c === null) {
542            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
543            $this->emit(new EofToken());
544            return;
545        }
546        $this->state = TokenizerState::ScriptDataEscaped;
547        $this->emitChar($c);
548    }
549
550    private function stateScriptDataEscapedDashDash(): void
551    {
552        $c = $this->consume();
553        if ($c === '-') {
554            $this->emitChar('-');
555            return;
556        }
557        if ($c === '<') {
558            $this->state = TokenizerState::ScriptDataEscapedLessThanSign;
559            return;
560        }
561        if ($c === '>') {
562            $this->state = TokenizerState::ScriptData;
563            $this->emitChar('>');
564            return;
565        }
566        if ($c === "\u{0000}") {
567            $this->error(ParseErrorCode::UnexpectedNullCharacter);
568            $this->state = TokenizerState::ScriptDataEscaped;
569            $this->emitChar("\u{FFFD}");
570            return;
571        }
572        if ($c === null) {
573            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
574            $this->emit(new EofToken());
575            return;
576        }
577        $this->state = TokenizerState::ScriptDataEscaped;
578        $this->emitChar($c);
579    }
580
581    // ============================================================
582    // 13.2.5.23–25 Script data escaped less-than / end-tag states
583    // ============================================================
584    private function stateScriptDataEscapedLessThanSign(): void
585    {
586        $c = $this->consume();
587        if ($c === '/') {
588            $this->tempBuffer = '';
589            $this->state = TokenizerState::ScriptDataEscapedEndTagOpen;
590            return;
591        }
592        if ($c !== null && self::isAsciiAlpha($c)) {
593            $this->tempBuffer = '';
594            $this->emitChar('<');
595            $this->reconsumeIn(TokenizerState::ScriptDataDoubleEscapeStart);
596            return;
597        }
598        $this->emitChar('<');
599        $this->reconsumeIn(TokenizerState::ScriptDataEscaped);
600    }
601
602    private function stateScriptDataEscapedEndTagOpen(): void
603    {
604        $c = $this->consume();
605        if ($c !== null && self::isAsciiAlpha($c)) {
606            $this->currentToken = new EndTagToken();
607            $this->reconsumeIn(TokenizerState::ScriptDataEscapedEndTagName);
608            return;
609        }
610        $this->emitChar('<');
611        $this->emitChar('/');
612        $this->reconsumeIn(TokenizerState::ScriptDataEscaped);
613    }
614
615    private function stateScriptDataEscapedEndTagName(): void
616    {
617        $this->endTagNameAlternativeReturn(TokenizerState::ScriptDataEscaped);
618    }
619
620    // ============================================================
621    // 13.2.5.26–31 Script data double escape states
622    // ============================================================
623    private function stateScriptDataDoubleEscapeStart(): void
624    {
625        $c = $this->consume();
626        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ' || $c === '/' || $c === '>') {
627            $this->state = $this->tempBuffer === 'script'
628                ? TokenizerState::ScriptDataDoubleEscaped
629                : TokenizerState::ScriptDataEscaped;
630            $this->emitChar($c);
631            return;
632        }
633        if ($c !== null && self::isAsciiUpperAlpha($c)) {
634            $this->tempBuffer .= strtolower($c);
635            $this->emitChar($c);
636            return;
637        }
638        if ($c !== null && self::isAsciiLowerAlpha($c)) {
639            $this->tempBuffer .= $c;
640            $this->emitChar($c);
641            return;
642        }
643        $this->reconsumeIn(TokenizerState::ScriptDataEscaped);
644    }
645
646    private function stateScriptDataDoubleEscaped(): void
647    {
648        $c = $this->consume();
649        if ($c === '-') {
650            $this->state = TokenizerState::ScriptDataDoubleEscapedDash;
651            $this->emitChar('-');
652            return;
653        }
654        if ($c === '<') {
655            $this->state = TokenizerState::ScriptDataDoubleEscapedLessThanSign;
656            $this->emitChar('<');
657            return;
658        }
659        if ($c === "\u{0000}") {
660            $this->error(ParseErrorCode::UnexpectedNullCharacter);
661            $this->emitChar("\u{FFFD}");
662            return;
663        }
664        if ($c === null) {
665            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
666            $this->emit(new EofToken());
667            return;
668        }
669        $this->emitChar($c);
670    }
671
672    private function stateScriptDataDoubleEscapedDash(): void
673    {
674        $c = $this->consume();
675        if ($c === '-') {
676            $this->state = TokenizerState::ScriptDataDoubleEscapedDashDash;
677            $this->emitChar('-');
678            return;
679        }
680        if ($c === '<') {
681            $this->state = TokenizerState::ScriptDataDoubleEscapedLessThanSign;
682            $this->emitChar('<');
683            return;
684        }
685        if ($c === "\u{0000}") {
686            $this->error(ParseErrorCode::UnexpectedNullCharacter);
687            $this->state = TokenizerState::ScriptDataDoubleEscaped;
688            $this->emitChar("\u{FFFD}");
689            return;
690        }
691        if ($c === null) {
692            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
693            $this->emit(new EofToken());
694            return;
695        }
696        $this->state = TokenizerState::ScriptDataDoubleEscaped;
697        $this->emitChar($c);
698    }
699
700    private function stateScriptDataDoubleEscapedDashDash(): void
701    {
702        $c = $this->consume();
703        if ($c === '-') {
704            $this->emitChar('-');
705            return;
706        }
707        if ($c === '<') {
708            $this->state = TokenizerState::ScriptDataDoubleEscapedLessThanSign;
709            $this->emitChar('<');
710            return;
711        }
712        if ($c === '>') {
713            $this->state = TokenizerState::ScriptData;
714            $this->emitChar('>');
715            return;
716        }
717        if ($c === "\u{0000}") {
718            $this->error(ParseErrorCode::UnexpectedNullCharacter);
719            $this->state = TokenizerState::ScriptDataDoubleEscaped;
720            $this->emitChar("\u{FFFD}");
721            return;
722        }
723        if ($c === null) {
724            $this->error(ParseErrorCode::EofInScriptHtmlCommentLikeText);
725            $this->emit(new EofToken());
726            return;
727        }
728        $this->state = TokenizerState::ScriptDataDoubleEscaped;
729        $this->emitChar($c);
730    }
731
732    private function stateScriptDataDoubleEscapedLessThanSign(): void
733    {
734        $c = $this->consume();
735        if ($c === '/') {
736            $this->tempBuffer = '';
737            $this->state = TokenizerState::ScriptDataDoubleEscapeEnd;
738            $this->emitChar('/');
739            return;
740        }
741        $this->reconsumeIn(TokenizerState::ScriptDataDoubleEscaped);
742    }
743
744    private function stateScriptDataDoubleEscapeEnd(): void
745    {
746        $c = $this->consume();
747        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ' || $c === '/' || $c === '>') {
748            $this->state = $this->tempBuffer === 'script'
749                ? TokenizerState::ScriptDataEscaped
750                : TokenizerState::ScriptDataDoubleEscaped;
751            $this->emitChar($c);
752            return;
753        }
754        if ($c !== null && self::isAsciiUpperAlpha($c)) {
755            $this->tempBuffer .= strtolower($c);
756            $this->emitChar($c);
757            return;
758        }
759        if ($c !== null && self::isAsciiLowerAlpha($c)) {
760            $this->tempBuffer .= $c;
761            $this->emitChar($c);
762            return;
763        }
764        $this->reconsumeIn(TokenizerState::ScriptDataDoubleEscaped);
765    }
766
767    // ============================================================
768    // 13.2.5.5 PLAINTEXT state
769    // ============================================================
770    private function statePlaintext(): void
771    {
772        $c = $this->consume();
773        if ($c === null) {
774            $this->emit(new EofToken());
775            return;
776        }
777        if ($c === "\u{0000}") {
778            $this->error(ParseErrorCode::UnexpectedNullCharacter);
779            $this->emitChar("\u{FFFD}");
780            return;
781        }
782        $this->emitChar($c);
783    }
784
785    // ============================================================
786    // 13.2.5.6 Tag open state
787    // ============================================================
788    private function stateTagOpen(): void
789    {
790        $c = $this->consume();
791        if ($c === '!') {
792            $this->state = TokenizerState::MarkupDeclarationOpen;
793            return;
794        }
795        if ($c === '/') {
796            $this->state = TokenizerState::EndTagOpen;
797            return;
798        }
799        if ($c !== null && self::isAsciiAlpha($c)) {
800            $this->currentToken = new StartTagToken();
801            $this->reconsumeIn(TokenizerState::TagName);
802            return;
803        }
804        if ($c === '?') {
805            $this->error(ParseErrorCode::UnexpectedQuestionMarkInsteadOfTagName);
806            $this->currentToken = new CommentToken();
807            $this->reconsumeIn(TokenizerState::BogusComment);
808            return;
809        }
810        if ($c === null) {
811            $this->error(ParseErrorCode::EofBeforeTagName);
812            $this->emitChar('<');
813            $this->emit(new EofToken());
814            return;
815        }
816        $this->error(ParseErrorCode::InvalidFirstCharacterOfTagName);
817        $this->emitChar('<');
818        $this->reconsumeIn(TokenizerState::Data);
819    }
820
821    // ============================================================
822    // 13.2.5.7 End tag open state
823    // ============================================================
824    private function stateEndTagOpen(): void
825    {
826        $c = $this->consume();
827        if ($c !== null && self::isAsciiAlpha($c)) {
828            $this->currentToken = new EndTagToken();
829            $this->reconsumeIn(TokenizerState::TagName);
830            return;
831        }
832        if ($c === '>') {
833            $this->error(ParseErrorCode::MissingEndTagName);
834            $this->state = TokenizerState::Data;
835            return;
836        }
837        if ($c === null) {
838            $this->error(ParseErrorCode::EofBeforeTagName);
839            $this->emitChar('<');
840            $this->emitChar('/');
841            $this->emit(new EofToken());
842            return;
843        }
844        $this->error(ParseErrorCode::InvalidFirstCharacterOfTagName);
845        $this->currentToken = new CommentToken();
846        $this->reconsumeIn(TokenizerState::BogusComment);
847    }
848
849    // ============================================================
850    // 13.2.5.8 Tag name state
851    // ============================================================
852    private function stateTagName(): void
853    {
854        $c = $this->consume();
855        $tag = $this->currentTokenAsTag();
856        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
857            $this->state = TokenizerState::BeforeAttributeName;
858            return;
859        }
860        if ($c === '/') {
861            $this->state = TokenizerState::SelfClosingStartTag;
862            return;
863        }
864        if ($c === '>') {
865            $this->finalizeAndEmitTag();
866            $this->state = TokenizerState::Data;
867            return;
868        }
869        if ($c !== null && self::isAsciiUpperAlpha($c)) {
870            $tag->tagName .= strtolower($c);
871            return;
872        }
873        if ($c === "\u{0000}") {
874            $this->error(ParseErrorCode::UnexpectedNullCharacter);
875            $tag->tagName .= "\u{FFFD}";
876            return;
877        }
878        if ($c === null) {
879            $this->error(ParseErrorCode::EofInTag);
880            $this->emit(new EofToken());
881            return;
882        }
883        $tag->tagName .= $c;
884    }
885
886    // ============================================================
887    // 13.2.5.9–11 RCDATA less-than and end-tag states
888    // ============================================================
889    private function stateRcdataLessThanSign(): void
890    {
891        $c = $this->consume();
892        if ($c === '/') {
893            $this->tempBuffer = '';
894            $this->state = TokenizerState::RcdataEndTagOpen;
895            return;
896        }
897        $this->emitChar('<');
898        $this->reconsumeIn(TokenizerState::Rcdata);
899    }
900
901    private function stateRcdataEndTagOpen(): void
902    {
903        $c = $this->consume();
904        if ($c !== null && self::isAsciiAlpha($c)) {
905            $this->currentToken = new EndTagToken();
906            $this->reconsumeIn(TokenizerState::RcdataEndTagName);
907            return;
908        }
909        $this->emitChar('<');
910        $this->emitChar('/');
911        $this->reconsumeIn(TokenizerState::Rcdata);
912    }
913
914    private function stateRcdataEndTagName(): void
915    {
916        $this->endTagNameAlternativeReturn(TokenizerState::Rcdata);
917    }
918
919    // ============================================================
920    // 13.2.5.12–14 RAWTEXT less-than and end-tag states
921    // ============================================================
922    private function stateRawtextLessThanSign(): void
923    {
924        $c = $this->consume();
925        if ($c === '/') {
926            $this->tempBuffer = '';
927            $this->state = TokenizerState::RawtextEndTagOpen;
928            return;
929        }
930        $this->emitChar('<');
931        $this->reconsumeIn(TokenizerState::Rawtext);
932    }
933
934    private function stateRawtextEndTagOpen(): void
935    {
936        $c = $this->consume();
937        if ($c !== null && self::isAsciiAlpha($c)) {
938            $this->currentToken = new EndTagToken();
939            $this->reconsumeIn(TokenizerState::RawtextEndTagName);
940            return;
941        }
942        $this->emitChar('<');
943        $this->emitChar('/');
944        $this->reconsumeIn(TokenizerState::Rawtext);
945    }
946
947    private function stateRawtextEndTagName(): void
948    {
949        $this->endTagNameAlternativeReturn(TokenizerState::Rawtext);
950    }
951
952    /**
953     * Shared logic for RCDATA / RAWTEXT / Script end-tag-name states. If the
954     * end tag matches the most recent start tag (the "appropriate end tag"),
955     * transition like a normal tag close; otherwise emit characters and
956     * return to the source state.
957     */
958    private function endTagNameAlternativeReturn(TokenizerState $sourceState): void
959    {
960        $c = $this->consume();
961        $tag = $this->currentTokenAsEnd();
962        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
963            if ($this->isAppropriateEndTag($tag)) {
964                $this->state = TokenizerState::BeforeAttributeName;
965                return;
966            }
967            $this->emitFakeOpeningChars($sourceState);
968            return;
969        }
970        if ($c === '/') {
971            if ($this->isAppropriateEndTag($tag)) {
972                $this->state = TokenizerState::SelfClosingStartTag;
973                return;
974            }
975            $this->emitFakeOpeningChars($sourceState);
976            return;
977        }
978        if ($c === '>') {
979            if ($this->isAppropriateEndTag($tag)) {
980                $this->finalizeAndEmitTag();
981                $this->state = TokenizerState::Data;
982                return;
983            }
984            $this->emitFakeOpeningChars($sourceState);
985            return;
986        }
987        if ($c !== null && self::isAsciiUpperAlpha($c)) {
988            $tag->tagName .= strtolower($c);
989            $this->tempBuffer .= $c;
990            return;
991        }
992        if ($c !== null && self::isAsciiLowerAlpha($c)) {
993            $tag->tagName .= $c;
994            $this->tempBuffer .= $c;
995            return;
996        }
997        $this->emitFakeOpeningChars($sourceState);
998    }
999
1000    private function emitFakeOpeningChars(TokenizerState $sourceState): void
1001    {
1002        $this->emitChar('<');
1003        $this->emitChar('/');
1004        if ($this->tempBuffer !== '') {
1005            $this->emitChar($this->tempBuffer);
1006        }
1007        $this->reconsumeIn($sourceState);
1008    }
1009
1010    // ============================================================
1011    // 13.2.5.32 Before attribute name state
1012    // ============================================================
1013    private function stateBeforeAttributeName(): void
1014    {
1015        $c = $this->consume();
1016        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1017            return;
1018        }
1019        if ($c === '/' || $c === '>' || $c === null) {
1020            $this->reconsumeIn(TokenizerState::AfterAttributeName);
1021            return;
1022        }
1023        if ($c === '=') {
1024            $this->error(ParseErrorCode::UnexpectedEqualsSignBeforeAttributeName);
1025            $tag = $this->currentTokenAsTag();
1026            $this->startNewAttribute($tag);
1027            $this->appendToCurrentAttributeName($tag, '=');
1028            $this->state = TokenizerState::AttributeName;
1029            return;
1030        }
1031        $tag = $this->currentTokenAsTag();
1032        $this->startNewAttribute($tag);
1033        $this->reconsumeIn(TokenizerState::AttributeName);
1034    }
1035
1036    // ============================================================
1037    // 13.2.5.33 Attribute name state
1038    // ============================================================
1039    private function stateAttributeName(): void
1040    {
1041        $c = $this->consume();
1042        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' '
1043            || $c === '/' || $c === '>' || $c === null
1044        ) {
1045            $this->reconsumeIn(TokenizerState::AfterAttributeName);
1046            return;
1047        }
1048        if ($c === '=') {
1049            $this->state = TokenizerState::BeforeAttributeValue;
1050            return;
1051        }
1052        $tag = $this->currentTokenAsTag();
1053        if (self::isAsciiUpperAlpha($c)) {
1054            $this->appendToCurrentAttributeName($tag, strtolower($c));
1055            return;
1056        }
1057        if ($c === "\u{0000}") {
1058            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1059            $this->appendToCurrentAttributeName($tag, "\u{FFFD}");
1060            return;
1061        }
1062        if ($c === '"' || $c === "'" || $c === '<') {
1063            $this->error(ParseErrorCode::UnexpectedCharacterInAttributeName);
1064        }
1065        $this->appendToCurrentAttributeName($tag, $c);
1066    }
1067
1068    // ============================================================
1069    // 13.2.5.34 After attribute name state
1070    // ============================================================
1071    private function stateAfterAttributeName(): void
1072    {
1073        $c = $this->consume();
1074        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1075            return;
1076        }
1077        if ($c === '/') {
1078            $this->state = TokenizerState::SelfClosingStartTag;
1079            return;
1080        }
1081        if ($c === '=') {
1082            $this->state = TokenizerState::BeforeAttributeValue;
1083            return;
1084        }
1085        if ($c === '>') {
1086            $this->finalizeAndEmitTag();
1087            $this->state = TokenizerState::Data;
1088            return;
1089        }
1090        if ($c === null) {
1091            $this->error(ParseErrorCode::EofInTag);
1092            $this->emit(new EofToken());
1093            return;
1094        }
1095        $tag = $this->currentTokenAsTag();
1096        $this->startNewAttribute($tag);
1097        $this->reconsumeIn(TokenizerState::AttributeName);
1098    }
1099
1100    // ============================================================
1101    // 13.2.5.35 Before attribute value state
1102    // ============================================================
1103    private function stateBeforeAttributeValue(): void
1104    {
1105        $c = $this->consume();
1106        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1107            return;
1108        }
1109        if ($c === '"') {
1110            $this->state = TokenizerState::AttributeValueDoubleQuoted;
1111            return;
1112        }
1113        if ($c === "'") {
1114            $this->state = TokenizerState::AttributeValueSingleQuoted;
1115            return;
1116        }
1117        if ($c === '>') {
1118            $this->error(ParseErrorCode::MissingAttributeValue);
1119            $this->finalizeAndEmitTag();
1120            $this->state = TokenizerState::Data;
1121            return;
1122        }
1123        $this->reconsumeIn(TokenizerState::AttributeValueUnquoted);
1124    }
1125
1126    // ============================================================
1127    // 13.2.5.36 Attribute value (double-quoted) state
1128    // ============================================================
1129    private function stateAttributeValueDoubleQuoted(): void
1130    {
1131        $c = $this->consume();
1132        if ($c === '"') {
1133            $this->state = TokenizerState::AfterAttributeValueQuoted;
1134            return;
1135        }
1136        if ($c === '&') {
1137            $this->returnState = TokenizerState::AttributeValueDoubleQuoted;
1138            $this->state = TokenizerState::CharacterReference;
1139            return;
1140        }
1141        if ($c === "\u{0000}") {
1142            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1143            $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), "\u{FFFD}");
1144            return;
1145        }
1146        if ($c === null) {
1147            $this->error(ParseErrorCode::EofInTag);
1148            $this->emit(new EofToken());
1149            return;
1150        }
1151        $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), $c);
1152    }
1153
1154    // ============================================================
1155    // 13.2.5.37 Attribute value (single-quoted) state
1156    // ============================================================
1157    private function stateAttributeValueSingleQuoted(): void
1158    {
1159        $c = $this->consume();
1160        if ($c === "'") {
1161            $this->state = TokenizerState::AfterAttributeValueQuoted;
1162            return;
1163        }
1164        if ($c === '&') {
1165            $this->returnState = TokenizerState::AttributeValueSingleQuoted;
1166            $this->state = TokenizerState::CharacterReference;
1167            return;
1168        }
1169        if ($c === "\u{0000}") {
1170            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1171            $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), "\u{FFFD}");
1172            return;
1173        }
1174        if ($c === null) {
1175            $this->error(ParseErrorCode::EofInTag);
1176            $this->emit(new EofToken());
1177            return;
1178        }
1179        $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), $c);
1180    }
1181
1182    // ============================================================
1183    // 13.2.5.38 Attribute value (unquoted) state
1184    // ============================================================
1185    private function stateAttributeValueUnquoted(): void
1186    {
1187        $c = $this->consume();
1188        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1189            $this->state = TokenizerState::BeforeAttributeName;
1190            return;
1191        }
1192        if ($c === '&') {
1193            $this->returnState = TokenizerState::AttributeValueUnquoted;
1194            $this->state = TokenizerState::CharacterReference;
1195            return;
1196        }
1197        if ($c === '>') {
1198            $this->finalizeAndEmitTag();
1199            $this->state = TokenizerState::Data;
1200            return;
1201        }
1202        if ($c === "\u{0000}") {
1203            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1204            $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), "\u{FFFD}");
1205            return;
1206        }
1207        if ($c === null) {
1208            $this->error(ParseErrorCode::EofInTag);
1209            $this->emit(new EofToken());
1210            return;
1211        }
1212        if ($c === '"' || $c === "'" || $c === '<' || $c === '=' || $c === '`') {
1213            $this->error(ParseErrorCode::UnexpectedCharacterInUnquotedAttributeValue);
1214        }
1215        $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), $c);
1216    }
1217
1218    // ============================================================
1219    // 13.2.5.39 After attribute value (quoted) state
1220    // ============================================================
1221    private function stateAfterAttributeValueQuoted(): void
1222    {
1223        $c = $this->consume();
1224        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1225            $this->state = TokenizerState::BeforeAttributeName;
1226            return;
1227        }
1228        if ($c === '/') {
1229            $this->state = TokenizerState::SelfClosingStartTag;
1230            return;
1231        }
1232        if ($c === '>') {
1233            $this->finalizeAndEmitTag();
1234            $this->state = TokenizerState::Data;
1235            return;
1236        }
1237        if ($c === null) {
1238            $this->error(ParseErrorCode::EofInTag);
1239            $this->emit(new EofToken());
1240            return;
1241        }
1242        $this->error(ParseErrorCode::MissingWhitespaceBetweenAttributes);
1243        $this->reconsumeIn(TokenizerState::BeforeAttributeName);
1244    }
1245
1246    // ============================================================
1247    // 13.2.5.40 Self-closing start tag state
1248    // ============================================================
1249    private function stateSelfClosingStartTag(): void
1250    {
1251        $c = $this->consume();
1252        if ($c === '>') {
1253            $tag = $this->currentTokenAsTag();
1254            $tag->selfClosing = true;
1255            if ($tag instanceof EndTagToken) {
1256                $this->error(ParseErrorCode::EndTagWithTrailingSolidus);
1257            }
1258            $this->finalizeAndEmitTag();
1259            $this->state = TokenizerState::Data;
1260            return;
1261        }
1262        if ($c === null) {
1263            $this->error(ParseErrorCode::EofInTag);
1264            $this->emit(new EofToken());
1265            return;
1266        }
1267        $this->error(ParseErrorCode::UnexpectedSolidusInTag);
1268        $this->reconsumeIn(TokenizerState::BeforeAttributeName);
1269    }
1270
1271    // ============================================================
1272    // 13.2.5.41 Bogus comment state
1273    // ============================================================
1274    private function stateBogusComment(): void
1275    {
1276        $c = $this->consume();
1277        $comment = $this->currentTokenAsComment();
1278        if ($c === '>') {
1279            $this->emit($comment);
1280            $this->state = TokenizerState::Data;
1281            return;
1282        }
1283        if ($c === null) {
1284            $this->emit($comment);
1285            $this->emit(new EofToken());
1286            return;
1287        }
1288        if ($c === "\u{0000}") {
1289            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1290            $comment->append("\u{FFFD}");
1291            return;
1292        }
1293        $comment->append($c);
1294    }
1295
1296    // ============================================================
1297    // 13.2.5.42 Markup declaration open state
1298    // ============================================================
1299    private function stateMarkupDeclarationOpen(): void
1300    {
1301        // Peek ahead at the next characters. Two cases at Phase 1B.2:
1302        //   "--"   â†’ comment start
1303        //   "doctype" (case-insensitive) â†’ doctype
1304        // CDATA section ([CDATA[) is deferred to 1B.2-bis.
1305        $rest = $this->peekRemaining(7);
1306        if (str_starts_with($rest, '--')) {
1307            $this->advance(2);
1308            $this->currentToken = new CommentToken();
1309            $this->state = TokenizerState::CommentStart;
1310            return;
1311        }
1312        if (strcasecmp(substr($rest, 0, 7), 'doctype') === 0) {
1313            $this->advance(7);
1314            $this->state = TokenizerState::Doctype;
1315            return;
1316        }
1317        if (str_starts_with($rest, '[CDATA[')) {
1318            $this->advance(7);
1319            if ($this->inForeignContent) {
1320                $this->state = TokenizerState::CdataSection;
1321                return;
1322            }
1323            $this->error(ParseErrorCode::CdataInHtmlContent);
1324            $this->currentToken = new CommentToken('[CDATA[');
1325            $this->state = TokenizerState::BogusComment;
1326            return;
1327        }
1328        $this->error(ParseErrorCode::IncorrectlyOpenedComment);
1329        $this->currentToken = new CommentToken();
1330        $this->state = TokenizerState::BogusComment;
1331    }
1332
1333    // ============================================================
1334    // 13.2.5.43–48 Comment states
1335    // ============================================================
1336    private function stateCommentStart(): void
1337    {
1338        $c = $this->consume();
1339        if ($c === '-') {
1340            $this->state = TokenizerState::CommentStartDash;
1341            return;
1342        }
1343        if ($c === '>') {
1344            $this->error(ParseErrorCode::AbruptClosingOfEmptyComment);
1345            $this->emit($this->currentTokenAsComment());
1346            $this->state = TokenizerState::Data;
1347            return;
1348        }
1349        $this->reconsumeIn(TokenizerState::Comment);
1350    }
1351
1352    private function stateCommentStartDash(): void
1353    {
1354        $c = $this->consume();
1355        if ($c === '-') {
1356            $this->state = TokenizerState::CommentEnd;
1357            return;
1358        }
1359        if ($c === '>') {
1360            $this->error(ParseErrorCode::AbruptClosingOfEmptyComment);
1361            $this->emit($this->currentTokenAsComment());
1362            $this->state = TokenizerState::Data;
1363            return;
1364        }
1365        if ($c === null) {
1366            $this->error(ParseErrorCode::EofInComment);
1367            $this->emit($this->currentTokenAsComment());
1368            $this->emit(new EofToken());
1369            return;
1370        }
1371        $this->currentTokenAsComment()->append('-');
1372        $this->reconsumeIn(TokenizerState::Comment);
1373    }
1374
1375    private function stateComment(): void
1376    {
1377        $c = $this->consume();
1378        $comment = $this->currentTokenAsComment();
1379        if ($c === '<') {
1380            $comment->append('<');
1381            $this->state = TokenizerState::CommentLessThanSign;
1382            return;
1383        }
1384        if ($c === '-') {
1385            $this->state = TokenizerState::CommentEndDash;
1386            return;
1387        }
1388        if ($c === "\u{0000}") {
1389            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1390            $comment->append("\u{FFFD}");
1391            return;
1392        }
1393        if ($c === null) {
1394            $this->error(ParseErrorCode::EofInComment);
1395            $this->emit($comment);
1396            $this->emit(new EofToken());
1397            return;
1398        }
1399        $comment->append($c);
1400    }
1401
1402    // ============================================================
1403    // 13.2.5.45–48 Comment less-than-sign / bang recovery states
1404    // ============================================================
1405    private function stateCommentLessThanSign(): void
1406    {
1407        $c = $this->consume();
1408        $comment = $this->currentTokenAsComment();
1409        if ($c === '!') {
1410            $comment->append('!');
1411            $this->state = TokenizerState::CommentLessThanSignBang;
1412            return;
1413        }
1414        if ($c === '<') {
1415            $comment->append('<');
1416            return;
1417        }
1418        $this->reconsumeIn(TokenizerState::Comment);
1419    }
1420
1421    private function stateCommentLessThanSignBang(): void
1422    {
1423        $c = $this->consume();
1424        if ($c === '-') {
1425            $this->state = TokenizerState::CommentLessThanSignBangDash;
1426            return;
1427        }
1428        $this->reconsumeIn(TokenizerState::Comment);
1429    }
1430
1431    private function stateCommentLessThanSignBangDash(): void
1432    {
1433        $c = $this->consume();
1434        if ($c === '-') {
1435            $this->state = TokenizerState::CommentLessThanSignBangDashDash;
1436            return;
1437        }
1438        $this->reconsumeIn(TokenizerState::CommentEndDash);
1439    }
1440
1441    private function stateCommentLessThanSignBangDashDash(): void
1442    {
1443        $c = $this->consume();
1444        if ($c === '>' || $c === null) {
1445            $this->reconsumeIn(TokenizerState::CommentEnd);
1446            return;
1447        }
1448        $this->error(ParseErrorCode::NestedComment);
1449        $this->reconsumeIn(TokenizerState::CommentEnd);
1450    }
1451
1452    // ============================================================
1453    // 13.2.5.69–71 CDATA section states (only valid in foreign content)
1454    // ============================================================
1455    private function stateCdataSection(): void
1456    {
1457        $c = $this->consume();
1458        if ($c === ']') {
1459            $this->state = TokenizerState::CdataSectionBracket;
1460            return;
1461        }
1462        if ($c === null) {
1463            $this->error(ParseErrorCode::EofInCdata);
1464            $this->emit(new EofToken());
1465            return;
1466        }
1467        // NULL inside CDATA is emitted verbatim per spec â€” no replacement.
1468        $this->emitChar($c);
1469    }
1470
1471    private function stateCdataSectionBracket(): void
1472    {
1473        $c = $this->consume();
1474        if ($c === ']') {
1475            $this->state = TokenizerState::CdataSectionEnd;
1476            return;
1477        }
1478        $this->emitChar(']');
1479        $this->reconsumeIn(TokenizerState::CdataSection);
1480    }
1481
1482    private function stateCdataSectionEnd(): void
1483    {
1484        $c = $this->consume();
1485        if ($c === ']') {
1486            $this->emitChar(']');
1487            return;
1488        }
1489        if ($c === '>') {
1490            $this->state = TokenizerState::Data;
1491            return;
1492        }
1493        $this->emitChar(']');
1494        $this->emitChar(']');
1495        $this->reconsumeIn(TokenizerState::CdataSection);
1496    }
1497
1498    private function stateCommentEndDash(): void
1499    {
1500        $c = $this->consume();
1501        if ($c === '-') {
1502            $this->state = TokenizerState::CommentEnd;
1503            return;
1504        }
1505        if ($c === null) {
1506            $this->error(ParseErrorCode::EofInComment);
1507            $this->emit($this->currentTokenAsComment());
1508            $this->emit(new EofToken());
1509            return;
1510        }
1511        $this->currentTokenAsComment()->append('-');
1512        $this->reconsumeIn(TokenizerState::Comment);
1513    }
1514
1515    private function stateCommentEnd(): void
1516    {
1517        $c = $this->consume();
1518        $comment = $this->currentTokenAsComment();
1519        if ($c === '>') {
1520            $this->emit($comment);
1521            $this->state = TokenizerState::Data;
1522            return;
1523        }
1524        if ($c === '!') {
1525            $this->state = TokenizerState::CommentEndBang;
1526            return;
1527        }
1528        if ($c === '-') {
1529            $comment->append('-');
1530            return;
1531        }
1532        if ($c === null) {
1533            $this->error(ParseErrorCode::EofInComment);
1534            $this->emit($comment);
1535            $this->emit(new EofToken());
1536            return;
1537        }
1538        $comment->append('--');
1539        $this->reconsumeIn(TokenizerState::Comment);
1540    }
1541
1542    private function stateCommentEndBang(): void
1543    {
1544        $c = $this->consume();
1545        $comment = $this->currentTokenAsComment();
1546        if ($c === '-') {
1547            $comment->append('--!');
1548            $this->state = TokenizerState::CommentEndDash;
1549            return;
1550        }
1551        if ($c === '>') {
1552            $this->error(ParseErrorCode::IncorrectlyClosedComment);
1553            $this->emit($comment);
1554            $this->state = TokenizerState::Data;
1555            return;
1556        }
1557        if ($c === null) {
1558            $this->error(ParseErrorCode::EofInComment);
1559            $this->emit($comment);
1560            $this->emit(new EofToken());
1561            return;
1562        }
1563        $comment->append('--!');
1564        $this->reconsumeIn(TokenizerState::Comment);
1565    }
1566
1567    // ============================================================
1568    // 13.2.5.53 DOCTYPE state (and friends)
1569    // ============================================================
1570    private function stateDoctype(): void
1571    {
1572        $c = $this->consume();
1573        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1574            $this->state = TokenizerState::BeforeDoctypeName;
1575            return;
1576        }
1577        if ($c === '>') {
1578            $this->reconsumeIn(TokenizerState::BeforeDoctypeName);
1579            return;
1580        }
1581        if ($c === null) {
1582            $this->error(ParseErrorCode::EofInDoctype);
1583            $token = new DoctypeToken();
1584            $token->forceQuirks = true;
1585            $this->emit($token);
1586            $this->emit(new EofToken());
1587            return;
1588        }
1589        $this->error(ParseErrorCode::MissingWhitespaceBeforeDoctypeName);
1590        $this->reconsumeIn(TokenizerState::BeforeDoctypeName);
1591    }
1592
1593    private function stateBeforeDoctypeName(): void
1594    {
1595        $c = $this->consume();
1596        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1597            return;
1598        }
1599        $token = new DoctypeToken();
1600        $this->currentToken = $token;
1601        if ($c !== null && self::isAsciiUpperAlpha($c)) {
1602            $token->name = strtolower($c);
1603            $this->state = TokenizerState::DoctypeName;
1604            return;
1605        }
1606        if ($c === "\u{0000}") {
1607            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1608            $token->name = "\u{FFFD}";
1609            $this->state = TokenizerState::DoctypeName;
1610            return;
1611        }
1612        if ($c === '>') {
1613            $this->error(ParseErrorCode::MissingDoctypeName);
1614            $token->forceQuirks = true;
1615            $this->emit($token);
1616            $this->state = TokenizerState::Data;
1617            return;
1618        }
1619        if ($c === null) {
1620            $this->error(ParseErrorCode::EofInDoctype);
1621            $token->forceQuirks = true;
1622            $this->emit($token);
1623            $this->emit(new EofToken());
1624            return;
1625        }
1626        $token->name = $c;
1627        $this->state = TokenizerState::DoctypeName;
1628    }
1629
1630    private function stateDoctypeName(): void
1631    {
1632        $c = $this->consume();
1633        $token = $this->currentTokenAsDoctype();
1634        assert($token->name !== null);
1635        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1636            $this->state = TokenizerState::AfterDoctypeName;
1637            return;
1638        }
1639        if ($c === '>') {
1640            $this->emit($token);
1641            $this->state = TokenizerState::Data;
1642            return;
1643        }
1644        if ($c !== null && self::isAsciiUpperAlpha($c)) {
1645            $token->name .= strtolower($c);
1646            return;
1647        }
1648        if ($c === "\u{0000}") {
1649            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1650            $token->name .= "\u{FFFD}";
1651            return;
1652        }
1653        if ($c === null) {
1654            $this->error(ParseErrorCode::EofInDoctype);
1655            $token->forceQuirks = true;
1656            $this->emit($token);
1657            $this->emit(new EofToken());
1658            return;
1659        }
1660        $token->name .= $c;
1661    }
1662
1663    private function stateAfterDoctypeName(): void
1664    {
1665        $c = $this->consume();
1666        $token = $this->currentTokenAsDoctype();
1667        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1668            return;
1669        }
1670        if ($c === '>') {
1671            $this->emit($token);
1672            $this->state = TokenizerState::Data;
1673            return;
1674        }
1675        if ($c === null) {
1676            $this->error(ParseErrorCode::EofInDoctype);
1677            $token->forceQuirks = true;
1678            $this->emit($token);
1679            $this->emit(new EofToken());
1680            return;
1681        }
1682        // Look ahead for PUBLIC or SYSTEM (case-insensitive, including the
1683        // current char). pos points one past the current char; -1 to include it.
1684        $effectivePos = $this->reconsume ? $this->pos - 1 : $this->pos - 1;
1685        $window = implode('', array_slice($this->chars, $effectivePos, 6));
1686        if (strcasecmp($window, 'PUBLIC') === 0) {
1687            $this->pos = $effectivePos + 6;
1688            $this->reconsume = false;
1689            $this->state = TokenizerState::AfterDoctypePublicKeyword;
1690            return;
1691        }
1692        if (strcasecmp($window, 'SYSTEM') === 0) {
1693            $this->pos = $effectivePos + 6;
1694            $this->reconsume = false;
1695            $this->state = TokenizerState::AfterDoctypeSystemKeyword;
1696            return;
1697        }
1698        $this->error(ParseErrorCode::InvalidCharacterSequenceAfterDoctypeName);
1699        $token->forceQuirks = true;
1700        $this->reconsumeIn(TokenizerState::BogusDoctype);
1701    }
1702
1703    // ============================================================
1704    // 13.2.5.57–67 DOCTYPE PUBLIC / SYSTEM identifier states
1705    // ============================================================
1706    private function stateAfterDoctypePublicKeyword(): void
1707    {
1708        $c = $this->consume();
1709        $token = $this->currentTokenAsDoctype();
1710        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1711            $this->state = TokenizerState::BeforeDoctypePublicIdentifier;
1712            return;
1713        }
1714        if ($c === '"') {
1715            $this->error(ParseErrorCode::MissingWhitespaceAfterDoctypePublicKeyword);
1716            $token->publicId = '';
1717            $this->state = TokenizerState::DoctypePublicIdentifierDoubleQuoted;
1718            return;
1719        }
1720        if ($c === "'") {
1721            $this->error(ParseErrorCode::MissingWhitespaceAfterDoctypePublicKeyword);
1722            $token->publicId = '';
1723            $this->state = TokenizerState::DoctypePublicIdentifierSingleQuoted;
1724            return;
1725        }
1726        if ($c === '>') {
1727            $this->error(ParseErrorCode::MissingDoctypePublicIdentifier);
1728            $token->forceQuirks = true;
1729            $this->emit($token);
1730            $this->state = TokenizerState::Data;
1731            return;
1732        }
1733        if ($c === null) {
1734            $this->error(ParseErrorCode::EofInDoctype);
1735            $token->forceQuirks = true;
1736            $this->emit($token);
1737            $this->emit(new EofToken());
1738            return;
1739        }
1740        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypePublicIdentifier);
1741        $token->forceQuirks = true;
1742        $this->reconsumeIn(TokenizerState::BogusDoctype);
1743    }
1744
1745    private function stateBeforeDoctypePublicIdentifier(): void
1746    {
1747        $c = $this->consume();
1748        $token = $this->currentTokenAsDoctype();
1749        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1750            return;
1751        }
1752        if ($c === '"') {
1753            $token->publicId = '';
1754            $this->state = TokenizerState::DoctypePublicIdentifierDoubleQuoted;
1755            return;
1756        }
1757        if ($c === "'") {
1758            $token->publicId = '';
1759            $this->state = TokenizerState::DoctypePublicIdentifierSingleQuoted;
1760            return;
1761        }
1762        if ($c === '>') {
1763            $this->error(ParseErrorCode::MissingDoctypePublicIdentifier);
1764            $token->forceQuirks = true;
1765            $this->emit($token);
1766            $this->state = TokenizerState::Data;
1767            return;
1768        }
1769        if ($c === null) {
1770            $this->error(ParseErrorCode::EofInDoctype);
1771            $token->forceQuirks = true;
1772            $this->emit($token);
1773            $this->emit(new EofToken());
1774            return;
1775        }
1776        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypePublicIdentifier);
1777        $token->forceQuirks = true;
1778        $this->reconsumeIn(TokenizerState::BogusDoctype);
1779    }
1780
1781    private function stateDoctypePublicIdentifierDoubleQuoted(): void
1782    {
1783        $this->doctypeQuotedIdentifier(true, '"');
1784    }
1785
1786    private function stateDoctypePublicIdentifierSingleQuoted(): void
1787    {
1788        $this->doctypeQuotedIdentifier(true, "'");
1789    }
1790
1791    private function stateAfterDoctypePublicIdentifier(): void
1792    {
1793        $c = $this->consume();
1794        $token = $this->currentTokenAsDoctype();
1795        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1796            $this->state = TokenizerState::BetweenDoctypePublicAndSystemIdentifiers;
1797            return;
1798        }
1799        if ($c === '>') {
1800            $this->emit($token);
1801            $this->state = TokenizerState::Data;
1802            return;
1803        }
1804        if ($c === '"') {
1805            $this->error(ParseErrorCode::MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers);
1806            $token->systemId = '';
1807            $this->state = TokenizerState::DoctypeSystemIdentifierDoubleQuoted;
1808            return;
1809        }
1810        if ($c === "'") {
1811            $this->error(ParseErrorCode::MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers);
1812            $token->systemId = '';
1813            $this->state = TokenizerState::DoctypeSystemIdentifierSingleQuoted;
1814            return;
1815        }
1816        if ($c === null) {
1817            $this->error(ParseErrorCode::EofInDoctype);
1818            $token->forceQuirks = true;
1819            $this->emit($token);
1820            $this->emit(new EofToken());
1821            return;
1822        }
1823        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypeSystemIdentifier);
1824        $token->forceQuirks = true;
1825        $this->reconsumeIn(TokenizerState::BogusDoctype);
1826    }
1827
1828    private function stateBetweenDoctypePublicAndSystemIdentifiers(): void
1829    {
1830        $c = $this->consume();
1831        $token = $this->currentTokenAsDoctype();
1832        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1833            return;
1834        }
1835        if ($c === '>') {
1836            $this->emit($token);
1837            $this->state = TokenizerState::Data;
1838            return;
1839        }
1840        if ($c === '"') {
1841            $token->systemId = '';
1842            $this->state = TokenizerState::DoctypeSystemIdentifierDoubleQuoted;
1843            return;
1844        }
1845        if ($c === "'") {
1846            $token->systemId = '';
1847            $this->state = TokenizerState::DoctypeSystemIdentifierSingleQuoted;
1848            return;
1849        }
1850        if ($c === null) {
1851            $this->error(ParseErrorCode::EofInDoctype);
1852            $token->forceQuirks = true;
1853            $this->emit($token);
1854            $this->emit(new EofToken());
1855            return;
1856        }
1857        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypeSystemIdentifier);
1858        $token->forceQuirks = true;
1859        $this->reconsumeIn(TokenizerState::BogusDoctype);
1860    }
1861
1862    private function stateAfterDoctypeSystemKeyword(): void
1863    {
1864        $c = $this->consume();
1865        $token = $this->currentTokenAsDoctype();
1866        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1867            $this->state = TokenizerState::BeforeDoctypeSystemIdentifier;
1868            return;
1869        }
1870        if ($c === '"') {
1871            $this->error(ParseErrorCode::MissingWhitespaceAfterDoctypeSystemKeyword);
1872            $token->systemId = '';
1873            $this->state = TokenizerState::DoctypeSystemIdentifierDoubleQuoted;
1874            return;
1875        }
1876        if ($c === "'") {
1877            $this->error(ParseErrorCode::MissingWhitespaceAfterDoctypeSystemKeyword);
1878            $token->systemId = '';
1879            $this->state = TokenizerState::DoctypeSystemIdentifierSingleQuoted;
1880            return;
1881        }
1882        if ($c === '>') {
1883            $this->error(ParseErrorCode::MissingDoctypeSystemIdentifier);
1884            $token->forceQuirks = true;
1885            $this->emit($token);
1886            $this->state = TokenizerState::Data;
1887            return;
1888        }
1889        if ($c === null) {
1890            $this->error(ParseErrorCode::EofInDoctype);
1891            $token->forceQuirks = true;
1892            $this->emit($token);
1893            $this->emit(new EofToken());
1894            return;
1895        }
1896        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypeSystemIdentifier);
1897        $token->forceQuirks = true;
1898        $this->reconsumeIn(TokenizerState::BogusDoctype);
1899    }
1900
1901    private function stateBeforeDoctypeSystemIdentifier(): void
1902    {
1903        $c = $this->consume();
1904        $token = $this->currentTokenAsDoctype();
1905        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1906            return;
1907        }
1908        if ($c === '"') {
1909            $token->systemId = '';
1910            $this->state = TokenizerState::DoctypeSystemIdentifierDoubleQuoted;
1911            return;
1912        }
1913        if ($c === "'") {
1914            $token->systemId = '';
1915            $this->state = TokenizerState::DoctypeSystemIdentifierSingleQuoted;
1916            return;
1917        }
1918        if ($c === '>') {
1919            $this->error(ParseErrorCode::MissingDoctypeSystemIdentifier);
1920            $token->forceQuirks = true;
1921            $this->emit($token);
1922            $this->state = TokenizerState::Data;
1923            return;
1924        }
1925        if ($c === null) {
1926            $this->error(ParseErrorCode::EofInDoctype);
1927            $token->forceQuirks = true;
1928            $this->emit($token);
1929            $this->emit(new EofToken());
1930            return;
1931        }
1932        $this->error(ParseErrorCode::MissingQuoteBeforeDoctypeSystemIdentifier);
1933        $token->forceQuirks = true;
1934        $this->reconsumeIn(TokenizerState::BogusDoctype);
1935    }
1936
1937    private function stateDoctypeSystemIdentifierDoubleQuoted(): void
1938    {
1939        $this->doctypeQuotedIdentifier(false, '"');
1940    }
1941
1942    private function stateDoctypeSystemIdentifierSingleQuoted(): void
1943    {
1944        $this->doctypeQuotedIdentifier(false, "'");
1945    }
1946
1947    private function stateAfterDoctypeSystemIdentifier(): void
1948    {
1949        $c = $this->consume();
1950        $token = $this->currentTokenAsDoctype();
1951        if ($c === "\t" || $c === "\n" || $c === "\f" || $c === ' ') {
1952            return;
1953        }
1954        if ($c === '>') {
1955            $this->emit($token);
1956            $this->state = TokenizerState::Data;
1957            return;
1958        }
1959        if ($c === null) {
1960            $this->error(ParseErrorCode::EofInDoctype);
1961            $token->forceQuirks = true;
1962            $this->emit($token);
1963            $this->emit(new EofToken());
1964            return;
1965        }
1966        // Per spec: do NOT set force-quirks here. The DOCTYPE is otherwise
1967        // syntactically valid; trailing garbage just goes to a bogus state.
1968        $this->error(ParseErrorCode::UnexpectedCharacterAfterDoctypeSystemIdentifier);
1969        $this->reconsumeIn(TokenizerState::BogusDoctype);
1970    }
1971
1972    /**
1973     * Shared logic for the four quoted-identifier states. $isPublic selects
1974     * which identifier field to append to; $terminator is " or '.
1975     */
1976    private function doctypeQuotedIdentifier(bool $isPublic, string $terminator): void
1977    {
1978        $c = $this->consume();
1979        $token = $this->currentTokenAsDoctype();
1980        $field = $isPublic ? 'publicId' : 'systemId';
1981        if ($c === $terminator) {
1982            $this->state = $isPublic
1983                ? TokenizerState::AfterDoctypePublicIdentifier
1984                : TokenizerState::AfterDoctypeSystemIdentifier;
1985            return;
1986        }
1987        if ($c === "\u{0000}") {
1988            $this->error(ParseErrorCode::UnexpectedNullCharacter);
1989            assert($token->{$field} !== null);
1990            $token->{$field} .= "\u{FFFD}";
1991            return;
1992        }
1993        if ($c === '>') {
1994            $this->error($isPublic
1995                ? ParseErrorCode::AbruptDoctypePublicIdentifier
1996                : ParseErrorCode::AbruptDoctypeSystemIdentifier);
1997            $token->forceQuirks = true;
1998            $this->emit($token);
1999            $this->state = TokenizerState::Data;
2000            return;
2001        }
2002        if ($c === null) {
2003            $this->error(ParseErrorCode::EofInDoctype);
2004            $token->forceQuirks = true;
2005            $this->emit($token);
2006            $this->emit(new EofToken());
2007            return;
2008        }
2009        assert($token->{$field} !== null);
2010        $token->{$field} .= $c;
2011    }
2012
2013    private function stateBogusDoctype(): void
2014    {
2015        $c = $this->consume();
2016        if ($c === '>') {
2017            $this->emit($this->currentTokenAsDoctype());
2018            $this->state = TokenizerState::Data;
2019            return;
2020        }
2021        if ($c === null) {
2022            $this->emit($this->currentTokenAsDoctype());
2023            $this->emit(new EofToken());
2024            return;
2025        }
2026        if ($c === "\u{0000}") {
2027            $this->error(ParseErrorCode::UnexpectedNullCharacter);
2028        }
2029        // Otherwise, ignore (no append per spec).
2030    }
2031
2032    // ============================================================
2033    // 13.2.5.72–80 Character reference states
2034    // ============================================================
2035    private function stateCharacterReference(): void
2036    {
2037        $this->tempBuffer = '&';
2038        $c = $this->consume();
2039        if ($c === null) {
2040            $this->flushTempBufferToCharOrAttribute();
2041            $this->reconsumeIn($this->returnState);
2042            return;
2043        }
2044        if (self::isAsciiAlphanumeric($c)) {
2045            $this->reconsumeIn(TokenizerState::NamedCharacterReference);
2046            return;
2047        }
2048        if ($c === '#') {
2049            $this->tempBuffer .= '#';
2050            $this->state = TokenizerState::NumericCharacterReference;
2051            return;
2052        }
2053        $this->flushTempBufferToCharOrAttribute();
2054        $this->reconsumeIn($this->returnState);
2055    }
2056
2057    private function stateNamedCharacterReference(): void
2058    {
2059        // Greedy longest-match against the in-memory table. WHATWG defines
2060        // matching against the spec's full ~2200-entry trie; our table is the
2061        // high-frequency subset (see NamedCharacterReferences).
2062        $start = $this->reconsume ? $this->pos - 1 : $this->pos;
2063        $bestMatch = null;
2064        $bestLen = 0;
2065        for ($len = 1; $len <= 32 && $start + $len <= $this->length; $len++) {
2066            $candidate = implode('', array_slice($this->chars, $start, $len));
2067            if (isset(NamedCharacterReferences::TABLE[$candidate])) {
2068                $bestMatch = $candidate;
2069                $bestLen = $len;
2070            }
2071        }
2072
2073        if ($bestMatch !== null) {
2074            $hasSemicolon = str_ends_with($bestMatch, ';');
2075            $nextChar = $start + $bestLen < $this->length ? $this->chars[$start + $bestLen] : null;
2076            $inAttribute = $this->returnState === TokenizerState::AttributeValueDoubleQuoted
2077                || $this->returnState === TokenizerState::AttributeValueSingleQuoted
2078                || $this->returnState === TokenizerState::AttributeValueUnquoted;
2079
2080            // Special case for attribute values + legacy entries: if the next
2081            // char is "=" or alphanumeric, don't decode (preserves
2082            // backward-compat with old URLs like ?foo=bar&copy=true).
2083            if (!$hasSemicolon && $inAttribute && $nextChar !== null
2084                && ($nextChar === '=' || self::isAsciiAlphanumeric($nextChar))
2085            ) {
2086                $this->tempBuffer = '&' . $bestMatch;
2087                $this->advance($bestLen);
2088                $this->reconsume = false;
2089                $this->flushTempBufferToCharOrAttribute();
2090                $this->state = $this->returnState;
2091                return;
2092            }
2093
2094            if (!$hasSemicolon) {
2095                $this->error(ParseErrorCode::MissingSemicolonAfterCharacterReference);
2096            }
2097            $this->tempBuffer = NamedCharacterReferences::TABLE[$bestMatch];
2098            $this->advance($bestLen);
2099            $this->reconsume = false;
2100            $this->flushTempBufferToCharOrAttribute();
2101            $this->state = $this->returnState;
2102            return;
2103        }
2104
2105        // No match â€” flush "&" and fall through to the ambiguous-ampersand state
2106        // to consume any remaining ASCII alphanumerics + ";" without decoding.
2107        $this->flushTempBufferToCharOrAttribute();
2108        $this->state = TokenizerState::AmbiguousAmpersand;
2109    }
2110
2111    private function stateAmbiguousAmpersand(): void
2112    {
2113        $c = $this->consume();
2114        if ($c !== null && self::isAsciiAlphanumeric($c)) {
2115            $inAttribute = $this->returnState === TokenizerState::AttributeValueDoubleQuoted
2116                || $this->returnState === TokenizerState::AttributeValueSingleQuoted
2117                || $this->returnState === TokenizerState::AttributeValueUnquoted;
2118            if ($inAttribute) {
2119                $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), $c);
2120            } else {
2121                $this->emitChar($c);
2122            }
2123            return;
2124        }
2125        if ($c === ';') {
2126            $this->error(ParseErrorCode::UnknownNamedCharacterReference);
2127        }
2128        $this->reconsumeIn($this->returnState);
2129    }
2130
2131    private function stateNumericCharacterReference(): void
2132    {
2133        $this->characterReferenceCode = 0;
2134        $c = $this->consume();
2135        if ($c === 'x' || $c === 'X') {
2136            $this->tempBuffer .= $c;
2137            $this->state = TokenizerState::HexadecimalCharacterReferenceStart;
2138            return;
2139        }
2140        $this->reconsumeIn(TokenizerState::DecimalCharacterReferenceStart);
2141    }
2142
2143    private function stateHexadecimalCharacterReferenceStart(): void
2144    {
2145        $c = $this->consume();
2146        if ($c !== null && self::isAsciiHexDigit($c)) {
2147            $this->reconsumeIn(TokenizerState::HexadecimalCharacterReference);
2148            return;
2149        }
2150        $this->error(ParseErrorCode::AbsenceOfDigitsInNumericCharacterReference);
2151        $this->flushTempBufferToCharOrAttribute();
2152        $this->reconsumeIn($this->returnState);
2153    }
2154
2155    private function stateDecimalCharacterReferenceStart(): void
2156    {
2157        $c = $this->consume();
2158        if ($c !== null && ctype_digit($c)) {
2159            $this->reconsumeIn(TokenizerState::DecimalCharacterReference);
2160            return;
2161        }
2162        $this->error(ParseErrorCode::AbsenceOfDigitsInNumericCharacterReference);
2163        $this->flushTempBufferToCharOrAttribute();
2164        $this->reconsumeIn($this->returnState);
2165    }
2166
2167    private function stateHexadecimalCharacterReference(): void
2168    {
2169        $c = $this->consume();
2170        if ($c === null) {
2171            $this->error(ParseErrorCode::MissingSemicolonAfterCharacterReference);
2172            $this->state = TokenizerState::NumericCharacterReferenceEnd;
2173            $this->reconsume = true;
2174            return;
2175        }
2176        if (ctype_digit($c)) {
2177            $this->characterReferenceCode = $this->characterReferenceCode * 16 + (ord($c) - 0x30);
2178            return;
2179        }
2180        if ($c >= 'A' && $c <= 'F') {
2181            $this->characterReferenceCode = $this->characterReferenceCode * 16 + (ord($c) - 0x37);
2182            return;
2183        }
2184        if ($c >= 'a' && $c <= 'f') {
2185            $this->characterReferenceCode = $this->characterReferenceCode * 16 + (ord($c) - 0x57);
2186            return;
2187        }
2188        if ($c === ';') {
2189            $this->state = TokenizerState::NumericCharacterReferenceEnd;
2190            return;
2191        }
2192        $this->error(ParseErrorCode::MissingSemicolonAfterCharacterReference);
2193        $this->reconsumeIn(TokenizerState::NumericCharacterReferenceEnd);
2194    }
2195
2196    private function stateDecimalCharacterReference(): void
2197    {
2198        $c = $this->consume();
2199        if ($c === null) {
2200            $this->error(ParseErrorCode::MissingSemicolonAfterCharacterReference);
2201            $this->state = TokenizerState::NumericCharacterReferenceEnd;
2202            $this->reconsume = true;
2203            return;
2204        }
2205        if (ctype_digit($c)) {
2206            $this->characterReferenceCode = $this->characterReferenceCode * 10 + (ord($c) - 0x30);
2207            return;
2208        }
2209        if ($c === ';') {
2210            $this->state = TokenizerState::NumericCharacterReferenceEnd;
2211            return;
2212        }
2213        $this->error(ParseErrorCode::MissingSemicolonAfterCharacterReference);
2214        $this->reconsumeIn(TokenizerState::NumericCharacterReferenceEnd);
2215    }
2216
2217    private function stateNumericCharacterReferenceEnd(): void
2218    {
2219        $code = $this->characterReferenceCode;
2220        if ($code === 0) {
2221            $this->error(ParseErrorCode::NullCharacterReference);
2222            $code = 0xFFFD;
2223        } elseif ($code > 0x10FFFF) {
2224            $this->error(ParseErrorCode::CharacterReferenceOutsideUnicodeRange);
2225            $code = 0xFFFD;
2226        } elseif ($code >= 0xD800 && $code <= 0xDFFF) {
2227            $this->error(ParseErrorCode::SurrogateCharacterReference);
2228            $code = 0xFFFD;
2229        } elseif (isset(NamedCharacterReferences::NUMERIC_REPLACEMENTS[$code])) {
2230            $this->error(ParseErrorCode::ControlCharacterReference);
2231            $code = NamedCharacterReferences::NUMERIC_REPLACEMENTS[$code];
2232        }
2233        $this->tempBuffer = mb_chr($code, 'UTF-8');
2234        $this->flushTempBufferToCharOrAttribute();
2235        $this->state = $this->returnState;
2236    }
2237
2238    private function flushTempBufferToCharOrAttribute(): void
2239    {
2240        $inAttribute = $this->returnState === TokenizerState::AttributeValueDoubleQuoted
2241            || $this->returnState === TokenizerState::AttributeValueSingleQuoted
2242            || $this->returnState === TokenizerState::AttributeValueUnquoted;
2243        if ($inAttribute) {
2244            $this->appendToCurrentAttributeValue($this->currentTokenAsTag(), $this->tempBuffer);
2245        } else {
2246            if ($this->tempBuffer !== '') {
2247                $this->emitChar($this->tempBuffer);
2248            }
2249        }
2250        $this->tempBuffer = '';
2251    }
2252
2253    // ============================================================
2254    // Tag finalisation
2255    // ============================================================
2256    private function finalizeAndEmitTag(): void
2257    {
2258        $tag = $this->currentTokenAsTag();
2259        $this->dedupAttributes($tag);
2260        if ($tag instanceof EndTagToken && count($tag->attributes) > 0) {
2261            $this->error(ParseErrorCode::EndTagWithAttributes);
2262        }
2263        $this->emit($tag);
2264    }
2265
2266    // ============================================================
2267    // Character classification helpers
2268    // ============================================================
2269    private static function isAsciiAlpha(string $c): bool
2270    {
2271        return ($c >= 'a' && $c <= 'z') || ($c >= 'A' && $c <= 'Z');
2272    }
2273
2274    private static function isAsciiUpperAlpha(string $c): bool
2275    {
2276        return $c >= 'A' && $c <= 'Z';
2277    }
2278
2279    private static function isAsciiLowerAlpha(string $c): bool
2280    {
2281        return $c >= 'a' && $c <= 'z';
2282    }
2283
2284    private static function isAsciiAlphanumeric(string $c): bool
2285    {
2286        return self::isAsciiAlpha($c) || ctype_digit($c);
2287    }
2288
2289    private static function isAsciiHexDigit(string $c): bool
2290    {
2291        return ctype_digit($c) || ($c >= 'A' && $c <= 'F') || ($c >= 'a' && $c <= 'f');
2292    }
2293}