PluginProbe
Parse.ly / 3.19.2
Parse.ly v3.19.2
3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 3.20.3 All 105 releases
wp-parsely / vendor / masterminds / html5 / src / HTML5 / Parser / DOMTreeBuilder.php

DOMTreeBuilder.php in Parse.ly 3.19.2, at vendor/masterminds/html5/src/HTML5/Parser/DOMTreeBuilder.php

716 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Masterminds\HTML5\Parser;
4
5 use Masterminds\HTML5\Elements;
6 use Masterminds\HTML5\InstructionProcessor;
7
8 /**
9 * Create an HTML5 DOM tree from events.
10 *
11 * This attempts to create a DOM from events emitted by a parser. This
12 * attempts (but does not guarantee) to up-convert older HTML documents
13 * to HTML5. It does this by applying HTML5's rules, but it will not
14 * change the architecture of the document itself.
15 *
16 * Many of the error correction and quirks features suggested in the specification
17 * are implemented herein; however, not all of them are. Since we do not
18 * assume a graphical user agent, no presentation-specific logic is conducted
19 * during tree building.
20 *
21 * FIXME: The present tree builder does not exactly follow the state machine rules
22 * for insert modes as outlined in the HTML5 spec. The processor needs to be
23 * re-written to accomodate this. See, for example, the Go language HTML5
24 * parser.
25 */
26 class DOMTreeBuilder implements EventHandler
27 {
28 /**
29 * Defined in http://www.w3.org/TR/html51/infrastructure.html#html-namespace-0.
30 */
31 const NAMESPACE_HTML = 'http://www.w3.org/1999/xhtml';
32
33 const NAMESPACE_MATHML = 'http://www.w3.org/1998/Math/MathML';
34
35 const NAMESPACE_SVG = 'http://www.w3.org/2000/svg';
36
37 const NAMESPACE_XLINK = 'http://www.w3.org/1999/xlink';
38
39 const NAMESPACE_XML = 'http://www.w3.org/XML/1998/namespace';
40
41 const NAMESPACE_XMLNS = 'http://www.w3.org/2000/xmlns/';
42
43 const OPT_DISABLE_HTML_NS = 'disable_html_ns';
44
45 const OPT_TARGET_DOC = 'target_document';
46
47 const OPT_IMPLICIT_NS = 'implicit_namespaces';
48
49 /**
50 * Holds the HTML5 element names that causes a namespace switch.
51 *
52 * @var array
53 */
54 protected $nsRoots = array(
55 'html' => self::NAMESPACE_HTML,
56 'svg' => self::NAMESPACE_SVG,
57 'math' => self::NAMESPACE_MATHML,
58 );
59
60 /**
61 * Holds the always available namespaces (which does not require the XMLNS declaration).
62 *
63 * @var array
64 */
65 protected $implicitNamespaces = array(
66 'xml' => self::NAMESPACE_XML,
67 'xmlns' => self::NAMESPACE_XMLNS,
68 'xlink' => self::NAMESPACE_XLINK,
69 );
70
71 /**
72 * Holds a stack of currently active namespaces.
73 *
74 * @var array
75 */
76 protected $nsStack = array();
77
78 /**
79 * Holds the number of namespaces declared by a node.
80 *
81 * @var array
82 */
83 protected $pushes = array();
84
85 /**
86 * Defined in 8.2.5.
87 */
88 const IM_INITIAL = 0;
89
90 const IM_BEFORE_HTML = 1;
91
92 const IM_BEFORE_HEAD = 2;
93
94 const IM_IN_HEAD = 3;
95
96 const IM_IN_HEAD_NOSCRIPT = 4;
97
98 const IM_AFTER_HEAD = 5;
99
100 const IM_IN_BODY = 6;
101
102 const IM_TEXT = 7;
103
104 const IM_IN_TABLE = 8;
105
106 const IM_IN_TABLE_TEXT = 9;
107
108 const IM_IN_CAPTION = 10;
109
110 const IM_IN_COLUMN_GROUP = 11;
111
112 const IM_IN_TABLE_BODY = 12;
113
114 const IM_IN_ROW = 13;
115
116 const IM_IN_CELL = 14;
117
118 const IM_IN_SELECT = 15;
119
120 const IM_IN_SELECT_IN_TABLE = 16;
121
122 const IM_AFTER_BODY = 17;
123
124 const IM_IN_FRAMESET = 18;
125
126 const IM_AFTER_FRAMESET = 19;
127
128 const IM_AFTER_AFTER_BODY = 20;
129
130 const IM_AFTER_AFTER_FRAMESET = 21;
131
132 const IM_IN_SVG = 22;
133
134 const IM_IN_MATHML = 23;
135
136 protected $options = array();
137
138 protected $stack = array();
139
140 protected $current; // Pointer in the tag hierarchy.
141 protected $rules;
142 protected $doc;
143
144 protected $frag;
145
146 protected $processor;
147
148 protected $insertMode = 0;
149
150 /**
151 * Track if we are in an element that allows only inline child nodes.
152 *
153 * @var string|null
154 */
155 protected $onlyInline;
156
157 /**
158 * Quirks mode is enabled by default.
159 * Any document that is missing the DT will be considered to be in quirks mode.
160 */
161 protected $quirks = true;
162
163 protected $errors = array();
164
165 public function __construct($isFragment = false, array $options = array())
166 {
167 $this->options = $options;
168
169 if (isset($options[self::OPT_TARGET_DOC])) {
170 $this->doc = $options[self::OPT_TARGET_DOC];
171 } else {
172 $impl = new \DOMImplementation();
173 // XXX:
174 // Create the doctype. For now, we are always creating HTML5
175 // documents, and attempting to up-convert any older DTDs to HTML5.
176 $dt = $impl->createDocumentType('html');
177 // $this->doc = \DOMImplementation::createDocument(NULL, 'html', $dt);
178 $this->doc = $impl->createDocument(null, '', $dt);
179 $this->doc->encoding = !empty($options['encoding']) ? $options['encoding'] : 'UTF-8';
180 }
181
182 $this->errors = array();
183
184 $this->current = $this->doc; // ->documentElement;
185
186 // Create a rules engine for tags.
187 $this->rules = new TreeBuildingRules();
188
189 $implicitNS = array();
190 if (isset($this->options[self::OPT_IMPLICIT_NS])) {
191 $implicitNS = $this->options[self::OPT_IMPLICIT_NS];
192 } elseif (isset($this->options['implicitNamespaces'])) {
193 $implicitNS = $this->options['implicitNamespaces'];
194 }
195
196 // Fill $nsStack with the defalut HTML5 namespaces, plus the "implicitNamespaces" array taken form $options
197 array_unshift($this->nsStack, $implicitNS + array('' => self::NAMESPACE_HTML) + $this->implicitNamespaces);
198
199 if ($isFragment) {
200 $this->insertMode = static::IM_IN_BODY;
201 $this->frag = $this->doc->createDocumentFragment();
202 $this->current = $this->frag;
203 }
204 }
205
206 /**
207 * Get the document.
208 */
209 public function document()
210 {
211 return $this->doc;
212 }
213
214 /**
215 * Get the DOM fragment for the body.
216 *
217 * This returns a DOMNodeList because a fragment may have zero or more
218 * DOMNodes at its root.
219 *
220 * @see http://www.w3.org/TR/2012/CR-html5-20121217/syntax.html#concept-frag-parse-context
221 *
222 * @return \DOMDocumentFragment
223 */
224 public function fragment()
225 {
226 return $this->frag;
227 }
228
229 /**
230 * Provide an instruction processor.
231 *
232 * This is used for handling Processor Instructions as they are
233 * inserted. If omitted, PI's are inserted directly into the DOM tree.
234 *
235 * @param InstructionProcessor $proc
236 */
237 public function setInstructionProcessor(InstructionProcessor $proc)
238 {
239 $this->processor = $proc;
240 }
241
242 public function doctype($name, $idType = 0, $id = null, $quirks = false)
243 {
244 // This is used solely for setting quirks mode. Currently we don't
245 // try to preserve the inbound DT. We convert it to HTML5.
246 $this->quirks = $quirks;
247
248 if ($this->insertMode > static::IM_INITIAL) {
249 $this->parseError('Illegal placement of DOCTYPE tag. Ignoring: ' . $name);
250
251 return;
252 }
253
254 $this->insertMode = static::IM_BEFORE_HTML;
255 }
256
257 /**
258 * Process the start tag.
259 *
260 * @todo - XMLNS namespace handling (we need to parse, even if it's not valid)
261 * - XLink, MathML and SVG namespace handling
262 * - Omission rules: 8.1.2.4 Optional tags
263 *
264 * @param string $name
265 * @param array $attributes
266 * @param bool $selfClosing
267 *
268 * @return int
269 */
270 public function startTag($name, $attributes = array(), $selfClosing = false)
271 {
272 $lname = $this->normalizeTagName($name);
273
274 // Make sure we have an html element.
275 if (!$this->doc->documentElement && 'html' !== $name && !$this->frag) {
276 $this->startTag('html');
277 }
278
279 // Set quirks mode if we're at IM_INITIAL with no doctype.
280 if ($this->insertMode === static::IM_INITIAL) {
281 $this->quirks = true;
282 $this->parseError('No DOCTYPE specified.');
283 }
284
285 // SPECIAL TAG HANDLING:
286 // Spec says do this, and "don't ask."
287 // find the spec where this is defined... looks problematic
288 if ('image' === $name && !($this->insertMode === static::IM_IN_SVG || $this->insertMode === static::IM_IN_MATHML)) {
289 $name = 'img';
290 }
291
292 // Autoclose p tags where appropriate.
293 if ($this->insertMode >= static::IM_IN_BODY && Elements::isA($name, Elements::AUTOCLOSE_P)) {
294 $this->autoclose('p');
295 }
296
297 // Set insert mode:
298 switch ($name) {
299 case 'html':
300 $this->insertMode = static::IM_BEFORE_HEAD;
301 break;
302 case 'head':
303 if ($this->insertMode > static::IM_BEFORE_HEAD) {
304 $this->parseError('Unexpected head tag outside of head context.');
305 } else {
306 $this->insertMode = static::IM_IN_HEAD;
307 }
308 break;
309 case 'body':
310 $this->insertMode = static::IM_IN_BODY;
311 break;
312 case 'svg':
313 $this->insertMode = static::IM_IN_SVG;
314 break;
315 case 'math':
316 $this->insertMode = static::IM_IN_MATHML;
317 break;
318 case 'noscript':
319 if ($this->insertMode === static::IM_IN_HEAD) {
320 $this->insertMode = static::IM_IN_HEAD_NOSCRIPT;
321 }
322 break;
323 }
324
325 // Special case handling for SVG.
326 if ($this->insertMode === static::IM_IN_SVG) {
327 $lname = Elements::normalizeSvgElement($lname);
328 }
329
330 $pushes = 0;
331 // when we found a tag thats appears inside $nsRoots, we have to switch the defalut namespace
332 if (isset($this->nsRoots[$lname]) && $this->nsStack[0][''] !== $this->nsRoots[$lname]) {
333 array_unshift($this->nsStack, array(
334 '' => $this->nsRoots[$lname],
335 ) + $this->nsStack[0]);
336 ++$pushes;
337 }
338 $needsWorkaround = false;
339 if (isset($this->options['xmlNamespaces']) && $this->options['xmlNamespaces']) {
340 // when xmlNamespaces is true a and we found a 'xmlns' or 'xmlns:*' attribute, we should add a new item to the $nsStack
341 foreach ($attributes as $aName => $aVal) {
342 if ('xmlns' === $aName) {
343 $needsWorkaround = $aVal;
344 array_unshift($this->nsStack, array(
345 '' => $aVal,
346 ) + $this->nsStack[0]);
347 ++$pushes;
348 } elseif ('xmlns' === (($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : '')) {
349 array_unshift($this->nsStack, array(
350 substr($aName, $pos + 1) => $aVal,
351 ) + $this->nsStack[0]);
352 ++$pushes;
353 }
354 }
355 }
356
357 if ($this->onlyInline && Elements::isA($lname, Elements::BLOCK_TAG)) {
358 $this->autoclose($this->onlyInline);
359 $this->onlyInline = null;
360 }
361
362 // some elements as table related tags might have optional end tags that force us to auto close multiple tags
363 // https://www.w3.org/TR/html401/struct/tables.html
364 if ($this->current instanceof \DOMElement && isset(Elements::$optionalEndElementsParentsToClose[$lname])) {
365 foreach (Elements::$optionalEndElementsParentsToClose[$lname] as $parentElName) {
366 if ($this->current instanceof \DOMElement && $this->current->tagName === $parentElName) {
367 $this->autoclose($parentElName);
368 }
369 }
370 }
371
372 try {
373 $prefix = ($pos = strpos($lname, ':')) ? substr($lname, 0, $pos) : '';
374
375 if (false !== $needsWorkaround) {
376 $xml = "<$lname xmlns=\"$needsWorkaround\" " . (strlen($prefix) && isset($this->nsStack[0][$prefix]) ? ("xmlns:$prefix=\"" . $this->nsStack[0][$prefix] . '"') : '') . '/>';
377
378 $frag = new \DOMDocument('1.0', 'UTF-8');
379 $frag->loadXML($xml);
380
381 $ele = $this->doc->importNode($frag->documentElement, true);
382 } else {
383 if (!isset($this->nsStack[0][$prefix]) || ('' === $prefix && isset($this->options[self::OPT_DISABLE_HTML_NS]) && $this->options[self::OPT_DISABLE_HTML_NS])) {
384 $ele = $this->doc->createElement($lname);
385 } else {
386 $ele = $this->doc->createElementNS($this->nsStack[0][$prefix], $lname);
387 }
388 }
389 } catch (\DOMException $e) {
390 $this->parseError("Illegal tag name: <$lname>. Replaced with <invalid>.");
391 $ele = $this->doc->createElement('invalid');
392 }
393
394 if (Elements::isA($lname, Elements::BLOCK_ONLY_INLINE)) {
395 $this->onlyInline = $lname;
396 }
397
398 // When we add some namespacess, we have to track them. Later, when "endElement" is invoked, we have to remove them.
399 // When we are on a void tag, we do not need to care about namesapce nesting.
400 if ($pushes > 0 && !Elements::isA($name, Elements::VOID_TAG)) {
401 // PHP tends to free the memory used by DOM,
402 // to avoid spl_object_hash collisions whe have to avoid garbage collection of $ele storing it into $pushes
403 // see https://bugs.php.net/bug.php?id=67459
404 $this->pushes[spl_object_hash($ele)] = array($pushes, $ele);
405 }
406
407 foreach ($attributes as $aName => $aVal) {
408 // xmlns attributes can't be set
409 if ('xmlns' === $aName) {
410 continue;
411 }
412
413 if ($this->insertMode === static::IM_IN_SVG) {
414 $aName = Elements::normalizeSvgAttribute($aName);
415 } elseif ($this->insertMode === static::IM_IN_MATHML) {
416 $aName = Elements::normalizeMathMlAttribute($aName);
417 }
418
419 $aVal = (string) $aVal;
420
421 try {
422 $prefix = ($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : false;
423
424 if ('xmlns' === $prefix) {
425 $ele->setAttributeNS(self::NAMESPACE_XMLNS, $aName, $aVal);
426 } elseif (false !== $prefix && isset($this->nsStack[0][$prefix])) {
427 $ele->setAttributeNS($this->nsStack[0][$prefix], $aName, $aVal);
428 } else {
429 $ele->setAttribute($aName, $aVal);
430 }
431 } catch (\DOMException $e) {
432 $this->parseError("Illegal attribute name for tag $name. Ignoring: $aName");
433 continue;
434 }
435
436 // This is necessary on a non-DTD schema, like HTML5.
437 if ('id' === $aName) {
438 $ele->setIdAttribute('id', true);
439 }
440 }
441
442 if ($this->frag !== $this->current && $this->rules->hasRules($name)) {
443 // Some elements have special processing rules. Handle those separately.
444 $this->current = $this->rules->evaluate($ele, $this->current);
445 } else {
446 // Otherwise, it's a standard element.
447 $this->current->appendChild($ele);
448
449 if (!Elements::isA($name, Elements::VOID_TAG)) {
450 $this->current = $ele;
451 }
452
453 // Self-closing tags should only be respected on foreign elements
454 // (and are implied on void elements)
455 // See: https://www.w3.org/TR/html5/syntax.html#start-tags
456 if (Elements::isHtml5Element($name)) {
457 $selfClosing = false;
458 }
459 }
460
461 // This is sort of a last-ditch attempt to correct for cases where no head/body
462 // elements are provided.
463 if ($this->insertMode <= static::IM_BEFORE_HEAD && 'head' !== $name && 'html' !== $name) {
464 $this->insertMode = static::IM_IN_BODY;
465 }
466
467 // When we are on a void tag, we do not need to care about namesapce nesting,
468 // but we have to remove the namespaces pushed to $nsStack.
469 if ($pushes > 0 && Elements::isA($name, Elements::VOID_TAG)) {
470 // remove the namespaced definded by current node
471 for ($i = 0; $i < $pushes; ++$i) {
472 array_shift($this->nsStack);
473 }
474 }
475
476 if ($selfClosing) {
477 $this->endTag($name);
478 }
479
480 // Return the element mask, which the tokenizer can then use to set
481 // various processing rules.
482 return Elements::element($name);
483 }
484
485 public function endTag($name)
486 {
487 $lname = $this->normalizeTagName($name);
488
489 // Special case within 12.2.6.4.7: An end tag whose tag name is "br" should be treated as an opening tag
490 if ('br' === $name) {
491 $this->parseError('Closing tag encountered for void element br.');
492
493 $this->startTag('br');
494 }
495 // Ignore closing tags for other unary elements.
496 elseif (Elements::isA($name, Elements::VOID_TAG)) {
497 return;
498 }
499
500 if ($this->insertMode <= static::IM_BEFORE_HTML) {
501 // 8.2.5.4.2
502 if (in_array($name, array(
503 'html',
504 'br',
505 'head',
506 'title',
507 ))) {
508 $this->startTag('html');
509 $this->endTag($name);
510 $this->insertMode = static::IM_BEFORE_HEAD;
511
512 return;
513 }
514
515 // Ignore the tag.
516 $this->parseError('Illegal closing tag at global scope.');
517
518 return;
519 }
520
521 // Special case handling for SVG.
522 if ($this->insertMode === static::IM_IN_SVG) {
523 $lname = Elements::normalizeSvgElement($lname);
524 }
525
526 $cid = spl_object_hash($this->current);
527
528 // XXX: HTML has no parent. What do we do, though,
529 // if this element appears in the wrong place?
530 if ('html' === $lname) {
531 return;
532 }
533
534 // remove the namespaced definded by current node
535 if (isset($this->pushes[$cid])) {
536 for ($i = 0; $i < $this->pushes[$cid][0]; ++$i) {
537 array_shift($this->nsStack);
538 }
539 unset($this->pushes[$cid]);
540 }
541
542 if (!$this->autoclose($lname)) {
543 $this->parseError('Could not find closing tag for ' . $lname);
544 }
545
546 switch ($lname) {
547 case 'head':
548 $this->insertMode = static::IM_AFTER_HEAD;
549 break;
550 case 'body':
551 $this->insertMode = static::IM_AFTER_BODY;
552 break;
553 case 'svg':
554 case 'mathml':
555 $this->insertMode = static::IM_IN_BODY;
556 break;
557 }
558 }
559
560 public function comment($cdata)
561 {
562 // TODO: Need to handle case where comment appears outside of the HTML tag.
563 $node = $this->doc->createComment($cdata);
564 $this->current->appendChild($node);
565 }
566
567 public function text($data)
568 {
569 // XXX: Hmmm.... should we really be this strict?
570 if ($this->insertMode < static::IM_IN_HEAD) {
571 // Per '8.2.5.4.3 The "before head" insertion mode' the characters
572 // " \t\n\r\f" should be ignored but no mention of a parse error. This is
573 // practical as most documents contain these characters. Other text is not
574 // expected here so recording a parse error is necessary.
575 $dataTmp = trim($data, " \t\n\r\f");
576 if (!empty($dataTmp)) {
577 // fprintf(STDOUT, "Unexpected insert mode: %d", $this->insertMode);
578 $this->parseError('Unexpected text. Ignoring: ' . $dataTmp);
579 }
580
581 return;
582 }
583 // fprintf(STDOUT, "Appending text %s.", $data);
584 $node = $this->doc->createTextNode($data);
585 $this->current->appendChild($node);
586 }
587
588 public function eof()
589 {
590 // If the $current isn't the $root, do we need to do anything?
591 }
592
593 public function parseError($msg, $line = 0, $col = 0)
594 {
595 $this->errors[] = sprintf('Line %d, Col %d: %s', $line, $col, $msg);
596 }
597
598 public function getErrors()
599 {
600 return $this->errors;
601 }
602
603 public function cdata($data)
604 {
605 $node = $this->doc->createCDATASection($data);
606 $this->current->appendChild($node);
607 }
608
609 public function processingInstruction($name, $data = null)
610 {
611 // XXX: Ignore initial XML declaration, per the spec.
612 if ($this->insertMode === static::IM_INITIAL && 'xml' === strtolower($name)) {
613 return;
614 }
615
616 // Important: The processor may modify the current DOM tree however it sees fit.
617 if ($this->processor instanceof InstructionProcessor) {
618 $res = $this->processor->process($this->current, $name, $data);
619 if (!empty($res)) {
620 $this->current = $res;
621 }
622
623 return;
624 }
625
626 // Otherwise, this is just a dumb PI element.
627 $node = $this->doc->createProcessingInstruction($name, $data);
628
629 $this->current->appendChild($node);
630 }
631
632 // ==========================================================================
633 // UTILITIES
634 // ==========================================================================
635
636 /**
637 * Apply normalization rules to a tag name.
638 * See sections 2.9 and 8.1.2.
639 *
640 * @param string $tagName
641 *
642 * @return string The normalized tag name.
643 */
644 protected function normalizeTagName($tagName)
645 {
646 /*
647 * Section 2.9 suggests that we should not do this. if (strpos($name, ':') !== false) { // We know from the grammar that there must be at least one other // char besides :, since : is not a legal tag start. $parts = explode(':', $name); return array_pop($parts); }
648 */
649 return $tagName;
650 }
651
652 protected function quirksTreeResolver($name)
653 {
654 throw new \Exception('Not implemented.');
655 }
656
657 /**
658 * Automatically climb the tree and close the closest node with the matching $tag.
659 *
660 * @param string $tagName
661 *
662 * @return bool
663 */
664 protected function autoclose($tagName)
665 {
666 $working = $this->current;
667 do {
668 if (XML_ELEMENT_NODE !== $working->nodeType) {
669 return false;
670 }
671 if ($working->tagName === $tagName) {
672 $this->current = $working->parentNode;
673
674 return true;
675 }
676 } while ($working = $working->parentNode);
677
678 return false;
679 }
680
681 /**
682 * Checks if the given tagname is an ancestor of the present candidate.
683 *
684 * If $this->current or anything above $this->current matches the given tag
685 * name, this returns true.
686 *
687 * @param string $tagName
688 *
689 * @return bool
690 */
691 protected function isAncestor($tagName)
692 {
693 $candidate = $this->current;
694 while (XML_ELEMENT_NODE === $candidate->nodeType) {
695 if ($candidate->tagName === $tagName) {
696 return true;
697 }
698 $candidate = $candidate->parentNode;
699 }
700
701 return false;
702 }
703
704 /**
705 * Returns true if the immediate parent element is of the given tagname.
706 *
707 * @param string $tagName
708 *
709 * @return bool
710 */
711 protected function isParent($tagName)
712 {
713 return $this->current->tagName === $tagName;
714 }
715 }
716