PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.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 All 106 releases
wp-parsely / vendor / masterminds / html5 / src / HTML5 / Parser / DOMTreeBuilder.php

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

714 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 public function setInstructionProcessor(InstructionProcessor $proc)
236 {
237 $this->processor = $proc;
238 }
239
240 public function doctype($name, $idType = 0, $id = null, $quirks = false)
241 {
242 // This is used solely for setting quirks mode. Currently we don't
243 // try to preserve the inbound DT. We convert it to HTML5.
244 $this->quirks = $quirks;
245
246 if ($this->insertMode > static::IM_INITIAL) {
247 $this->parseError('Illegal placement of DOCTYPE tag. Ignoring: ' . $name);
248
249 return;
250 }
251
252 $this->insertMode = static::IM_BEFORE_HTML;
253 }
254
255 /**
256 * Process the start tag.
257 *
258 * @todo - XMLNS namespace handling (we need to parse, even if it's not valid)
259 * - XLink, MathML and SVG namespace handling
260 * - Omission rules: 8.1.2.4 Optional tags
261 *
262 * @param string $name
263 * @param array $attributes
264 * @param bool $selfClosing
265 *
266 * @return int
267 */
268 public function startTag($name, $attributes = array(), $selfClosing = false)
269 {
270 $lname = $this->normalizeTagName($name);
271
272 // Make sure we have an html element.
273 if (!$this->doc->documentElement && 'html' !== $name && !$this->frag) {
274 $this->startTag('html');
275 }
276
277 // Set quirks mode if we're at IM_INITIAL with no doctype.
278 if ($this->insertMode === static::IM_INITIAL) {
279 $this->quirks = true;
280 $this->parseError('No DOCTYPE specified.');
281 }
282
283 // SPECIAL TAG HANDLING:
284 // Spec says do this, and "don't ask."
285 // find the spec where this is defined... looks problematic
286 if ('image' === $name && !($this->insertMode === static::IM_IN_SVG || $this->insertMode === static::IM_IN_MATHML)) {
287 $name = 'img';
288 }
289
290 // Autoclose p tags where appropriate.
291 if ($this->insertMode >= static::IM_IN_BODY && Elements::isA($name, Elements::AUTOCLOSE_P)) {
292 $this->autoclose('p');
293 }
294
295 // Set insert mode:
296 switch ($name) {
297 case 'html':
298 $this->insertMode = static::IM_BEFORE_HEAD;
299 break;
300 case 'head':
301 if ($this->insertMode > static::IM_BEFORE_HEAD) {
302 $this->parseError('Unexpected head tag outside of head context.');
303 } else {
304 $this->insertMode = static::IM_IN_HEAD;
305 }
306 break;
307 case 'body':
308 $this->insertMode = static::IM_IN_BODY;
309 break;
310 case 'svg':
311 $this->insertMode = static::IM_IN_SVG;
312 break;
313 case 'math':
314 $this->insertMode = static::IM_IN_MATHML;
315 break;
316 case 'noscript':
317 if ($this->insertMode === static::IM_IN_HEAD) {
318 $this->insertMode = static::IM_IN_HEAD_NOSCRIPT;
319 }
320 break;
321 }
322
323 // Special case handling for SVG.
324 if ($this->insertMode === static::IM_IN_SVG) {
325 $lname = Elements::normalizeSvgElement($lname);
326 }
327
328 $pushes = 0;
329 // when we found a tag thats appears inside $nsRoots, we have to switch the defalut namespace
330 if (isset($this->nsRoots[$lname]) && $this->nsStack[0][''] !== $this->nsRoots[$lname]) {
331 array_unshift($this->nsStack, array(
332 '' => $this->nsRoots[$lname],
333 ) + $this->nsStack[0]);
334 ++$pushes;
335 }
336 $needsWorkaround = false;
337 if (isset($this->options['xmlNamespaces']) && $this->options['xmlNamespaces']) {
338 // when xmlNamespaces is true a and we found a 'xmlns' or 'xmlns:*' attribute, we should add a new item to the $nsStack
339 foreach ($attributes as $aName => $aVal) {
340 if ('xmlns' === $aName) {
341 $needsWorkaround = $aVal;
342 array_unshift($this->nsStack, array(
343 '' => $aVal,
344 ) + $this->nsStack[0]);
345 ++$pushes;
346 } elseif ('xmlns' === (($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : '')) {
347 array_unshift($this->nsStack, array(
348 substr($aName, $pos + 1) => $aVal,
349 ) + $this->nsStack[0]);
350 ++$pushes;
351 }
352 }
353 }
354
355 if ($this->onlyInline && Elements::isA($lname, Elements::BLOCK_TAG)) {
356 $this->autoclose($this->onlyInline);
357 $this->onlyInline = null;
358 }
359
360 // some elements as table related tags might have optional end tags that force us to auto close multiple tags
361 // https://www.w3.org/TR/html401/struct/tables.html
362 if ($this->current instanceof \DOMElement && isset(Elements::$optionalEndElementsParentsToClose[$lname])) {
363 foreach (Elements::$optionalEndElementsParentsToClose[$lname] as $parentElName) {
364 if ($this->current instanceof \DOMElement && $this->current->tagName === $parentElName) {
365 $this->autoclose($parentElName);
366 }
367 }
368 }
369
370 try {
371 $prefix = ($pos = strpos($lname, ':')) ? substr($lname, 0, $pos) : '';
372
373 if (false !== $needsWorkaround) {
374 $xml = "<$lname xmlns=\"$needsWorkaround\" " . (strlen($prefix) && isset($this->nsStack[0][$prefix]) ? ("xmlns:$prefix=\"" . $this->nsStack[0][$prefix] . '"') : '') . '/>';
375
376 $frag = new \DOMDocument('1.0', 'UTF-8');
377 $frag->loadXML($xml);
378
379 $ele = $this->doc->importNode($frag->documentElement, true);
380 } else {
381 if (!isset($this->nsStack[0][$prefix]) || ('' === $prefix && isset($this->options[self::OPT_DISABLE_HTML_NS]) && $this->options[self::OPT_DISABLE_HTML_NS])) {
382 $ele = $this->doc->createElement($lname);
383 } else {
384 $ele = $this->doc->createElementNS($this->nsStack[0][$prefix], $lname);
385 }
386 }
387 } catch (\DOMException $e) {
388 $this->parseError("Illegal tag name: <$lname>. Replaced with <invalid>.");
389 $ele = $this->doc->createElement('invalid');
390 }
391
392 if (Elements::isA($lname, Elements::BLOCK_ONLY_INLINE)) {
393 $this->onlyInline = $lname;
394 }
395
396 // When we add some namespacess, we have to track them. Later, when "endElement" is invoked, we have to remove them.
397 // When we are on a void tag, we do not need to care about namesapce nesting.
398 if ($pushes > 0 && !Elements::isA($name, Elements::VOID_TAG)) {
399 // PHP tends to free the memory used by DOM,
400 // to avoid spl_object_id collisions we have to avoid garbage collection of $ele storing it into $pushes
401 // see https://bugs.php.net/bug.php?id=67459
402 $this->pushes[spl_object_id($ele)] = array($pushes, $ele);
403 }
404
405 foreach ($attributes as $aName => $aVal) {
406 // xmlns attributes can't be set
407 if ('xmlns' === $aName) {
408 continue;
409 }
410
411 if ($this->insertMode === static::IM_IN_SVG) {
412 $aName = Elements::normalizeSvgAttribute($aName);
413 } elseif ($this->insertMode === static::IM_IN_MATHML) {
414 $aName = Elements::normalizeMathMlAttribute($aName);
415 }
416
417 $aVal = (string) $aVal;
418
419 try {
420 $prefix = ($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : false;
421
422 if ('xmlns' === $prefix) {
423 $ele->setAttributeNS(self::NAMESPACE_XMLNS, $aName, $aVal);
424 } elseif (false !== $prefix && isset($this->nsStack[0][$prefix])) {
425 $ele->setAttributeNS($this->nsStack[0][$prefix], $aName, $aVal);
426 } else {
427 $ele->setAttribute($aName, $aVal);
428 }
429 } catch (\DOMException $e) {
430 $this->parseError("Illegal attribute name for tag $name. Ignoring: $aName");
431 continue;
432 }
433
434 // This is necessary on a non-DTD schema, like HTML5.
435 if ('id' === $aName) {
436 $ele->setIdAttribute('id', true);
437 }
438 }
439
440 if ($this->frag !== $this->current && $this->rules->hasRules($name)) {
441 // Some elements have special processing rules. Handle those separately.
442 $this->current = $this->rules->evaluate($ele, $this->current);
443 } else {
444 // Otherwise, it's a standard element.
445 $this->current->appendChild($ele);
446
447 if (!Elements::isA($name, Elements::VOID_TAG)) {
448 $this->current = $ele;
449 }
450
451 // Self-closing tags should only be respected on foreign elements
452 // (and are implied on void elements)
453 // See: https://www.w3.org/TR/html5/syntax.html#start-tags
454 if (Elements::isHtml5Element($name)) {
455 $selfClosing = false;
456 }
457 }
458
459 // This is sort of a last-ditch attempt to correct for cases where no head/body
460 // elements are provided.
461 if ($this->insertMode <= static::IM_BEFORE_HEAD && 'head' !== $name && 'html' !== $name) {
462 $this->insertMode = static::IM_IN_BODY;
463 }
464
465 // When we are on a void tag, we do not need to care about namesapce nesting,
466 // but we have to remove the namespaces pushed to $nsStack.
467 if ($pushes > 0 && Elements::isA($name, Elements::VOID_TAG)) {
468 // remove the namespaced definded by current node
469 for ($i = 0; $i < $pushes; ++$i) {
470 array_shift($this->nsStack);
471 }
472 }
473
474 if ($selfClosing) {
475 $this->endTag($name);
476 }
477
478 // Return the element mask, which the tokenizer can then use to set
479 // various processing rules.
480 return Elements::element($name);
481 }
482
483 public function endTag($name)
484 {
485 $lname = $this->normalizeTagName($name);
486
487 // Special case within 12.2.6.4.7: An end tag whose tag name is "br" should be treated as an opening tag
488 if ('br' === $name) {
489 $this->parseError('Closing tag encountered for void element br.');
490
491 $this->startTag('br');
492 }
493 // Ignore closing tags for other unary elements.
494 elseif (Elements::isA($name, Elements::VOID_TAG)) {
495 return;
496 }
497
498 if ($this->insertMode <= static::IM_BEFORE_HTML) {
499 // 8.2.5.4.2
500 if (in_array($name, array(
501 'html',
502 'br',
503 'head',
504 'title',
505 ))) {
506 $this->startTag('html');
507 $this->endTag($name);
508 $this->insertMode = static::IM_BEFORE_HEAD;
509
510 return;
511 }
512
513 // Ignore the tag.
514 $this->parseError('Illegal closing tag at global scope.');
515
516 return;
517 }
518
519 // Special case handling for SVG.
520 if ($this->insertMode === static::IM_IN_SVG) {
521 $lname = Elements::normalizeSvgElement($lname);
522 }
523
524 $cid = spl_object_id($this->current);
525
526 // XXX: HTML has no parent. What do we do, though,
527 // if this element appears in the wrong place?
528 if ('html' === $lname) {
529 return;
530 }
531
532 // remove the namespaced definded by current node
533 if (isset($this->pushes[$cid])) {
534 for ($i = 0; $i < $this->pushes[$cid][0]; ++$i) {
535 array_shift($this->nsStack);
536 }
537 unset($this->pushes[$cid]);
538 }
539
540 if (!$this->autoclose($lname)) {
541 $this->parseError('Could not find closing tag for ' . $lname);
542 }
543
544 switch ($lname) {
545 case 'head':
546 $this->insertMode = static::IM_AFTER_HEAD;
547 break;
548 case 'body':
549 $this->insertMode = static::IM_AFTER_BODY;
550 break;
551 case 'svg':
552 case 'mathml':
553 $this->insertMode = static::IM_IN_BODY;
554 break;
555 }
556 }
557
558 public function comment($cdata)
559 {
560 // TODO: Need to handle case where comment appears outside of the HTML tag.
561 $node = $this->doc->createComment($cdata);
562 $this->current->appendChild($node);
563 }
564
565 public function text($data)
566 {
567 // XXX: Hmmm.... should we really be this strict?
568 if ($this->insertMode < static::IM_IN_HEAD) {
569 // Per '8.2.5.4.3 The "before head" insertion mode' the characters
570 // " \t\n\r\f" should be ignored but no mention of a parse error. This is
571 // practical as most documents contain these characters. Other text is not
572 // expected here so recording a parse error is necessary.
573 $dataTmp = trim($data, " \t\n\r\f");
574 if (!empty($dataTmp)) {
575 // fprintf(STDOUT, "Unexpected insert mode: %d", $this->insertMode);
576 $this->parseError('Unexpected text. Ignoring: ' . $dataTmp);
577 }
578
579 return;
580 }
581 // fprintf(STDOUT, "Appending text %s.", $data);
582 $node = $this->doc->createTextNode($data);
583 $this->current->appendChild($node);
584 }
585
586 public function eof()
587 {
588 // If the $current isn't the $root, do we need to do anything?
589 }
590
591 public function parseError($msg, $line = 0, $col = 0)
592 {
593 $this->errors[] = sprintf('Line %d, Col %d: %s', $line, $col, $msg);
594 }
595
596 public function getErrors()
597 {
598 return $this->errors;
599 }
600
601 public function cdata($data)
602 {
603 $node = $this->doc->createCDATASection($data);
604 $this->current->appendChild($node);
605 }
606
607 public function processingInstruction($name, $data = null)
608 {
609 // XXX: Ignore initial XML declaration, per the spec.
610 if ($this->insertMode === static::IM_INITIAL && 'xml' === strtolower($name)) {
611 return;
612 }
613
614 // Important: The processor may modify the current DOM tree however it sees fit.
615 if ($this->processor instanceof InstructionProcessor) {
616 $res = $this->processor->process($this->current, $name, $data);
617 if (!empty($res)) {
618 $this->current = $res;
619 }
620
621 return;
622 }
623
624 // Otherwise, this is just a dumb PI element.
625 $node = $this->doc->createProcessingInstruction($name, $data);
626
627 $this->current->appendChild($node);
628 }
629
630 // ==========================================================================
631 // UTILITIES
632 // ==========================================================================
633
634 /**
635 * Apply normalization rules to a tag name.
636 * See sections 2.9 and 8.1.2.
637 *
638 * @param string $tagName
639 *
640 * @return string The normalized tag name.
641 */
642 protected function normalizeTagName($tagName)
643 {
644 /*
645 * 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); }
646 */
647 return $tagName;
648 }
649
650 protected function quirksTreeResolver($name)
651 {
652 throw new \Exception('Not implemented.');
653 }
654
655 /**
656 * Automatically climb the tree and close the closest node with the matching $tag.
657 *
658 * @param string $tagName
659 *
660 * @return bool
661 */
662 protected function autoclose($tagName)
663 {
664 $working = $this->current;
665 do {
666 if (XML_ELEMENT_NODE !== $working->nodeType) {
667 return false;
668 }
669 if ($working->tagName === $tagName) {
670 $this->current = $working->parentNode;
671
672 return true;
673 }
674 } while ($working = $working->parentNode);
675
676 return false;
677 }
678
679 /**
680 * Checks if the given tagname is an ancestor of the present candidate.
681 *
682 * If $this->current or anything above $this->current matches the given tag
683 * name, this returns true.
684 *
685 * @param string $tagName
686 *
687 * @return bool
688 */
689 protected function isAncestor($tagName)
690 {
691 $candidate = $this->current;
692 while (XML_ELEMENT_NODE === $candidate->nodeType) {
693 if ($candidate->tagName === $tagName) {
694 return true;
695 }
696 $candidate = $candidate->parentNode;
697 }
698
699 return false;
700 }
701
702 /**
703 * Returns true if the immediate parent element is of the given tagname.
704 *
705 * @param string $tagName
706 *
707 * @return bool
708 */
709 protected function isParent($tagName)
710 {
711 return $this->current->tagName === $tagName;
712 }
713 }
714