diff options
Diffstat (limited to 'src/HTML5/Parser/Tokenizer.php')
-rw-r--r-- | src/HTML5/Parser/Tokenizer.php | 1907 |
1 files changed, 953 insertions, 954 deletions
diff --git a/src/HTML5/Parser/Tokenizer.php b/src/HTML5/Parser/Tokenizer.php index c70d92f..0db9ee1 100644 --- a/src/HTML5/Parser/Tokenizer.php +++ b/src/HTML5/Parser/Tokenizer.php @@ -23,1062 +23,1061 @@ use Masterminds\HTML5\Elements; * * @see http://www.w3.org/TR/2012/CR-html5-20121217/ */ -class Tokenizer { - protected $scanner; - protected $events; - protected $tok; - - /** - * Buffer for text. - */ - protected $text = ''; - - // When this goes to false, the parser stops. - protected $carryOn = TRUE; - - protected $textMode = 0; // TEXTMODE_NORMAL; - protected $untilTag = NULL; - - const WHITE="\t\n\f "; - - /** - * Create a new tokenizer. - * - * Typically, parsing a document involves creating a new tokenizer, giving - * it a scanner (input) and an event handler (output), and then calling - * the Tokenizer::parse() method.` - * - * @param \Masterminds\HTML5\Parser\Scanner $scanner - * A scanner initialized with an input stream. - * @param \Masterminds\HTML5\Parser\EventHandler $eventHandler - * An event handler, initialized and ready to receive - * events. - */ - public function __construct($scanner, $eventHandler) { - $this->scanner = $scanner; - $this->events = $eventHandler; - } - - /** - * Begin parsing. - * - * This will begin scanning the document, tokenizing as it goes. - * Tokens are emitted into the event handler. - * - * Tokenizing will continue until the document is completely - * read. Errors are emitted into the event handler, but - * the parser will attempt to continue parsing until the - * entire input stream is read. - */ - public function parse() { - $p = 0; - do { - $p = $this->scanner->position(); - $this->consumeData(); - - // FIXME: Add infinite loop protection. - } - while ($this->carryOn); - } - - /** - * Set the text mode for the character data reader. - * - * HTML5 defines three different modes for reading text: - * - Normal: Read until a tag is encountered. - * - RCDATA: Read until a tag is encountered, but skip a few otherwise- - * special characters. - * - Raw: Read until a special closing tag is encountered (viz. pre, script) - * - * This allows those modes to be set. - * - * Normally, setting is done by the event handler via a special return code on - * startTag(), but it can also be set manually using this function. - * - * @param integer $textmode - * One of Elements::TEXT_* - * @param string $untilTag - * The tag that should stop RAW or RCDATA mode. Normal mode does not - * use this indicator. - */ - public function setTextMode($textmode, $untilTag = NULL) { - $this->textMode = $textmode & (Elements::TEXT_RAW | Elements::TEXT_RCDATA); - $this->untilTag = $untilTag; - } - - /** - * Consume a character and make a move. - * HTML5 8.2.4.1 - */ - protected function consumeData() { - // Character Ref - /* - $this->characterReference() || - $this->tagOpen() || - $this->eof() || - $this->characterData(); - */ +class Tokenizer +{ - $this->characterReference(); - $this->tagOpen(); - $this->eof(); - $this->characterData(); - - - return $this->carryOn; - } - - /** - * Parse anything that looks like character data. - * - * Different rules apply based on the current text mode. - * - * @see Elements::TEXT_RAW Elements::TEXT_RCDATA. - */ - protected function characterData() { - if ($this->scanner->current() === FALSE) { - return FALSE; - } - switch ($this->textMode) { - case Elements::TEXT_RAW: - return $this->rawText(); - case Elements::TEXT_RCDATA: - return $this->rcdata(); - default: - $tok = $this->scanner->current(); - if (strspn($tok, "<&")) { - return FALSE; - } - return $this->text(); - } - } + protected $scanner; - /** - * This buffers the current token as character data. - */ - protected function text() { - $tok = $this->scanner->current(); + protected $events; - // This should never happen... - if ($tok === FALSE) { - return FALSE; - } - // Null - if ($tok === "\00") { - $this->parseError("Received NULL character."); - } - // fprintf(STDOUT, "Writing '%s'", $tok); - $this->buffer($tok); - $this->scanner->next(); - return TRUE; - } - - /** - * Read text in RAW mode. - */ - protected function rawText() { - if (is_null($this->untilTag)) { - return $this->text(); - } - $sequence = '</' . $this->untilTag . '>'; - $txt = $this->readUntilSequence($sequence); - $this->events->text($txt); - $this->setTextMode(0); - return $this->endTag(); - } - - /** - * Read text in RCDATA mode. - */ - protected function rcdata() { - if (is_null($this->untilTag)) { - return $this->text(); - } - $sequence = '</' . $this->untilTag . '>'; - $txt = ''; - $tok = $this->scanner->current(); - while ($tok !== FALSE && !($tok == '<' && ($this->sequenceMatches($sequence) || $this->sequenceMatches(strtoupper($sequence))))) { - if ($tok == '&') { - $txt .= $this->decodeCharacterReference(); - $tok = $this->scanner->current(); - } - else { - $txt .= $tok; - $tok = $this->scanner->next(); - } - } - $this->events->text($txt); - $this->setTextMode(0); - return $this->endTag(); - } - - /** - * If the document is read, emit an EOF event. - */ - protected function eof() { - if ($this->scanner->current() === FALSE) { - //fprintf(STDOUT, "EOF"); - $this->flushBuffer(); - $this->events->eof(); - $this->carryOn = FALSE; - return TRUE; - } - return FALSE; - } - - /** - * Handle character references (aka entities). - * - * This version is specific to PCDATA, as it buffers data into the - * text buffer. For a generic version, see decodeCharacterReference(). - * - * HTML5 8.2.4.2 - */ - protected function characterReference() { - $ref = $this->decodeCharacterReference(); - if ($ref !== FALSE) { - $this->buffer($ref); - return TRUE; - } - return FALSE; - } - - - /** - * Emit a tagStart event on encountering a tag. - * - * 8.2.4.8 - */ - protected function tagOpen() { - if ($this->scanner->current() != '<') { - return FALSE; + protected $tok; + + /** + * Buffer for text. + */ + protected $text = ''; + + // When this goes to false, the parser stops. + protected $carryOn = TRUE; + + protected $textMode = 0; // TEXTMODE_NORMAL; + protected $untilTag = NULL; + + const WHITE = "\t\n\f "; + + /** + * Create a new tokenizer. + * + * Typically, parsing a document involves creating a new tokenizer, giving + * it a scanner (input) and an event handler (output), and then calling + * the Tokenizer::parse() method.` + * + * @param \Masterminds\HTML5\Parser\Scanner $scanner + * A scanner initialized with an input stream. + * @param \Masterminds\HTML5\Parser\EventHandler $eventHandler + * An event handler, initialized and ready to receive + * events. + */ + public function __construct($scanner, $eventHandler) + { + $this->scanner = $scanner; + $this->events = $eventHandler; + } + + /** + * Begin parsing. + * + * This will begin scanning the document, tokenizing as it goes. + * Tokens are emitted into the event handler. + * + * Tokenizing will continue until the document is completely + * read. Errors are emitted into the event handler, but + * the parser will attempt to continue parsing until the + * entire input stream is read. + */ + public function parse() + { + $p = 0; + do { + $p = $this->scanner->position(); + $this->consumeData(); + + // FIXME: Add infinite loop protection. + } while ($this->carryOn); + } + + /** + * Set the text mode for the character data reader. + * + * HTML5 defines three different modes for reading text: + * - Normal: Read until a tag is encountered. + * - RCDATA: Read until a tag is encountered, but skip a few otherwise- + * special characters. + * - Raw: Read until a special closing tag is encountered (viz. pre, script) + * + * This allows those modes to be set. + * + * Normally, setting is done by the event handler via a special return code on + * startTag(), but it can also be set manually using this function. + * + * @param integer $textmode + * One of Elements::TEXT_* + * @param string $untilTag + * The tag that should stop RAW or RCDATA mode. Normal mode does not + * use this indicator. + */ + public function setTextMode($textmode, $untilTag = NULL) + { + $this->textMode = $textmode & (Elements::TEXT_RAW | Elements::TEXT_RCDATA); + $this->untilTag = $untilTag; } - // Any buffered text data can go out now. - $this->flushBuffer(); - - $this->scanner->next(); - - return $this->markupDeclaration() || - $this->endTag() || - $this->processingInstruction() || - $this->tagName() || - // This always returns false. - $this->parseError("Illegal tag opening") || - $this->characterData(); - } - - /** - * Look for markup. - */ - protected function markupDeclaration() { - if ($this->scanner->current() != '!') { - return FALSE; + /** + * Consume a character and make a move. + * HTML5 8.2.4.1 + */ + protected function consumeData() + { + // Character Ref + /* + * $this->characterReference() || $this->tagOpen() || $this->eof() || $this->characterData(); + */ + $this->characterReference(); + $this->tagOpen(); + $this->eof(); + $this->characterData(); + + return $this->carryOn; + } + + /** + * Parse anything that looks like character data. + * + * Different rules apply based on the current text mode. + * + * @see Elements::TEXT_RAW Elements::TEXT_RCDATA. + */ + protected function characterData() + { + if ($this->scanner->current() === FALSE) { + return FALSE; + } + switch ($this->textMode) { + case Elements::TEXT_RAW: + return $this->rawText(); + case Elements::TEXT_RCDATA: + return $this->rcdata(); + default: + $tok = $this->scanner->current(); + if (strspn($tok, "<&")) { + return FALSE; + } + return $this->text(); + } } - $tok = $this->scanner->next(); + /** + * This buffers the current token as character data. + */ + protected function text() + { + $tok = $this->scanner->current(); - // Comment: - if ($tok == '-' && $this->scanner->peek() == '-') { - $this->scanner->next(); // Consume the other '-' - $this->scanner->next(); // Next char. - return $this->comment(); + // This should never happen... + if ($tok === FALSE) { + return FALSE; + } + // Null + if ($tok === "\00") { + $this->parseError("Received NULL character."); + } + // fprintf(STDOUT, "Writing '%s'", $tok); + $this->buffer($tok); + $this->scanner->next(); + return TRUE; } - // Doctype - elseif($tok == 'D' || $tok == 'd') { - return $this->doctype(''); + + /** + * Read text in RAW mode. + */ + protected function rawText() + { + if (is_null($this->untilTag)) { + return $this->text(); + } + $sequence = '</' . $this->untilTag . '>'; + $txt = $this->readUntilSequence($sequence); + $this->events->text($txt); + $this->setTextMode(0); + return $this->endTag(); } - // CDATA section - elseif($tok == '[') { - return $this->cdataSection(); + + /** + * Read text in RCDATA mode. + */ + protected function rcdata() + { + if (is_null($this->untilTag)) { + return $this->text(); + } + $sequence = '</' . $this->untilTag . '>'; + $txt = ''; + $tok = $this->scanner->current(); + while ($tok !== FALSE && ! ($tok == '<' && ($this->sequenceMatches($sequence) || $this->sequenceMatches(strtoupper($sequence))))) { + if ($tok == '&') { + $txt .= $this->decodeCharacterReference(); + $tok = $this->scanner->current(); + } else { + $txt .= $tok; + $tok = $this->scanner->next(); + } + } + $this->events->text($txt); + $this->setTextMode(0); + return $this->endTag(); } - // FINISH - $this->parseError("Expected <!--, <![CDATA[, or <!DOCTYPE. Got <!%s", $tok); - $this->bogusComment('<!'); - return TRUE; - } - - /** - * Consume an end tag. - * 8.2.4.9 - */ - protected function endTag() { - if ($this->scanner->current() != '/') { - return FALSE; + /** + * If the document is read, emit an EOF event. + */ + protected function eof() + { + if ($this->scanner->current() === FALSE) { + // fprintf(STDOUT, "EOF"); + $this->flushBuffer(); + $this->events->eof(); + $this->carryOn = FALSE; + return TRUE; + } + return FALSE; } - $tok = $this->scanner->next(); - - // a-zA-Z -> tagname - // > -> parse error - // EOF -> parse error - // -> parse error - if (!ctype_alpha($tok)) { - $this->parseError("Expected tag name, got '%s'", $tok); - if ($tok == "\0" || $tok === FALSE) { + + /** + * Handle character references (aka entities). + * + * This version is specific to PCDATA, as it buffers data into the + * text buffer. For a generic version, see decodeCharacterReference(). + * + * HTML5 8.2.4.2 + */ + protected function characterReference() + { + $ref = $this->decodeCharacterReference(); + if ($ref !== FALSE) { + $this->buffer($ref); + return TRUE; + } return FALSE; - } - return $this->bogusComment('</'); } - $name = strtolower($this->scanner->charsUntil("\n\f \t>")); - // Trash whitespace. - $this->scanner->whitespace(); + /** + * Emit a tagStart event on encountering a tag. + * + * 8.2.4.8 + */ + protected function tagOpen() + { + if ($this->scanner->current() != '<') { + return FALSE; + } + + // Any buffered text data can go out now. + $this->flushBuffer(); - if ($this->scanner->current() != '>') { - $this->parseError("Expected >, got '%s'", $this->scanner->current()); - // We just trash stuff until we get to the next tag close. - $this->scanner->charsUntil('>'); + $this->scanner->next(); + + return $this->markupDeclaration() || $this->endTag() || $this->processingInstruction() || $this->tagName() || + /* This always returns false. */ + $this->parseError("Illegal tag opening") || $this->characterData(); } - $this->events->endTag($name); - $this->scanner->next(); - return TRUE; + /** + * Look for markup. + */ + protected function markupDeclaration() + { + if ($this->scanner->current() != '!') { + return FALSE; + } - } + $tok = $this->scanner->next(); - /** - * Consume a tag name and body. - * 8.2.4.10 - */ - protected function tagName() { - $tok = $this->scanner->current(); - if (!ctype_alpha($tok)) { - return FALSE; - } + // Comment: + if ($tok == '-' && $this->scanner->peek() == '-') { + $this->scanner->next(); // Consume the other '-' + $this->scanner->next(); // Next char. + return $this->comment(); + } - // We know this is at least one char. - $name = strtolower($this->scanner->charsWhile( - ":0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - )); - $attributes = array(); - $selfClose = FALSE; - - // Handle attribute parse exceptions here so that we can - // react by trying to build a sensible parse tree. - try { - do { - $this->scanner->whitespace(); - $this->attribute($attributes); - } - while (!$this->isTagEnd($selfClose)); - } - catch (ParseError $e) { - $selfClose = FALSE; - } + elseif ($tok == 'D' || $tok == 'd') { // Doctype + return $this->doctype(''); + } - $mode = $this->events->startTag($name, $attributes, $selfClose); - // Should we do this? What does this buy that selfClose doesn't? - if ($selfClose) { - $this->events->endTag($name); - } - elseif (is_int($mode)) { - //fprintf(STDOUT, "Event response says move into mode %d for tag %s", $mode, $name); - $this->setTextMode($mode, $name); - } + elseif ($tok == '[') { // CDATA section + return $this->cdataSection(); + } - $this->scanner->next(); - - return TRUE; - } - - /** - * Check if the scanner has reached the end of a tag. - */ - protected function isTagEnd(&$selfClose) { - $tok = $this->scanner->current(); - if ($tok == '/') { - $this->scanner->next(); - $this->scanner->whitespace(); - if ($this->scanner->current() == '>') { - $selfClose = TRUE; - return TRUE; - } - if ($this->scanner->current() === FALSE) { - $this->parseError("Unexpected EOF inside of tag."); + // FINISH + $this->parseError("Expected <!--, <![CDATA[, or <!DOCTYPE. Got <!%s", $tok); + $this->bogusComment('<!'); return TRUE; - } - // Basically, we skip the / token and go on. - // See 8.2.4.43. - $this->parseError("Unexpected '%s' inside of a tag.", $this->scanner->current()); - return FALSE; } - if ($this->scanner->current() == '>') { - return TRUE; - } - if ($this->scanner->current() === FALSE) { - $this->parseError("Unexpected EOF inside of tag."); - return TRUE; - } + /** + * Consume an end tag. + * 8.2.4.9 + */ + protected function endTag() + { + if ($this->scanner->current() != '/') { + return FALSE; + } + $tok = $this->scanner->next(); - return FALSE; - } + // a-zA-Z -> tagname + // > -> parse error + // EOF -> parse error + // -> parse error + if (! ctype_alpha($tok)) { + $this->parseError("Expected tag name, got '%s'", $tok); + if ($tok == "\0" || $tok === FALSE) { + return FALSE; + } + return $this->bogusComment('</'); + } + $name = strtolower($this->scanner->charsUntil("\n\f \t>")); + // Trash whitespace. + $this->scanner->whitespace(); - /** - * Parse attributes from inside of a tag. - */ - protected function attribute(&$attributes) { - $tok = $this->scanner->current(); - if ($tok == '/' || $tok == '>' || $tok === FALSE) { - return FALSE; - } + if ($this->scanner->current() != '>') { + $this->parseError("Expected >, got '%s'", $this->scanner->current()); + // We just trash stuff until we get to the next tag close. + $this->scanner->charsUntil('>'); + } - if ($tok == '<') { - $this->parseError("Unexepcted '<' inside of attributes list."); - // Push the < back onto the stack. - $this->scanner->unconsume(); - // Let the caller figure out how to handle this. - throw new ParseError("Start tag inside of attribute."); + $this->events->endTag($name); + $this->scanner->next(); + return TRUE; } - $name = strtolower($this->scanner->charsUntil("/>=\n\f\t ")); + /** + * Consume a tag name and body. + * 8.2.4.10 + */ + protected function tagName() + { + $tok = $this->scanner->current(); + if (! ctype_alpha($tok)) { + return FALSE; + } - if (strlen($name) == 0) { - $this->parseError("Expected an attribute name, got %s.", $this->scanner->current()); - // Really, only '=' can be the char here. Everything else gets absorbed - // under one rule or another. - $name = $this->scanner->current(); - $this->scanner->next(); - } + // We know this is at least one char. + $name = strtolower($this->scanner->charsWhile(":0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")); + $attributes = array(); + $selfClose = FALSE; + + // Handle attribute parse exceptions here so that we can + // react by trying to build a sensible parse tree. + try { + do { + $this->scanner->whitespace(); + $this->attribute($attributes); + } while (! $this->isTagEnd($selfClose)); + } catch (ParseError $e) { + $selfClose = FALSE; + } - $isValidAttribute = TRUE; - // Attribute names can contain most Unicode characters for HTML5. - // But method "DOMElement::setAttribute" is throwing exception - // because of it's own internal restriction so these have to be filtered. - // see issue #23: https://github.com/Masterminds/html5-php/issues/23 - // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name - if (preg_match("/[\x1-\x2C\\/\x3B-\x40\x5B-\x5E\x60\x7B-\x7F]/u", $name)) { - $this->parseError("Unexpected characters in attribute name: %s", $name); - $isValidAttribute = FALSE; - } - // There is no limitation for 1st character in HTML5. - // But method "DOMElement::setAttribute" is throwing exception for the - // characters below so they have to be filtered. - // see issue #23: https://github.com/Masterminds/html5-php/issues/23 - // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name - else if (preg_match("/^[0-9.-]/u", $name)) { - $this->parseError("Unexpected character at the begining of attribute name: %s", $name); - $isValidAttribute = FALSE; - } - // 8.1.2.3 - $this->scanner->whitespace(); + $mode = $this->events->startTag($name, $attributes, $selfClose); + // Should we do this? What does this buy that selfClose doesn't? + if ($selfClose) { + $this->events->endTag($name); + } elseif (is_int($mode)) { + // fprintf(STDOUT, "Event response says move into mode %d for tag %s", $mode, $name); + $this->setTextMode($mode, $name); + } - $val = $this->attributeValue(); - if($isValidAttribute) { - $attributes[$name] = $val; - } - return TRUE; - } - - /** - * Consume an attribute value. - * 8.2.4.37 and after. - */ - protected function attributeValue() { - if ($this->scanner->current() != '=') { - return NULL; - } - $this->scanner->next(); - // 8.1.2.3 - $this->scanner->whitespace(); - - $tok = $this->scanner->current(); - switch ($tok) { - case "\n": - case "\f": - case " ": - case "\t": - // Whitespace here indicates an empty value. - return NULL; - case '"': - case "'": - $this->scanner->next(); - return $this->quotedAttributeValue($tok); - case '>': - // case '/': // 8.2.4.37 seems to allow foo=/ as a valid attr. - $this->parseError("Expected attribute value, got tag end."); - return NULL; - case '=': - case '`': - $this->parseError("Expecting quotes, got %s.", $tok); - return $this->unquotedAttributeValue(); - default: - return $this->unquotedAttributeValue(); - } - } - - /** - * Get an attribute value string. - * - * @param string $quote - * IMPORTANT: This is a series of chars! Any one of which will be considered - * termination of an attribute's value. E.g. "\"'" will stop at either - * ' or ". - * @return string - * The attribute value. - */ - protected function quotedAttributeValue($quote) { - $stoplist = "\f" . $quote; - $val = ''; - $tok = $this->scanner->current(); - while (strspn($tok, $stoplist) == 0 && $tok !== FALSE) { - if ($tok == '&') { - $val .= $this->decodeCharacterReference(TRUE); - $tok = $this->scanner->current(); - } - else { - $val .= $tok; - $tok = $this->scanner->next(); - } + $this->scanner->next(); + + return TRUE; } - $this->scanner->next(); - return $val; - } - protected function unquotedAttributeValue() { - $stoplist = "\t\n\f >"; - $val = ''; - $tok = $this->scanner->current(); - while (strspn($tok, $stoplist) == 0 && $tok !== FALSE) { - if ($tok == '&') { - $val .= $this->decodeCharacterReference(TRUE); + + /** + * Check if the scanner has reached the end of a tag. + */ + protected function isTagEnd(&$selfClose) + { $tok = $this->scanner->current(); - } - else { - if(strspn($tok, "\"'<=`") > 0) { - $this->parseError("Unexpected chars in unquoted attribute value %s", $tok); + if ($tok == '/') { + $this->scanner->next(); + $this->scanner->whitespace(); + if ($this->scanner->current() == '>') { + $selfClose = TRUE; + return TRUE; + } + if ($this->scanner->current() === FALSE) { + $this->parseError("Unexpected EOF inside of tag."); + return TRUE; + } + // Basically, we skip the / token and go on. + // See 8.2.4.43. + $this->parseError("Unexpected '%s' inside of a tag.", $this->scanner->current()); + return FALSE; } - $val .= $tok; - $tok = $this->scanner->next(); - } - } - return $val; - } - - - /** - * Consume malformed markup as if it were a comment. - * 8.2.4.44 - * - * The spec requires that the ENTIRE tag-like thing be enclosed inside of - * the comment. So this will generate comments like: - * - * <!--</+foo>--> - * - * @param string $leading - * Prepend any leading characters. This essentially - * negates the need to backtrack, but it's sort of - * a hack. - */ - protected function bogusComment($leading = '') { - - // TODO: This can be done more efficiently when the - // scanner exposes a readUntil() method. - $comment = $leading; - $tok = $this->scanner->current(); - do { - $comment .= $tok; - $tok = $this->scanner->next(); - } while ($tok !== FALSE && $tok != '>'); - - $this->flushBuffer(); - $this->events->comment($comment . $tok); - $this->scanner->next(); - - return TRUE; - } - - /** - * Read a comment. - * - * Expects the first tok to be inside of the comment. - */ - protected function comment() { - $tok = $this->scanner->current(); - $comment = ''; - - // <!-->. Emit an empty comment because 8.2.4.46 says to. - if ($tok == '>') { - // Parse error. Emit the comment token. - $this->parseError("Expected comment data, got '>'"); - $this->events->comment(''); - $this->scanner->next(); - return TRUE; - } - // Replace NULL with the replacement char. - if ($tok == "\0") { - $tok = UTF8Utils::FFFD; - } - while (!$this->isCommentEnd()) { - $comment .= $tok; - $tok = $this->scanner->next(); - } + if ($this->scanner->current() == '>') { + return TRUE; + } + if ($this->scanner->current() === FALSE) { + $this->parseError("Unexpected EOF inside of tag."); + return TRUE; + } - $this->events->comment($comment); - $this->scanner->next(); - return TRUE; - } - - /** - * Check if the scanner has reached the end of a comment. - */ - protected function isCommentEnd() { - // EOF - if($this->scanner->current() === FALSE) { - // Hit the end. - $this->parseError("Unexpected EOF in a comment."); - return TRUE; + return FALSE; } - // If it doesn't start with -, not the end. - if($this->scanner->current() != '-') { - return FALSE; - } + /** + * Parse attributes from inside of a tag. + */ + protected function attribute(&$attributes) + { + $tok = $this->scanner->current(); + if ($tok == '/' || $tok == '>' || $tok === FALSE) { + return FALSE; + } + if ($tok == '<') { + $this->parseError("Unexepcted '<' inside of attributes list."); + // Push the < back onto the stack. + $this->scanner->unconsume(); + // Let the caller figure out how to handle this. + throw new ParseError("Start tag inside of attribute."); + } - // Advance one, and test for '->' - if ($this->scanner->next() == '-' - && $this->scanner->peek() == '>') { - $this->scanner->next(); // Consume the last '>' - return TRUE; - } - // Unread '-'; - $this->scanner->unconsume(1); - return FALSE; - } - - /** - * Parse a DOCTYPE. - * - * Parse a DOCTYPE declaration. This method has strong bearing on whether or - * not Quirksmode is enabled on the event handler. - * - * @todo This method is a little long. Should probably refactor. - */ - protected function doctype() { - if (strcasecmp($this->scanner->current(), 'D')) { - return FALSE; - } - // Check that string is DOCTYPE. - $chars = $this->scanner->charsWhile("DOCTYPEdoctype"); - if (strcasecmp($chars, 'DOCTYPE')) { - $this->parseError('Expected DOCTYPE, got %s', $chars); - return $this->bogusComment('<!' . $chars); - } + $name = strtolower($this->scanner->charsUntil("/>=\n\f\t ")); - $this->scanner->whitespace(); - $tok = $this->scanner->current(); + if (strlen($name) == 0) { + $this->parseError("Expected an attribute name, got %s.", $this->scanner->current()); + // Really, only '=' can be the char here. Everything else gets absorbed + // under one rule or another. + $name = $this->scanner->current(); + $this->scanner->next(); + } + + $isValidAttribute = TRUE; + // Attribute names can contain most Unicode characters for HTML5. + // But method "DOMElement::setAttribute" is throwing exception + // because of it's own internal restriction so these have to be filtered. + // see issue #23: https://github.com/Masterminds/html5-php/issues/23 + // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name + if (preg_match("/[\x1-\x2C\\/\x3B-\x40\x5B-\x5E\x60\x7B-\x7F]/u", $name)) { + $this->parseError("Unexpected characters in attribute name: %s", $name); + $isValidAttribute = FALSE; + } // There is no limitation for 1st character in HTML5. + // But method "DOMElement::setAttribute" is throwing exception for the + // characters below so they have to be filtered. + // see issue #23: https://github.com/Masterminds/html5-php/issues/23 + // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name + else + if (preg_match("/^[0-9.-]/u", $name)) { + $this->parseError("Unexpected character at the begining of attribute name: %s", $name); + $isValidAttribute = FALSE; + } + // 8.1.2.3 + $this->scanner->whitespace(); - // EOF: die. - if ($tok === FALSE) { - $this->events->doctype('html5',EventHandler::DOCTYPE_NONE,'', TRUE); - return $this->eof(); + $val = $this->attributeValue(); + if ($isValidAttribute) { + $attributes[$name] = $val; + } + return TRUE; } - $doctypeName = ''; + /** + * Consume an attribute value. + * 8.2.4.37 and after. + */ + protected function attributeValue() + { + if ($this->scanner->current() != '=') { + return NULL; + } + $this->scanner->next(); + // 8.1.2.3 + $this->scanner->whitespace(); - // NULL char: convert. - if ($tok === "\0") { - $this->parseError("Unexpected NULL character in DOCTYPE."); - $doctypeName .= UTF8::FFFD; - $tok = $this->scanner->next(); + $tok = $this->scanner->current(); + switch ($tok) { + case "\n": + case "\f": + case " ": + case "\t": + // Whitespace here indicates an empty value. + return NULL; + case '"': + case "'": + $this->scanner->next(); + return $this->quotedAttributeValue($tok); + case '>': + // case '/': // 8.2.4.37 seems to allow foo=/ as a valid attr. + $this->parseError("Expected attribute value, got tag end."); + return NULL; + case '=': + case '`': + $this->parseError("Expecting quotes, got %s.", $tok); + return $this->unquotedAttributeValue(); + default: + return $this->unquotedAttributeValue(); + } } - $stop = " \n\f>"; - $doctypeName = $this->scanner->charsUntil($stop); - // Lowercase ASCII, replace \0 with FFFD - $doctypeName = strtolower(strtr($doctypeName, "\0", UTF8Utils::FFFD)); + /** + * Get an attribute value string. + * + * @param string $quote + * IMPORTANT: This is a series of chars! Any one of which will be considered + * termination of an attribute's value. E.g. "\"'" will stop at either + * ' or ". + * @return string The attribute value. + */ + protected function quotedAttributeValue($quote) + { + $stoplist = "\f" . $quote; + $val = ''; + $tok = $this->scanner->current(); + while (strspn($tok, $stoplist) == 0 && $tok !== FALSE) { + if ($tok == '&') { + $val .= $this->decodeCharacterReference(TRUE); + $tok = $this->scanner->current(); + } else { + $val .= $tok; + $tok = $this->scanner->next(); + } + } + $this->scanner->next(); + return $val; + } - $tok = $this->scanner->current(); + protected function unquotedAttributeValue() + { + $stoplist = "\t\n\f >"; + $val = ''; + $tok = $this->scanner->current(); + while (strspn($tok, $stoplist) == 0 && $tok !== FALSE) { + if ($tok == '&') { + $val .= $this->decodeCharacterReference(TRUE); + $tok = $this->scanner->current(); + } else { + if (strspn($tok, "\"'<=`") > 0) { + $this->parseError("Unexpected chars in unquoted attribute value %s", $tok); + } + $val .= $tok; + $tok = $this->scanner->next(); + } + } + return $val; + } + + /** + * Consume malformed markup as if it were a comment. + * 8.2.4.44 + * + * The spec requires that the ENTIRE tag-like thing be enclosed inside of + * the comment. So this will generate comments like: + * + * <!--</+foo>--> + * + * @param string $leading + * Prepend any leading characters. This essentially + * negates the need to backtrack, but it's sort of + * a hack. + */ + protected function bogusComment($leading = '') + { - // If FALSE, emit a parse error, DOCTYPE, and return. - if ($tok === FALSE) { - $this->parseError('Unexpected EOF in DOCTYPE declaration.'); - $this->events->doctype($doctypeName, EventHandler::DOCTYPE_NONE, NULL, TRUE); - return TRUE; - } + // TODO: This can be done more efficiently when the + // scanner exposes a readUntil() method. + $comment = $leading; + $tok = $this->scanner->current(); + do { + $comment .= $tok; + $tok = $this->scanner->next(); + } while ($tok !== FALSE && $tok != '>'); - // Short DOCTYPE, like <!DOCTYPE html> - if ($tok == '>') { - // DOCTYPE without a name. - if (strlen($doctypeName) == 0) { - $this->parseError("Expected a DOCTYPE name. Got nothing."); - $this->events->doctype($doctypeName, 0, NULL, TRUE); + $this->flushBuffer(); + $this->events->comment($comment . $tok); $this->scanner->next(); + return TRUE; - } - $this->events->doctype($doctypeName); - $this->scanner->next(); - return TRUE; } - $this->scanner->whitespace(); - - $pub = strtoupper($this->scanner->getAsciiAlpha()); - $white = strlen($this->scanner->whitespace()); - $tok = $this->scanner->current(); - - // Get ID, and flag it as pub or system. - if (($pub == 'PUBLIC' || $pub == 'SYSTEM') && $white > 0) { - // Get the sys ID. - $type = $pub == 'PUBLIC' ? EventHandler::DOCTYPE_PUBLIC : EventHandler::DOCTYPE_SYSTEM; - $id = $this->quotedString("\0>"); - if ($id === FALSE) { - $this->events->doctype($doctypeName, $type, $pub, FALSE); - return FALSE; - } - // Premature EOF. - if ($this->scanner->current() === FALSE) { - $this->parseError("Unexpected EOF in DOCTYPE"); - $this->events->doctype($doctypeName, $type, $id, TRUE); - return TRUE; - } + /** + * Read a comment. + * + * Expects the first tok to be inside of the comment. + */ + protected function comment() + { + $tok = $this->scanner->current(); + $comment = ''; + + // <!-->. Emit an empty comment because 8.2.4.46 says to. + if ($tok == '>') { + // Parse error. Emit the comment token. + $this->parseError("Expected comment data, got '>'"); + $this->events->comment(''); + $this->scanner->next(); + return TRUE; + } + + // Replace NULL with the replacement char. + if ($tok == "\0") { + $tok = UTF8Utils::FFFD; + } + while (! $this->isCommentEnd()) { + $comment .= $tok; + $tok = $this->scanner->next(); + } - // Well-formed complete DOCTYPE. - $this->scanner->whitespace(); - if ($this->scanner->current() == '>') { - $this->events->doctype($doctypeName, $type, $id, FALSE); + $this->events->comment($comment); $this->scanner->next(); return TRUE; - } - - // If we get here, we have <!DOCTYPE foo PUBLIC "bar" SOME_JUNK - // Throw away the junk, parse error, quirks mode, return TRUE. - $this->scanner->charsUntil(">"); - $this->parseError("Malformed DOCTYPE."); - $this->events->doctype($doctypeName, $type, $id, TRUE); - $this->scanner->next(); - return TRUE; } - // Else it's a bogus DOCTYPE. - // Consume to > and trash. - $this->scanner->charsUntil('>'); - - $this->parseError("Expected PUBLIC or SYSTEM. Got %s.", $pub); - $this->events->doctype($doctypeName, 0, NULL, TRUE); - $this->scanner->next(); - return TRUE; - - } - - /** - * Utility for reading a quoted string. - * - * @param string $stopchars - * Characters (in addition to a close-quote) that should stop the string. - * E.g. sometimes '>' is higher precedence than '"' or "'". - * @return mixed - * String if one is found (quotations omitted) - */ - protected function quotedString($stopchars) { - $tok = $this->scanner->current(); - if ($tok == '"' || $tok == "'") { - $this->scanner->next(); - $ret = $this->scanner->charsUntil($tok . $stopchars); - if ($this->scanner->current() == $tok) { - $this->scanner->next(); - } - else { - // Parse error because no close quote. - $this->parseError("Expected %s, got %s", $tok, $this->scanner->current()); - } - return $ret; - } - return FALSE; - } + /** + * Check if the scanner has reached the end of a comment. + */ + protected function isCommentEnd() + { + // EOF + if ($this->scanner->current() === FALSE) { + // Hit the end. + $this->parseError("Unexpected EOF in a comment."); + return TRUE; + } + // If it doesn't start with -, not the end. + if ($this->scanner->current() != '-') { + return FALSE; + } - /** - * Handle a CDATA section. - */ - protected function cdataSection() { - if ($this->scanner->current() != '[') { - return FALSE; + // Advance one, and test for '->' + if ($this->scanner->next() == '-' && $this->scanner->peek() == '>') { + $this->scanner->next(); // Consume the last '>' + return TRUE; + } + // Unread '-'; + $this->scanner->unconsume(1); + return FALSE; } - $cdata = ''; - $this->scanner->next(); - $chars = $this->scanner->charsWhile('CDAT'); - if ($chars != 'CDATA' || $this->scanner->current() != '[') { - $this->parseError('Expected [CDATA[, got %s', $chars); - return $this->bogusComment('<![' . $chars); - } + /** + * Parse a DOCTYPE. + * + * Parse a DOCTYPE declaration. This method has strong bearing on whether or + * not Quirksmode is enabled on the event handler. + * + * @todo This method is a little long. Should probably refactor. + */ + protected function doctype() + { + if (strcasecmp($this->scanner->current(), 'D')) { + return FALSE; + } + // Check that string is DOCTYPE. + $chars = $this->scanner->charsWhile("DOCTYPEdoctype"); + if (strcasecmp($chars, 'DOCTYPE')) { + $this->parseError('Expected DOCTYPE, got %s', $chars); + return $this->bogusComment('<!' . $chars); + } - $tok = $this->scanner->next(); - do { - if ($tok === FALSE) { - $this->parseError('Unexpected EOF inside CDATA.'); - $this->bogusComment('<![CDATA[' . $cdata); - return TRUE; - } - $cdata .= $tok; - $tok = $this->scanner->next(); - } - while (!$this->sequenceMatches(']]>')); - - // Consume ]]> - $this->scanner->consume(3); - - $this->events->cdata($cdata); - return TRUE; - - } - - // ================================================================ - // Non-HTML5 - // ================================================================ - /** - * Handle a processing instruction. - * - * XML processing instructions are supposed to be ignored in HTML5, - * treated as "bogus comments". However, since we're not a user - * agent, we allow them. We consume until ?> and then issue a - * EventListener::processingInstruction() event. - */ - protected function processingInstruction() { - if ($this->scanner->current() != '?') { - return FALSE; - } + $this->scanner->whitespace(); + $tok = $this->scanner->current(); - $tok = $this->scanner->next(); - $procName = $this->scanner->getAsciiAlpha(); - $white = strlen($this->scanner->whitespace()); + // EOF: die. + if ($tok === FALSE) { + $this->events->doctype('html5', EventHandler::DOCTYPE_NONE, '', TRUE); + return $this->eof(); + } - // If not a PI, send to bogusComment. - if (strlen($procName) == 0 || $white == 0 || $this->scanner->current() == FALSE) { - $this->parseError("Expected processing instruction name, got $tok"); - $this->bogusComment('<?' . $tok . $procName); - return TRUE; - } + $doctypeName = ''; - $data = ''; - // As long as it's not the case that the next two chars are ? and >. - while (!($this->scanner->current() == '?' && $this->scanner->peek() == '>')) { - $data .= $this->scanner->current(); + // NULL char: convert. + if ($tok === "\0") { + $this->parseError("Unexpected NULL character in DOCTYPE."); + $doctypeName .= UTF8::FFFD; + $tok = $this->scanner->next(); + } - $tok = $this->scanner->next(); - if ($tok === FALSE) { - $this->parseError("Unexpected EOF in processing instruction."); - $this->events->processingInstruction($procName, $data); - return TRUE; - } + $stop = " \n\f>"; + $doctypeName = $this->scanner->charsUntil($stop); + // Lowercase ASCII, replace \0 with FFFD + $doctypeName = strtolower(strtr($doctypeName, "\0", UTF8Utils::FFFD)); - } + $tok = $this->scanner->current(); - $this->scanner->next(); // > - $this->scanner->next(); // Next token. - $this->events->processingInstruction($procName, $data); - return TRUE; - } + // If FALSE, emit a parse error, DOCTYPE, and return. + if ($tok === FALSE) { + $this->parseError('Unexpected EOF in DOCTYPE declaration.'); + $this->events->doctype($doctypeName, EventHandler::DOCTYPE_NONE, NULL, TRUE); + return TRUE; + } + // Short DOCTYPE, like <!DOCTYPE html> + if ($tok == '>') { + // DOCTYPE without a name. + if (strlen($doctypeName) == 0) { + $this->parseError("Expected a DOCTYPE name. Got nothing."); + $this->events->doctype($doctypeName, 0, NULL, TRUE); + $this->scanner->next(); + return TRUE; + } + $this->events->doctype($doctypeName); + $this->scanner->next(); + return TRUE; + } + $this->scanner->whitespace(); - // ================================================================ - // UTILITY FUNCTIONS - // ================================================================ + $pub = strtoupper($this->scanner->getAsciiAlpha()); + $white = strlen($this->scanner->whitespace()); + $tok = $this->scanner->current(); - /** - * Read from the input stream until we get to the desired sequene - * or hit the end of the input stream. - */ - protected function readUntilSequence($sequence) { - $buffer = ''; + // Get ID, and flag it as pub or system. + if (($pub == 'PUBLIC' || $pub == 'SYSTEM') && $white > 0) { + // Get the sys ID. + $type = $pub == 'PUBLIC' ? EventHandler::DOCTYPE_PUBLIC : EventHandler::DOCTYPE_SYSTEM; + $id = $this->quotedString("\0>"); + if ($id === FALSE) { + $this->events->doctype($doctypeName, $type, $pub, FALSE); + return FALSE; + } + + // Premature EOF. + if ($this->scanner->current() === FALSE) { + $this->parseError("Unexpected EOF in DOCTYPE"); + $this->events->doctype($doctypeName, $type, $id, TRUE); + return TRUE; + } + + // Well-formed complete DOCTYPE. + $this->scanner->whitespace(); + if ($this->scanner->current() == '>') { + $this->events->doctype($doctypeName, $type, $id, FALSE); + $this->scanner->next(); + return TRUE; + } + + // If we get here, we have <!DOCTYPE foo PUBLIC "bar" SOME_JUNK + // Throw away the junk, parse error, quirks mode, return TRUE. + $this->scanner->charsUntil(">"); + $this->parseError("Malformed DOCTYPE."); + $this->events->doctype($doctypeName, $type, $id, TRUE); + $this->scanner->next(); + return TRUE; + } - // Optimization for reading larger blocks faster. - $first = substr($sequence, 0, 1); - while ($this->scanner->current() !== FALSE) { - $buffer .= $this->scanner->charsUntil($first); + // Else it's a bogus DOCTYPE. + // Consume to > and trash. + $this->scanner->charsUntil('>'); - // Stop as soon as we hit the stopping condition. - if ($this->sequenceMatches($sequence) || $this->sequenceMatches(strtoupper($sequence))) { - return $buffer; - } - $buffer .= $this->scanner->current(); - $this->scanner->next(); + $this->parseError("Expected PUBLIC or SYSTEM. Got %s.", $pub); + $this->events->doctype($doctypeName, 0, NULL, TRUE); + $this->scanner->next(); + return TRUE; } - // If we get here, we hit the EOF. - $this->parseError("Unexpected EOF during text read."); - return $buffer; - } - - /** - * Check if upcomming chars match the given sequence. - * - * This will read the stream for the $sequence. If it's - * found, this will return TRUE. If not, return FALSE. - * Since this unconsumes any chars it reads, the caller - * will still need to read the next sequence, even if - * this returns TRUE. - * - * Example: $this->sequenceMatches('</script>') will - * see if the input stream is at the start of a - * '</script>' string. - */ - protected function sequenceMatches($sequence) { - $len = strlen($sequence); - $buffer = ''; - for ($i = 0; $i < $len; ++$i) { - $buffer .= $this->scanner->current(); - - // EOF. Rewind and let the caller handle it. - if ($this->scanner->current() === FALSE) { - $this->scanner->unconsume($i); + /** + * Utility for reading a quoted string. + * + * @param string $stopchars + * Characters (in addition to a close-quote) that should stop the string. + * E.g. sometimes '>' is higher precedence than '"' or "'". + * @return mixed String if one is found (quotations omitted) + */ + protected function quotedString($stopchars) + { + $tok = $this->scanner->current(); + if ($tok == '"' || $tok == "'") { + $this->scanner->next(); + $ret = $this->scanner->charsUntil($tok . $stopchars); + if ($this->scanner->current() == $tok) { + $this->scanner->next(); + } else { + // Parse error because no close quote. + $this->parseError("Expected %s, got %s", $tok, $this->scanner->current()); + } + return $ret; + } return FALSE; - } - $this->scanner->next(); } - $this->scanner->unconsume($len); - return $buffer == $sequence; - - } - - /** - * Send a TEXT event with the contents of the text buffer. - * - * This emits an EventHandler::text() event with the current contents of the - * temporary text buffer. (The buffer is used to group as much PCDATA - * as we can instead of emitting lots and lots of TEXT events.) - */ - protected function flushBuffer() { - if (empty($this->text)) { - return; - } - $this->events->text($this->text); - $this->text = ''; - } - - /** - * Add text to the temporary buffer. - * - * @see flushBuffer() - */ - protected function buffer($str) { - $this->text .= $str; - } - - /** - * Emit a parse error. - * - * A parse error always returns FALSE because it never consumes any - * characters. - */ - protected function parseError($msg) { - $args = func_get_args(); - - if (count($args) > 1) { - array_shift($args); - $msg = vsprintf($msg, $args); - } + /** + * Handle a CDATA section. + */ + protected function cdataSection() + { + if ($this->scanner->current() != '[') { + return FALSE; + } + $cdata = ''; + $this->scanner->next(); + + $chars = $this->scanner->charsWhile('CDAT'); + if ($chars != 'CDATA' || $this->scanner->current() != '[') { + $this->parseError('Expected [CDATA[, got %s', $chars); + return $this->bogusComment('<![' . $chars); + } - $line = $this->scanner->currentLine(); - $col = $this->scanner->columnOffset(); - $this->events->parseError($msg, $line, $col); - return FALSE; - } - - /** - * Decode a character reference and return the string. - * - * Returns FALSE if the entity could not be found. If $inAttribute is set - * to TRUE, a bare & will be returned as-is. - * - * @param boolean $inAttribute - * Set to TRUE if the text is inside of an attribute value. - * FALSE otherwise. - */ - protected function decodeCharacterReference($inAttribute = FALSE) { - - // If it fails this, it's definitely not an entity. - if ($this->scanner->current() != '&') { - return FALSE; + $tok = $this->scanner->next(); + do { + if ($tok === FALSE) { + $this->parseError('Unexpected EOF inside CDATA.'); + $this->bogusComment('<![CDATA[' . $cdata); + return TRUE; + } + $cdata .= $tok; + $tok = $this->scanner->next(); + } while (! $this->sequenceMatches(']]>')); + + // Consume ]]> + $this->scanner->consume(3); + + $this->events->cdata($cdata); + return TRUE; } - // Next char after &. - $tok = $this->scanner->next(); - $entity = ''; - $start = $this->scanner->position(); + // ================================================================ + // Non-HTML5 + // ================================================================ + /** + * Handle a processing instruction. + * + * XML processing instructions are supposed to be ignored in HTML5, + * treated as "bogus comments". However, since we're not a user + * agent, we allow them. We consume until ?> and then issue a + * EventListener::processingInstruction() event. + */ + protected function processingInstruction() + { + if ($this->scanner->current() != '?') { + return FALSE; + } + + $tok = $this->scanner->next(); + $procName = $this->scanner->getAsciiAlpha(); + $white = strlen($this->scanner->whitespace()); + + // If not a PI, send to bogusComment. + if (strlen($procName) == 0 || $white == 0 || $this->scanner->current() == FALSE) { + $this->parseError("Expected processing instruction name, got $tok"); + $this->bogusComment('<?' . $tok . $procName); + return TRUE; + } - if ($tok == FALSE) { - return '&'; + $data = ''; + // As long as it's not the case that the next two chars are ? and >. + while (! ($this->scanner->current() == '?' && $this->scanner->peek() == '>')) { + $data .= $this->scanner->current(); + + $tok = $this->scanner->next(); + if ($tok === FALSE) { + $this->parseError("Unexpected EOF in processing instruction."); + $this->events->processingInstruction($procName, $data); + return TRUE; + } + } + + $this->scanner->next(); // > + $this->scanner->next(); // Next token. + $this->events->processingInstruction($procName, $data); + return TRUE; } - // These indicate not an entity. We return just - // the &. - if (strspn($tok, static::WHITE . "&<") == 1) { - //$this->scanner->next(); - return '&'; + // ================================================================ + // UTILITY FUNCTIONS + // ================================================================ + + /** + * Read from the input stream until we get to the desired sequene + * or hit the end of the input stream. + */ + protected function readUntilSequence($sequence) + { + $buffer = ''; + + // Optimization for reading larger blocks faster. + $first = substr($sequence, 0, 1); + while ($this->scanner->current() !== FALSE) { + $buffer .= $this->scanner->charsUntil($first); + + // Stop as soon as we hit the stopping condition. + if ($this->sequenceMatches($sequence) || $this->sequenceMatches(strtoupper($sequence))) { + return $buffer; + } + $buffer .= $this->scanner->current(); + $this->scanner->next(); + } + + // If we get here, we hit the EOF. + $this->parseError("Unexpected EOF during text read."); + return $buffer; } - // Numeric entity - if ($tok == '#') { - $tok = $this->scanner->next(); - - // Hexidecimal encoding. - // X[0-9a-fA-F]+; - // x[0-9a-fA-F]+; - if ($tok == 'x' || $tok == 'X') { - $tok = $this->scanner->next(); // Consume x - - // Convert from hex code to char. - $hex = $this->scanner->getHex(); - if (empty($hex)) { - $this->parseError("Expected &#xHEX;, got &#x%s", $tok); - // We unconsume because we don't know what parser rules might - // be in effect for the remaining chars. For example. '&#>' - // might result in a specific parsing rule inside of tag - // contexts, while not inside of pcdata context. - $this->scanner->unconsume(2); - return '&'; + /** + * Check if upcomming chars match the given sequence. + * + * This will read the stream for the $sequence. If it's + * found, this will return TRUE. If not, return FALSE. + * Since this unconsumes any chars it reads, the caller + * will still need to read the next sequence, even if + * this returns TRUE. + * + * Example: $this->sequenceMatches('</script>') will + * see if the input stream is at the start of a + * '</script>' string. + */ + protected function sequenceMatches($sequence) + { + $len = strlen($sequence); + $buffer = ''; + for ($i = 0; $i < $len; ++ $i) { + $buffer .= $this->scanner->current(); + + // EOF. Rewind and let the caller handle it. + if ($this->scanner->current() === FALSE) { + $this->scanner->unconsume($i); + return FALSE; + } + $this->scanner->next(); } - $entity = CharacterReference::lookupHex($hex); - } - // Decimal encoding. - // [0-9]+; - else { - // Convert from decimal to char. - $numeric = $this->scanner->getNumeric(); - if ($numeric === FALSE) { - $this->parseError("Expected &#DIGITS;, got &#%s", $tok); - $this->scanner->unconsume(2); - return '&'; + + $this->scanner->unconsume($len); + return $buffer == $sequence; + } + + /** + * Send a TEXT event with the contents of the text buffer. + * + * This emits an EventHandler::text() event with the current contents of the + * temporary text buffer. (The buffer is used to group as much PCDATA + * as we can instead of emitting lots and lots of TEXT events.) + */ + protected function flushBuffer() + { + if (empty($this->text)) { + return; } - $entity = CharacterReference::lookupDecimal($numeric); - } + $this->events->text($this->text); + $this->text = ''; } - // String entity. - else { - // Attempt to consume a string up to a ';'. - // [a-zA-Z0-9]+; - $cname = $this->scanner->getAsciiAlpha(); - $entity = CharacterReference::lookupName($cname); - if ($entity == NULL) { - $this->parseError("No match in entity table for '%s'", $entity); - } + + /** + * Add text to the temporary buffer. + * + * @see flushBuffer() + */ + protected function buffer($str) + { + $this->text .= $str; } - // The scanner has advanced the cursor for us. - $tok = $this->scanner->current(); + /** + * Emit a parse error. + * + * A parse error always returns FALSE because it never consumes any + * characters. + */ + protected function parseError($msg) + { + $args = func_get_args(); + + if (count($args) > 1) { + array_shift($args); + $msg = vsprintf($msg, $args); + } - // We have an entity. We're done here. - if ($tok == ';') { - $this->scanner->next(); - return $entity; + $line = $this->scanner->currentLine(); + $col = $this->scanner->columnOffset(); + $this->events->parseError($msg, $line, $col); + return FALSE; } - // If in an attribute, then failing to match ; means unconsume the - // entire string. Otherwise, failure to match is an error. - if ($inAttribute) { - $this->scanner->unconsume($this->scanner->position() - $start); - return '&'; - } + /** + * Decode a character reference and return the string. + * + * Returns FALSE if the entity could not be found. If $inAttribute is set + * to TRUE, a bare & will be returned as-is. + * + * @param boolean $inAttribute + * Set to TRUE if the text is inside of an attribute value. + * FALSE otherwise. + */ + protected function decodeCharacterReference($inAttribute = FALSE) + { - $this->parseError("Expected &ENTITY;, got &ENTITY%s (no trailing ;) ", $tok); - return '&' . $entity; + // If it fails this, it's definitely not an entity. + if ($this->scanner->current() != '&') { + return FALSE; + } - } + // Next char after &. + $tok = $this->scanner->next(); + $entity = ''; + $start = $this->scanner->position(); + if ($tok == FALSE) { + return '&'; + } + + // These indicate not an entity. We return just + // the &. + if (strspn($tok, static::WHITE . "&<") == 1) { + // $this->scanner->next(); + return '&'; + } + + // Numeric entity + if ($tok == '#') { + $tok = $this->scanner->next(); + + // Hexidecimal encoding. + // X[0-9a-fA-F]+; + // x[0-9a-fA-F]+; + if ($tok == 'x' || $tok == 'X') { + $tok = $this->scanner->next(); // Consume x + + // Convert from hex code to char. + $hex = $this->scanner->getHex(); + if (empty($hex)) { + $this->parseError("Expected &#xHEX;, got &#x%s", $tok); + // We unconsume because we don't know what parser rules might + // be in effect for the remaining chars. For example. '&#>' + // might result in a specific parsing rule inside of tag + // contexts, while not inside of pcdata context. + $this->scanner->unconsume(2); + return '&'; + } + $entity = CharacterReference::lookupHex($hex); + } // Decimal encoding. + // [0-9]+; + else { + // Convert from decimal to char. + $numeric = $this->scanner->getNumeric(); + if ($numeric === FALSE) { + $this->parseError("Expected &#DIGITS;, got &#%s", $tok); + $this->scanner->unconsume(2); + return '&'; + } + $entity = CharacterReference::lookupDecimal($numeric); + } + } // String entity. + else { + // Attempt to consume a string up to a ';'. + // [a-zA-Z0-9]+; + $cname = $this->scanner->getAsciiAlpha(); + $entity = CharacterReference::lookupName($cname); + if ($entity == NULL) { + $this->parseError("No match in entity table for '%s'", $entity); + } + } + + // The scanner has advanced the cursor for us. + $tok = $this->scanner->current(); + + // We have an entity. We're done here. + if ($tok == ';') { + $this->scanner->next(); + return $entity; + } + + // If in an attribute, then failing to match ; means unconsume the + // entire string. Otherwise, failure to match is an error. + if ($inAttribute) { + $this->scanner->unconsume($this->scanner->position() - $start); + return '&'; + } + + $this->parseError("Expected &ENTITY;, got &ENTITY%s (no trailing ;) ", $tok); + return '&' . $entity; + } } |