Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.89% covered (success)
91.89%
68 / 74
84.00% covered (warning)
84.00%
21 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenElementsStack
91.89% covered (success)
91.89%
68 / 74
84.00% covered (warning)
84.00%
21 / 25
58.73
0.00% covered (danger)
0.00%
0 / 1
 push
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pop
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 top
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 currentNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 items
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 contains
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 containsLocalName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 indexOf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 removeAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 replaceAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insertAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 popUntilLocalName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 popUntilElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hasInScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasInListItemScope
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 hasInButtonScope
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 hasInTableScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasInSelectScope
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
7.33
 generateImpliedEndTags
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 generateImpliedEndTagsThoroughly
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 isSpecialHtmlElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasInScopeWithBoundaries
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
7.14
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html\TreeConstruction;
6
7use Phpdftk\Html\Dom\Document;
8use 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 */
17final 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}