Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.89% |
68 / 74 |
|
84.00% |
21 / 25 |
CRAP | |
0.00% |
0 / 1 |
| OpenElementsStack | |
91.89% |
68 / 74 |
|
84.00% |
21 / 25 |
58.73 | |
0.00% |
0 / 1 |
| push | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| pop | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| top | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| currentNode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isEmpty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| count | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| items | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| contains | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| containsLocalName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| indexOf | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| removeAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| remove | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| replaceAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| insertAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| popUntilLocalName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| popUntilElement | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| hasInScope | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasInListItemScope | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| hasInButtonScope | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| hasInTableScope | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasInSelectScope | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
7.33 | |||
| generateImpliedEndTags | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| generateImpliedEndTagsThoroughly | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| isSpecialHtmlElement | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasInScopeWithBoundaries | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
7.14 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Html\TreeConstruction; |
| 6 | |
| 7 | use Phpdftk\Html\Dom\Document; |
| 8 | use Phpdftk\Html\Dom\Element; |
| 9 | |
| 10 | /** |
| 11 | * The stack of open elements per WHATWG §13.2.4.2. Last-in-first-out, with |
| 12 | * a battery of "in scope" checks the tree-construction algorithm relies on. |
| 13 | * |
| 14 | * The element at the bottom of the stack (index 0) is conceptually the html |
| 15 | * element; the top (last) is the "current node". |
| 16 | */ |
| 17 | final class OpenElementsStack |
| 18 | { |
| 19 | /** @var list<Element> */ |
| 20 | private array $items = []; |
| 21 | |
| 22 | /** |
| 23 | * Per §13.2.4.2 — the "special" element list determines which elements |
| 24 | * close enclosing paragraphs, end formatting reconstruction loops, etc. |
| 25 | * Listed in HTML namespace only; foreign-content scoping is handled |
| 26 | * separately when Phase 1B.3-bis adds foreign-content insertion mode. |
| 27 | * |
| 28 | * @var array<string, true> |
| 29 | */ |
| 30 | private const array SPECIAL_HTML = [ |
| 31 | 'address' => true, 'applet' => true, 'area' => true, 'article' => true, |
| 32 | 'aside' => true, 'base' => true, 'basefont' => true, 'bgsound' => true, |
| 33 | 'blockquote' => true, 'body' => true, 'br' => true, 'button' => true, |
| 34 | 'caption' => true, 'center' => true, 'col' => true, 'colgroup' => true, |
| 35 | 'dd' => true, 'details' => true, 'dir' => true, 'div' => true, |
| 36 | 'dl' => true, 'dt' => true, 'embed' => true, 'fieldset' => true, |
| 37 | 'figcaption' => true, 'figure' => true, 'footer' => true, 'form' => true, |
| 38 | 'frame' => true, 'frameset' => true, 'h1' => true, 'h2' => true, |
| 39 | 'h3' => true, 'h4' => true, 'h5' => true, 'h6' => true, 'head' => true, |
| 40 | 'header' => true, 'hgroup' => true, 'hr' => true, 'html' => true, |
| 41 | 'iframe' => true, 'img' => true, 'input' => true, 'keygen' => true, |
| 42 | 'li' => true, 'link' => true, 'listing' => true, 'main' => true, |
| 43 | 'marquee' => true, 'menu' => true, 'meta' => true, 'nav' => true, |
| 44 | 'noembed' => true, 'noframes' => true, 'noscript' => true, 'object' => true, |
| 45 | 'ol' => true, 'p' => true, 'param' => true, 'plaintext' => true, |
| 46 | 'pre' => true, 'script' => true, 'search' => true, 'section' => true, |
| 47 | 'select' => true, 'source' => true, 'style' => true, 'summary' => true, |
| 48 | 'table' => true, 'tbody' => true, 'td' => true, 'template' => true, |
| 49 | 'textarea' => true, 'tfoot' => true, 'th' => true, 'thead' => true, |
| 50 | 'title' => true, 'tr' => true, 'track' => true, 'ul' => true, |
| 51 | 'wbr' => true, 'xmp' => true, |
| 52 | ]; |
| 53 | |
| 54 | /** Scope list used by "has X in scope" — the base case. */ |
| 55 | private const array SCOPE_BOUNDARIES = [ |
| 56 | 'applet', 'caption', 'html', 'table', 'td', 'th', |
| 57 | 'marquee', 'object', 'template', |
| 58 | ]; |
| 59 | |
| 60 | /** Adds "ol" and "ul" to the boundary set for list-item scope. */ |
| 61 | private const array LIST_ITEM_SCOPE_EXTRA = ['ol', 'ul']; |
| 62 | |
| 63 | /** Adds "button" for button scope. */ |
| 64 | private const array BUTTON_SCOPE_EXTRA = ['button']; |
| 65 | |
| 66 | /** Implied-end-tag set per §13.2.4.4. */ |
| 67 | private const array IMPLIED_END_TAG_NAMES = [ |
| 68 | 'dd', 'dt', 'li', 'option', 'optgroup', 'p', 'rb', 'rp', 'rt', 'rtc', |
| 69 | ]; |
| 70 | |
| 71 | /** Thorough implied-end-tag set per §13.2.4.4 — used after </template>. */ |
| 72 | private const array IMPLIED_END_TAG_NAMES_THOROUGHLY = [ |
| 73 | 'caption', 'colgroup', 'dd', 'dt', 'li', 'optgroup', 'option', 'p', |
| 74 | 'rb', 'rp', 'rt', 'rtc', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', |
| 75 | ]; |
| 76 | |
| 77 | public function push(Element $element): void |
| 78 | { |
| 79 | $this->items[] = $element; |
| 80 | } |
| 81 | |
| 82 | public function pop(): Element |
| 83 | { |
| 84 | $popped = array_pop($this->items); |
| 85 | if ($popped === null) { |
| 86 | throw new \LogicException('Cannot pop from empty open-elements stack'); |
| 87 | } |
| 88 | return $popped; |
| 89 | } |
| 90 | |
| 91 | public function top(): ?Element |
| 92 | { |
| 93 | return $this->items === [] ? null : $this->items[array_key_last($this->items)]; |
| 94 | } |
| 95 | |
| 96 | /** Alias to match spec terminology. */ |
| 97 | public function currentNode(): ?Element |
| 98 | { |
| 99 | return $this->top(); |
| 100 | } |
| 101 | |
| 102 | public function isEmpty(): bool |
| 103 | { |
| 104 | return $this->items === []; |
| 105 | } |
| 106 | |
| 107 | public function count(): int |
| 108 | { |
| 109 | return count($this->items); |
| 110 | } |
| 111 | |
| 112 | /** @return list<Element> snapshot */ |
| 113 | public function items(): array |
| 114 | { |
| 115 | return $this->items; |
| 116 | } |
| 117 | |
| 118 | public function contains(Element $element): bool |
| 119 | { |
| 120 | return in_array($element, $this->items, true); |
| 121 | } |
| 122 | |
| 123 | public function containsLocalName(string $localName, string $namespace = Document::HTML_NS): bool |
| 124 | { |
| 125 | foreach ($this->items as $el) { |
| 126 | if ($el->localName === $localName && $el->namespaceURI === $namespace) { |
| 127 | return true; |
| 128 | } |
| 129 | } |
| 130 | return false; |
| 131 | } |
| 132 | |
| 133 | public function indexOf(Element $element): ?int |
| 134 | { |
| 135 | $i = array_search($element, $this->items, true); |
| 136 | return $i === false ? null : $i; |
| 137 | } |
| 138 | |
| 139 | public function removeAt(int $index): void |
| 140 | { |
| 141 | array_splice($this->items, $index, 1); |
| 142 | } |
| 143 | |
| 144 | public function remove(Element $element): void |
| 145 | { |
| 146 | $i = $this->indexOf($element); |
| 147 | if ($i !== null) { |
| 148 | $this->removeAt($i); |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | public function replaceAt(int $index, Element $element): void |
| 153 | { |
| 154 | $this->items[$index] = $element; |
| 155 | } |
| 156 | |
| 157 | public function insertAt(int $index, Element $element): void |
| 158 | { |
| 159 | array_splice($this->items, $index, 0, [$element]); |
| 160 | } |
| 161 | |
| 162 | /** Pop elements until the named element has been popped. */ |
| 163 | public function popUntilLocalName(string ...$localNames): void |
| 164 | { |
| 165 | while ($this->items !== []) { |
| 166 | $top = $this->pop(); |
| 167 | if (in_array($top->localName, $localNames, true) && $top->namespaceURI === Document::HTML_NS) { |
| 168 | return; |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | public function popUntilElement(Element $target): void |
| 174 | { |
| 175 | while ($this->items !== []) { |
| 176 | $top = $this->pop(); |
| 177 | if ($top === $target) { |
| 178 | return; |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Has-element-in-scope per §13.2.4.2. Walks up looking for $localName; if |
| 185 | * a scope boundary is hit first, returns false. |
| 186 | */ |
| 187 | public function hasInScope(string $localName): bool |
| 188 | { |
| 189 | return $this->hasInScopeWithBoundaries($localName, self::SCOPE_BOUNDARIES); |
| 190 | } |
| 191 | |
| 192 | public function hasInListItemScope(string $localName): bool |
| 193 | { |
| 194 | return $this->hasInScopeWithBoundaries( |
| 195 | $localName, |
| 196 | array_merge(self::SCOPE_BOUNDARIES, self::LIST_ITEM_SCOPE_EXTRA), |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | public function hasInButtonScope(string $localName): bool |
| 201 | { |
| 202 | return $this->hasInScopeWithBoundaries( |
| 203 | $localName, |
| 204 | array_merge(self::SCOPE_BOUNDARIES, self::BUTTON_SCOPE_EXTRA), |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | public function hasInTableScope(string $localName): bool |
| 209 | { |
| 210 | return $this->hasInScopeWithBoundaries($localName, ['html', 'table', 'template']); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Select scope is inverse: any element NOT in {optgroup, option} is a boundary. |
| 215 | */ |
| 216 | public function hasInSelectScope(string $localName): bool |
| 217 | { |
| 218 | for ($i = array_key_last($this->items); $i !== null && $i >= 0; $i--) { |
| 219 | $el = $this->items[$i]; |
| 220 | if ($el->namespaceURI !== Document::HTML_NS) { |
| 221 | return false; |
| 222 | } |
| 223 | if ($el->localName === $localName) { |
| 224 | return true; |
| 225 | } |
| 226 | if (!in_array($el->localName, ['optgroup', 'option'], true)) { |
| 227 | return false; |
| 228 | } |
| 229 | } |
| 230 | return false; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * "Generate implied end tags": pop p/li/dd/dt/option/optgroup/rb/rp/rt/rtc |
| 235 | * until the current node is no longer one of those, optionally excluding |
| 236 | * a specific local name. |
| 237 | */ |
| 238 | public function generateImpliedEndTags(string $except = ''): void |
| 239 | { |
| 240 | while ($this->items !== []) { |
| 241 | $top = $this->top(); |
| 242 | if ($top === null || $top->namespaceURI !== Document::HTML_NS) { |
| 243 | return; |
| 244 | } |
| 245 | if ($top->localName === $except) { |
| 246 | return; |
| 247 | } |
| 248 | if (!in_array($top->localName, self::IMPLIED_END_TAG_NAMES, true)) { |
| 249 | return; |
| 250 | } |
| 251 | $this->pop(); |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | public function generateImpliedEndTagsThoroughly(): void |
| 256 | { |
| 257 | while ($this->items !== []) { |
| 258 | $top = $this->top(); |
| 259 | if ($top === null || $top->namespaceURI !== Document::HTML_NS) { |
| 260 | return; |
| 261 | } |
| 262 | if (!in_array($top->localName, self::IMPLIED_END_TAG_NAMES_THOROUGHLY, true)) { |
| 263 | return; |
| 264 | } |
| 265 | $this->pop(); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | public static function isSpecialHtmlElement(string $localName): bool |
| 270 | { |
| 271 | return isset(self::SPECIAL_HTML[$localName]); |
| 272 | } |
| 273 | |
| 274 | /** @param list<string> $boundaries */ |
| 275 | private function hasInScopeWithBoundaries(string $localName, array $boundaries): bool |
| 276 | { |
| 277 | for ($i = array_key_last($this->items); $i !== null && $i >= 0; $i--) { |
| 278 | $el = $this->items[$i]; |
| 279 | if ($el->localName === $localName && $el->namespaceURI === Document::HTML_NS) { |
| 280 | return true; |
| 281 | } |
| 282 | if (in_array($el->localName, $boundaries, true) && $el->namespaceURI === Document::HTML_NS) { |
| 283 | return false; |
| 284 | } |
| 285 | } |
| 286 | return false; |
| 287 | } |
| 288 | } |