Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.25% covered (success)
94.25%
82 / 87
84.00% covered (warning)
84.00%
21 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenElementsStack
94.25% covered (success)
94.25%
82 / 87
84.00% covered (warning)
84.00%
21 / 25
62.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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 hasInSelectScope
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
12.04
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        // Table scope is the only scope variant that does NOT treat
211        // MathML/SVG integration points as boundaries — its list per
212        // §13.2.4.2 is html / table / template only, all HTML.
213        return $this->hasInScopeWithBoundaries(
214            $localName,
215            ['html', 'table', 'template'],
216            includeForeignBoundaries: false,
217        );
218    }
219
220    /**
221     * Select scope is inverse: any element NOT in {optgroup, option} is a boundary.
222     */
223    public function hasInSelectScope(string $localName): bool
224    {
225        for ($i = array_key_last($this->items); $i !== null && $i >= 0; $i--) {
226            $el = $this->items[$i];
227            if ($el->namespaceURI !== Document::HTML_NS) {
228                return false;
229            }
230            if ($el->localName === $localName) {
231                return true;
232            }
233            if (!in_array($el->localName, ['optgroup', 'option'], true)) {
234                return false;
235            }
236        }
237        return false;
238    }
239
240    /**
241     * "Generate implied end tags": pop p/li/dd/dt/option/optgroup/rb/rp/rt/rtc
242     * until the current node is no longer one of those, optionally excluding
243     * a specific local name.
244     */
245    public function generateImpliedEndTags(string $except = ''): void
246    {
247        while ($this->items !== []) {
248            $top = $this->top();
249            if ($top === null || $top->namespaceURI !== Document::HTML_NS) {
250                return;
251            }
252            if ($top->localName === $except) {
253                return;
254            }
255            if (!in_array($top->localName, self::IMPLIED_END_TAG_NAMES, true)) {
256                return;
257            }
258            $this->pop();
259        }
260    }
261
262    public function generateImpliedEndTagsThoroughly(): void
263    {
264        while ($this->items !== []) {
265            $top = $this->top();
266            if ($top === null || $top->namespaceURI !== Document::HTML_NS) {
267                return;
268            }
269            if (!in_array($top->localName, self::IMPLIED_END_TAG_NAMES_THOROUGHLY, true)) {
270                return;
271            }
272            $this->pop();
273        }
274    }
275
276    public static function isSpecialHtmlElement(string $localName): bool
277    {
278        return isset(self::SPECIAL_HTML[$localName]);
279    }
280
281    /** MathML scope boundaries per WHATWG §13.2.4.2. */
282    private const array MATHML_SCOPE_BOUNDARIES = [
283        'mi', 'mo', 'mn', 'ms', 'mtext', 'annotation-xml',
284    ];
285
286    /** SVG scope boundaries per WHATWG §13.2.4.2. */
287    private const array SVG_SCOPE_BOUNDARIES = [
288        'foreignObject', 'desc', 'title',
289    ];
290
291    /** @param list<string> $boundaries */
292    private function hasInScopeWithBoundaries(
293        string $localName,
294        array $boundaries,
295        bool $includeForeignBoundaries = true,
296    ): bool {
297        for ($i = array_key_last($this->items); $i !== null && $i >= 0; $i--) {
298            $el = $this->items[$i];
299            if ($el->localName === $localName && $el->namespaceURI === Document::HTML_NS) {
300                return true;
301            }
302            if ($el->namespaceURI === Document::HTML_NS
303                && in_array($el->localName, $boundaries, true)
304            ) {
305                return false;
306            }
307            // §13.2.4.2 lists MathML text integration points (mi/mo/mn/ms/
308            // mtext/annotation-xml) and SVG HTML integration points
309            // (foreignObject/desc/title) as scope boundaries for every
310            // scope variant EXCEPT "table scope" — that one is the
311            // limited html/table/template trio only. Callers that want
312            // pure HTML-list semantics (hasInTableScope) pass false.
313            if (!$includeForeignBoundaries) {
314                continue;
315            }
316            if ($el->namespaceURI === Document::MATHML_NS
317                && in_array($el->localName, self::MATHML_SCOPE_BOUNDARIES, true)
318            ) {
319                return false;
320            }
321            if ($el->namespaceURI === Document::SVG_NS
322                && in_array($el->localName, self::SVG_SCOPE_BOUNDARIES, true)
323            ) {
324                return false;
325            }
326        }
327        return false;
328    }
329}