2 namespace Masterminds\HTML5\Parser;
4 use Masterminds\HTML5\Elements;
7 * Create an HTML5 DOM tree from events.
9 * This attempts to create a DOM from events emitted by a parser. This
10 * attempts (but does not guarantee) to up-convert older HTML documents
11 * to HTML5. It does this by applying HTML5's rules, but it will not
12 * change the architecture of the document itself.
14 * Many of the error correction and quirks features suggested in the specification
15 * are implemented herein; however, not all of them are. Since we do not
16 * assume a graphical user agent, no presentation-specific logic is conducted
17 * during tree building.
19 * FIXME: The present tree builder does not exactly follow the state machine rules
20 * for insert modes as outlined in the HTML5 spec. The processor needs to be
21 * re-written to accomodate this. See, for example, the Go language HTML5
24 class DOMTreeBuilder implements EventHandler
27 * Defined in http://www.w3.org/TR/html51/infrastructure.html#html-namespace-0
29 const NAMESPACE_HTML = 'http://www.w3.org/1999/xhtml';
31 const NAMESPACE_MATHML = 'http://www.w3.org/1998/Math/MathML';
33 const NAMESPACE_SVG = 'http://www.w3.org/2000/svg';
35 const NAMESPACE_XLINK = 'http://www.w3.org/1999/xlink';
37 const NAMESPACE_XML = 'http://www.w3.org/XML/1998/namespace';
39 const NAMESPACE_XMLNS = 'http://www.w3.org/2000/xmlns/';
41 const OPT_DISABLE_HTML_NS = 'disable_html_ns';
43 const OPT_TARGET_DOC = 'target_document';
45 const OPT_IMPLICIT_NS = 'implicit_namespaces';
48 * Holds the HTML5 element names that causes a namespace switch
52 protected $nsRoots = array(
53 'html' => self::NAMESPACE_HTML,
54 'svg' => self::NAMESPACE_SVG,
55 'math' => self::NAMESPACE_MATHML
59 * Holds the always available namespaces (which does not require the XMLNS declaration).
63 protected $implicitNamespaces = array(
64 'xml' => self::NAMESPACE_XML,
65 'xmlns' => self::NAMESPACE_XMLNS,
66 'xlink' => self::NAMESPACE_XLINK
70 * Holds a stack of currently active namespaces.
74 protected $nsStack = array();
77 * Holds the number of namespaces declared by a node.
81 protected $pushes = array();
88 const IM_BEFORE_HTML = 1;
90 const IM_BEFORE_HEAD = 2;
94 const IM_IN_HEAD_NOSCRIPT = 4;
96 const IM_AFTER_HEAD = 5;
102 const IM_IN_TABLE = 8;
104 const IM_IN_TABLE_TEXT = 9;
106 const IM_IN_CAPTION = 10;
108 const IM_IN_COLUMN_GROUP = 11;
110 const IM_IN_TABLE_BODY = 12;
112 const IM_IN_ROW = 13;
114 const IM_IN_CELL = 14;
116 const IM_IN_SELECT = 15;
118 const IM_IN_SELECT_IN_TABLE = 16;
120 const IM_AFTER_BODY = 17;
122 const IM_IN_FRAMESET = 18;
124 const IM_AFTER_FRAMESET = 19;
126 const IM_AFTER_AFTER_BODY = 20;
128 const IM_AFTER_AFTER_FRAMESET = 21;
130 const IM_IN_SVG = 22;
132 const IM_IN_MATHML = 23;
134 protected $options = array();
136 protected $stack = array();
138 protected $current; // Pointer in the tag hierarchy.
143 protected $processor;
145 protected $insertMode = 0;
148 * Track if we are in an element that allows only inline child nodes
151 protected $onlyInline;
154 * Quirks mode is enabled by default.
155 * Any document that is missing the
156 * DT will be considered to be in quirks mode.
158 protected $quirks = true;
160 protected $errors = array();
162 public function __construct($isFragment = false, array $options = array())
164 $this->options = $options;
166 if (isset($options[self::OPT_TARGET_DOC])) {
167 $this->doc = $options[self::OPT_TARGET_DOC];
169 $impl = new \DOMImplementation();
171 // Create the doctype. For now, we are always creating HTML5
172 // documents, and attempting to up-convert any older DTDs to HTML5.
173 $dt = $impl->createDocumentType('html');
174 // $this->doc = \DOMImplementation::createDocument(NULL, 'html', $dt);
175 $this->doc = $impl->createDocument(null, null, $dt);
177 $this->errors = array();
179 $this->current = $this->doc; // ->documentElement;
181 // Create a rules engine for tags.
182 $this->rules = new TreeBuildingRules($this->doc);
184 $implicitNS = array();
185 if (isset($this->options[self::OPT_IMPLICIT_NS])) {
186 $implicitNS = $this->options[self::OPT_IMPLICIT_NS];
187 } elseif (isset($this->options["implicitNamespaces"])) {
188 $implicitNS = $this->options["implicitNamespaces"];
191 // Fill $nsStack with the defalut HTML5 namespaces, plus the "implicitNamespaces" array taken form $options
192 array_unshift($this->nsStack, $implicitNS + array(
193 '' => self::NAMESPACE_HTML
194 ) + $this->implicitNamespaces);
197 $this->insertMode = static::IM_IN_BODY;
198 $this->frag = $this->doc->createDocumentFragment();
199 $this->current = $this->frag;
206 public function document()
212 * Get the DOM fragment for the body.
214 * This returns a DOMNodeList because a fragment may have zero or more
215 * DOMNodes at its root.
217 * @see http://www.w3.org/TR/2012/CR-html5-20121217/syntax.html#concept-frag-parse-context
219 * @return \DOMFragmentDocumentFragment
221 public function fragment()
227 * Provide an instruction processor.
229 * This is used for handling Processor Instructions as they are
230 * inserted. If omitted, PI's are inserted directly into the DOM tree.
232 public function setInstructionProcessor(\Masterminds\HTML5\InstructionProcessor $proc)
234 $this->processor = $proc;
237 public function doctype($name, $idType = 0, $id = null, $quirks = false)
239 // This is used solely for setting quirks mode. Currently we don't
240 // try to preserve the inbound DT. We convert it to HTML5.
241 $this->quirks = $quirks;
243 if ($this->insertMode > static::IM_INITIAL) {
244 $this->parseError("Illegal placement of DOCTYPE tag. Ignoring: " . $name);
249 $this->insertMode = static::IM_BEFORE_HTML;
253 * Process the start tag.
255 * @todo - XMLNS namespace handling (we need to parse, even if it's not valid)
256 * - XLink, MathML and SVG namespace handling
257 * - Omission rules: 8.1.2.4 Optional tags
259 public function startTag($name, $attributes = array(), $selfClosing = false)
261 // fprintf(STDOUT, $name);
262 $lname = $this->normalizeTagName($name);
264 // Make sure we have an html element.
265 if (! $this->doc->documentElement && $name !== 'html' && ! $this->frag) {
266 $this->startTag('html');
269 // Set quirks mode if we're at IM_INITIAL with no doctype.
270 if ($this->insertMode == static::IM_INITIAL) {
271 $this->quirks = true;
272 $this->parseError("No DOCTYPE specified.");
275 // SPECIAL TAG HANDLING:
276 // Spec says do this, and "don't ask."
277 if ($name == 'image') {
281 // Autoclose p tags where appropriate.
282 if ($this->insertMode >= static::IM_IN_BODY && Elements::isA($name, Elements::AUTOCLOSE_P)) {
283 $this->autoclose('p');
289 $this->insertMode = static::IM_BEFORE_HEAD;
292 if ($this->insertMode > static::IM_BEFORE_HEAD) {
293 $this->parseError("Unexpected head tag outside of head context.");
295 $this->insertMode = static::IM_IN_HEAD;
299 $this->insertMode = static::IM_IN_BODY;
302 $this->insertMode = static::IM_IN_SVG;
305 $this->insertMode = static::IM_IN_MATHML;
308 if ($this->insertMode == static::IM_IN_HEAD) {
309 $this->insertMode = static::IM_IN_HEAD_NOSCRIPT;
314 // Special case handling for SVG.
315 if ($this->insertMode == static::IM_IN_SVG) {
316 $lname = Elements::normalizeSvgElement($lname);
320 // when we found a tag thats appears inside $nsRoots, we have to switch the defalut namespace
321 if (isset($this->nsRoots[$lname]) && $this->nsStack[0][''] !== $this->nsRoots[$lname]) {
322 array_unshift($this->nsStack, array(
323 '' => $this->nsRoots[$lname]
324 ) + $this->nsStack[0]);
327 $needsWorkaround = false;
328 if (isset($this->options["xmlNamespaces"]) && $this->options["xmlNamespaces"]) {
329 // when xmlNamespaces is true a and we found a 'xmlns' or 'xmlns:*' attribute, we should add a new item to the $nsStack
330 foreach ($attributes as $aName => $aVal) {
331 if ($aName === 'xmlns') {
332 $needsWorkaround = $aVal;
333 array_unshift($this->nsStack, array(
335 ) + $this->nsStack[0]);
337 } elseif ((($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : '') === 'xmlns') {
338 array_unshift($this->nsStack, array(
339 substr($aName, $pos + 1) => $aVal
340 ) + $this->nsStack[0]);
346 if ($this->onlyInline && Elements::isA($lname, Elements::BLOCK_TAG)) {
347 $this->autoclose($this->onlyInline);
348 $this->onlyInline = null;
352 $prefix = ($pos = strpos($lname, ':')) ? substr($lname, 0, $pos) : '';
355 if ($needsWorkaround!==false) {
357 $xml = "<$lname xmlns=\"$needsWorkaround\" ".(strlen($prefix) && isset($this->nsStack[0][$prefix])?("xmlns:$prefix=\"".$this->nsStack[0][$prefix]."\""):"")."/>";
359 $frag = new \DOMDocument('1.0', 'UTF-8');
360 $frag->loadXML($xml);
362 $ele = $this->doc->importNode($frag->documentElement, true);
365 if (!isset($this->nsStack[0][$prefix]) || ($prefix === "" && isset($this->options[self::OPT_DISABLE_HTML_NS]) && $this->options[self::OPT_DISABLE_HTML_NS])) {
366 $ele = $this->doc->createElement($lname);
368 $ele = $this->doc->createElementNS($this->nsStack[0][$prefix], $lname);
372 } catch (\DOMException $e) {
373 $this->parseError("Illegal tag name: <$lname>. Replaced with <invalid>.");
374 $ele = $this->doc->createElement('invalid');
377 if (Elements::isA($lname, Elements::BLOCK_ONLY_INLINE)) {
378 $this->onlyInline = $lname;
381 // When we add some namespacess, we have to track them. Later, when "endElement" is invoked, we have to remove them.
382 // When we are on a void tag, we do not need to care about namesapce nesting.
383 if ($pushes > 0 && !Elements::isA($name, Elements::VOID_TAG)) {
384 // PHP tends to free the memory used by DOM,
385 // to avoid spl_object_hash collisions whe have to avoid garbage collection of $ele storing it into $pushes
386 // see https://bugs.php.net/bug.php?id=67459
387 $this->pushes[spl_object_hash($ele)] = array($pushes, $ele);
389 // SEE https://github.com/facebook/hhvm/issues/2962
390 if (defined('HHVM_VERSION')) {
391 $ele->setAttribute('html5-php-fake-id-attribute', spl_object_hash($ele));
395 foreach ($attributes as $aName => $aVal) {
396 // xmlns attributes can't be set
397 if ($aName === 'xmlns') {
401 if ($this->insertMode == static::IM_IN_SVG) {
402 $aName = Elements::normalizeSvgAttribute($aName);
403 } elseif ($this->insertMode == static::IM_IN_MATHML) {
404 $aName = Elements::normalizeMathMlAttribute($aName);
408 $prefix = ($pos = strpos($aName, ':')) ? substr($aName, 0, $pos) : false;
410 if ($prefix==='xmlns') {
411 $ele->setAttributeNs(self::NAMESPACE_XMLNS, $aName, $aVal);
412 } elseif ($prefix!==false && isset($this->nsStack[0][$prefix])) {
413 $ele->setAttributeNs($this->nsStack[0][$prefix], $aName, $aVal);
415 $ele->setAttribute($aName, $aVal);
417 } catch (\DOMException $e) {
418 $this->parseError("Illegal attribute name for tag $name. Ignoring: $aName");
422 // This is necessary on a non-DTD schema, like HTML5.
423 if ($aName == 'id') {
424 $ele->setIdAttribute('id', true);
428 // Some elements have special processing rules. Handle those separately.
429 if ($this->rules->hasRules($name) && $this->frag !== $this->current) {
430 $this->current = $this->rules->evaluate($ele, $this->current);
431 } // Otherwise, it's a standard element.
433 $this->current->appendChild($ele);
435 // XXX: Need to handle self-closing tags and unary tags.
436 if (! Elements::isA($name, Elements::VOID_TAG)) {
437 $this->current = $ele;
441 // This is sort of a last-ditch attempt to correct for cases where no head/body
442 // elements are provided.
443 if ($this->insertMode <= static::IM_BEFORE_HEAD && $name != 'head' && $name != 'html') {
444 $this->insertMode = static::IM_IN_BODY;
447 // When we are on a void tag, we do not need to care about namesapce nesting,
448 // but we have to remove the namespaces pushed to $nsStack.
449 if ($pushes > 0 && Elements::isA($name, Elements::VOID_TAG)) {
450 // remove the namespaced definded by current node
451 for ($i = 0; $i < $pushes; $i ++) {
452 array_shift($this->nsStack);
455 // Return the element mask, which the tokenizer can then use to set
456 // various processing rules.
457 return Elements::element($name);
460 public function endTag($name)
462 $lname = $this->normalizeTagName($name);
464 // Ignore closing tags for unary elements.
465 if (Elements::isA($name, Elements::VOID_TAG)) {
469 if ($this->insertMode <= static::IM_BEFORE_HTML) {
471 if (in_array($name, array(
477 $this->startTag('html');
478 $this->endTag($name);
479 $this->insertMode = static::IM_BEFORE_HEAD;
485 $this->parseError("Illegal closing tag at global scope.");
490 // Special case handling for SVG.
491 if ($this->insertMode == static::IM_IN_SVG) {
492 $lname = Elements::normalizeSvgElement($lname);
495 // See https://github.com/facebook/hhvm/issues/2962
496 if (defined('HHVM_VERSION') && ($cid = $this->current->getAttribute('html5-php-fake-id-attribute'))) {
497 $this->current->removeAttribute('html5-php-fake-id-attribute');
499 $cid = spl_object_hash($this->current);
502 // XXX: Not sure whether we need this anymore.
503 // if ($name != $lname) {
504 // return $this->quirksTreeResolver($lname);
507 // XXX: HTML has no parent. What do we do, though,
508 // if this element appears in the wrong place?
509 if ($lname == 'html') {
513 // remove the namespaced definded by current node
514 if (isset($this->pushes[$cid])) {
515 for ($i = 0; $i < $this->pushes[$cid][0]; $i ++) {
516 array_shift($this->nsStack);
518 unset($this->pushes[$cid]);
521 if (! $this->autoclose($lname)) {
522 $this->parseError('Could not find closing tag for ' . $lname);
525 // switch ($this->insertMode) {
528 $this->insertMode = static::IM_AFTER_HEAD;
531 $this->insertMode = static::IM_AFTER_BODY;
535 $this->insertMode = static::IM_IN_BODY;
540 public function comment($cdata)
542 // TODO: Need to handle case where comment appears outside of the HTML tag.
543 $node = $this->doc->createComment($cdata);
544 $this->current->appendChild($node);
547 public function text($data)
549 // XXX: Hmmm.... should we really be this strict?
550 if ($this->insertMode < static::IM_IN_HEAD) {
551 // Per '8.2.5.4.3 The "before head" insertion mode' the characters
552 // " \t\n\r\f" should be ignored but no mention of a parse error. This is
553 // practical as most documents contain these characters. Other text is not
554 // expected here so recording a parse error is necessary.
555 $dataTmp = trim($data, " \t\n\r\f");
556 if (! empty($dataTmp)) {
557 // fprintf(STDOUT, "Unexpected insert mode: %d", $this->insertMode);
558 $this->parseError("Unexpected text. Ignoring: " . $dataTmp);
563 // fprintf(STDOUT, "Appending text %s.", $data);
564 $node = $this->doc->createTextNode($data);
565 $this->current->appendChild($node);
568 public function eof()
570 // If the $current isn't the $root, do we need to do anything?
573 public function parseError($msg, $line = 0, $col = 0)
575 $this->errors[] = sprintf("Line %d, Col %d: %s", $line, $col, $msg);
578 public function getErrors()
580 return $this->errors;
583 public function cdata($data)
585 $node = $this->doc->createCDATASection($data);
586 $this->current->appendChild($node);
589 public function processingInstruction($name, $data = null)
591 // XXX: Ignore initial XML declaration, per the spec.
592 if ($this->insertMode == static::IM_INITIAL && 'xml' == strtolower($name)) {
596 // Important: The processor may modify the current DOM tree however
598 if (isset($this->processor)) {
599 $res = $this->processor->process($this->current, $name, $data);
601 $this->current = $res;
607 // Otherwise, this is just a dumb PI element.
608 $node = $this->doc->createProcessingInstruction($name, $data);
610 $this->current->appendChild($node);
613 // ==========================================================================
615 // ==========================================================================
618 * Apply normalization rules to a tag name.
620 * See sections 2.9 and 8.1.2.
622 * @param string $name
624 * @return string The normalized tag name.
626 protected function normalizeTagName($name)
629 * 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); }
634 protected function quirksTreeResolver($name)
636 throw new \Exception("Not implemented.");
640 * Automatically climb the tree and close the closest node with the matching $tag.
642 protected function autoclose($tag)
644 $working = $this->current;
646 if ($working->nodeType != XML_ELEMENT_NODE) {
649 if ($working->tagName == $tag) {
650 $this->current = $working->parentNode;
654 } while ($working = $working->parentNode);
659 * Checks if the given tagname is an ancestor of the present candidate.
661 * If $this->current or anything above $this->current matches the given tag
662 * name, this returns true.
664 protected function isAncestor($tagname)
666 $candidate = $this->current;
667 while ($candidate->nodeType === XML_ELEMENT_NODE) {
668 if ($candidate->tagName == $tagname) {
671 $candidate = $candidate->parentNode;
678 * Returns true if the immediate parent element is of the given tagname.
680 protected function isParent($tagname)
682 return $this->current->tagName == $tagname;