annotate vendor/masterminds/html5/src/HTML5/Parser/Tokenizer.php @ 0:4c8ae668cc8c

Initial import (non-working)
author Chris Cannam
date Wed, 29 Nov 2017 16:09:58 +0000
parents
children 129ea1e6d783
rev   line source
Chris@0 1 <?php
Chris@0 2 namespace Masterminds\HTML5\Parser;
Chris@0 3
Chris@0 4 use Masterminds\HTML5\Elements;
Chris@0 5
Chris@0 6 /**
Chris@0 7 * The HTML5 tokenizer.
Chris@0 8 *
Chris@0 9 * The tokenizer's role is reading data from the scanner and gathering it into
Chris@0 10 * semantic units. From the tokenizer, data is emitted to an event handler,
Chris@0 11 * which may (for example) create a DOM tree.
Chris@0 12 *
Chris@0 13 * The HTML5 specification has a detailed explanation of tokenizing HTML5. We
Chris@0 14 * follow that specification to the maximum extent that we can. If you find
Chris@0 15 * a discrepancy that is not documented, please file a bug and/or submit a
Chris@0 16 * patch.
Chris@0 17 *
Chris@0 18 * This tokenizer is implemented as a recursive descent parser.
Chris@0 19 *
Chris@0 20 * Within the API documentation, you may see references to the specific section
Chris@0 21 * of the HTML5 spec that the code attempts to reproduce. Example: 8.2.4.1.
Chris@0 22 * This refers to section 8.2.4.1 of the HTML5 CR specification.
Chris@0 23 *
Chris@0 24 * @see http://www.w3.org/TR/2012/CR-html5-20121217/
Chris@0 25 */
Chris@0 26 class Tokenizer
Chris@0 27 {
Chris@0 28
Chris@0 29 protected $scanner;
Chris@0 30
Chris@0 31 protected $events;
Chris@0 32
Chris@0 33 protected $tok;
Chris@0 34
Chris@0 35 /**
Chris@0 36 * Buffer for text.
Chris@0 37 */
Chris@0 38 protected $text = '';
Chris@0 39
Chris@0 40 // When this goes to false, the parser stops.
Chris@0 41 protected $carryOn = true;
Chris@0 42
Chris@0 43 protected $textMode = 0; // TEXTMODE_NORMAL;
Chris@0 44 protected $untilTag = null;
Chris@0 45
Chris@0 46 const CONFORMANT_XML = 'xml';
Chris@0 47 const CONFORMANT_HTML = 'html';
Chris@0 48 protected $mode = self::CONFORMANT_HTML;
Chris@0 49
Chris@0 50 const WHITE = "\t\n\f ";
Chris@0 51
Chris@0 52 /**
Chris@0 53 * Create a new tokenizer.
Chris@0 54 *
Chris@0 55 * Typically, parsing a document involves creating a new tokenizer, giving
Chris@0 56 * it a scanner (input) and an event handler (output), and then calling
Chris@0 57 * the Tokenizer::parse() method.`
Chris@0 58 *
Chris@0 59 * @param \Masterminds\HTML5\Parser\Scanner $scanner
Chris@0 60 * A scanner initialized with an input stream.
Chris@0 61 * @param \Masterminds\HTML5\Parser\EventHandler $eventHandler
Chris@0 62 * An event handler, initialized and ready to receive
Chris@0 63 * events.
Chris@0 64 * @param string $mode
Chris@0 65 */
Chris@0 66 public function __construct($scanner, $eventHandler, $mode = self::CONFORMANT_HTML)
Chris@0 67 {
Chris@0 68 $this->scanner = $scanner;
Chris@0 69 $this->events = $eventHandler;
Chris@0 70 $this->mode = $mode;
Chris@0 71 }
Chris@0 72
Chris@0 73 /**
Chris@0 74 * Begin parsing.
Chris@0 75 *
Chris@0 76 * This will begin scanning the document, tokenizing as it goes.
Chris@0 77 * Tokens are emitted into the event handler.
Chris@0 78 *
Chris@0 79 * Tokenizing will continue until the document is completely
Chris@0 80 * read. Errors are emitted into the event handler, but
Chris@0 81 * the parser will attempt to continue parsing until the
Chris@0 82 * entire input stream is read.
Chris@0 83 */
Chris@0 84 public function parse()
Chris@0 85 {
Chris@0 86 do {
Chris@0 87 $this->consumeData();
Chris@0 88 // FIXME: Add infinite loop protection.
Chris@0 89 } while ($this->carryOn);
Chris@0 90 }
Chris@0 91
Chris@0 92 /**
Chris@0 93 * Set the text mode for the character data reader.
Chris@0 94 *
Chris@0 95 * HTML5 defines three different modes for reading text:
Chris@0 96 * - Normal: Read until a tag is encountered.
Chris@0 97 * - RCDATA: Read until a tag is encountered, but skip a few otherwise-
Chris@0 98 * special characters.
Chris@0 99 * - Raw: Read until a special closing tag is encountered (viz. pre, script)
Chris@0 100 *
Chris@0 101 * This allows those modes to be set.
Chris@0 102 *
Chris@0 103 * Normally, setting is done by the event handler via a special return code on
Chris@0 104 * startTag(), but it can also be set manually using this function.
Chris@0 105 *
Chris@0 106 * @param integer $textmode
Chris@0 107 * One of Elements::TEXT_*
Chris@0 108 * @param string $untilTag
Chris@0 109 * The tag that should stop RAW or RCDATA mode. Normal mode does not
Chris@0 110 * use this indicator.
Chris@0 111 */
Chris@0 112 public function setTextMode($textmode, $untilTag = null)
Chris@0 113 {
Chris@0 114 $this->textMode = $textmode & (Elements::TEXT_RAW | Elements::TEXT_RCDATA);
Chris@0 115 $this->untilTag = $untilTag;
Chris@0 116 }
Chris@0 117
Chris@0 118 /**
Chris@0 119 * Consume a character and make a move.
Chris@0 120 * HTML5 8.2.4.1
Chris@0 121 */
Chris@0 122 protected function consumeData()
Chris@0 123 {
Chris@0 124 // Character Ref
Chris@0 125 /*
Chris@0 126 * $this->characterReference() || $this->tagOpen() || $this->eof() || $this->characterData();
Chris@0 127 */
Chris@0 128 $this->characterReference();
Chris@0 129 $this->tagOpen();
Chris@0 130 $this->eof();
Chris@0 131 $this->characterData();
Chris@0 132
Chris@0 133 return $this->carryOn;
Chris@0 134 }
Chris@0 135
Chris@0 136 /**
Chris@0 137 * Parse anything that looks like character data.
Chris@0 138 *
Chris@0 139 * Different rules apply based on the current text mode.
Chris@0 140 *
Chris@0 141 * @see Elements::TEXT_RAW Elements::TEXT_RCDATA.
Chris@0 142 */
Chris@0 143 protected function characterData()
Chris@0 144 {
Chris@0 145 $tok = $this->scanner->current();
Chris@0 146 if ($tok === false) {
Chris@0 147 return false;
Chris@0 148 }
Chris@0 149 switch ($this->textMode) {
Chris@0 150 case Elements::TEXT_RAW:
Chris@0 151 return $this->rawText();
Chris@0 152 case Elements::TEXT_RCDATA:
Chris@0 153 return $this->rcdata();
Chris@0 154 default:
Chris@0 155 if (strspn($tok, "<&")) {
Chris@0 156 return false;
Chris@0 157 }
Chris@0 158 return $this->text();
Chris@0 159 }
Chris@0 160 }
Chris@0 161
Chris@0 162 /**
Chris@0 163 * This buffers the current token as character data.
Chris@0 164 */
Chris@0 165 protected function text()
Chris@0 166 {
Chris@0 167 $tok = $this->scanner->current();
Chris@0 168
Chris@0 169 // This should never happen...
Chris@0 170 if ($tok === false) {
Chris@0 171 return false;
Chris@0 172 }
Chris@0 173 // Null
Chris@0 174 if ($tok === "\00") {
Chris@0 175 $this->parseError("Received null character.");
Chris@0 176 }
Chris@0 177 // fprintf(STDOUT, "Writing '%s'", $tok);
Chris@0 178 $this->buffer($tok);
Chris@0 179 $this->scanner->next();
Chris@0 180 return true;
Chris@0 181 }
Chris@0 182
Chris@0 183 /**
Chris@0 184 * Read text in RAW mode.
Chris@0 185 */
Chris@0 186 protected function rawText()
Chris@0 187 {
Chris@0 188 if (is_null($this->untilTag)) {
Chris@0 189 return $this->text();
Chris@0 190 }
Chris@0 191 $sequence = '</' . $this->untilTag . '>';
Chris@0 192 $txt = $this->readUntilSequence($sequence);
Chris@0 193 $this->events->text($txt);
Chris@0 194 $this->setTextMode(0);
Chris@0 195 return $this->endTag();
Chris@0 196 }
Chris@0 197
Chris@0 198 /**
Chris@0 199 * Read text in RCDATA mode.
Chris@0 200 */
Chris@0 201 protected function rcdata()
Chris@0 202 {
Chris@0 203 if (is_null($this->untilTag)) {
Chris@0 204 return $this->text();
Chris@0 205 }
Chris@0 206 $sequence = '</' . $this->untilTag;
Chris@0 207 $txt = '';
Chris@0 208 $tok = $this->scanner->current();
Chris@0 209
Chris@0 210 $caseSensitive = !Elements::isHtml5Element($this->untilTag);
Chris@0 211 while ($tok !== false && ! ($tok == '<' && ($this->sequenceMatches($sequence, $caseSensitive)))) {
Chris@0 212 if ($tok == '&') {
Chris@0 213 $txt .= $this->decodeCharacterReference();
Chris@0 214 $tok = $this->scanner->current();
Chris@0 215 } else {
Chris@0 216 $txt .= $tok;
Chris@0 217 $tok = $this->scanner->next();
Chris@0 218 }
Chris@0 219 }
Chris@0 220 $len = strlen($sequence);
Chris@0 221 $this->scanner->consume($len);
Chris@0 222 $len += strlen($this->scanner->whitespace());
Chris@0 223 if ($this->scanner->current() !== '>') {
Chris@0 224 $this->parseError("Unclosed RCDATA end tag");
Chris@0 225 }
Chris@0 226 $this->scanner->unconsume($len);
Chris@0 227 $this->events->text($txt);
Chris@0 228 $this->setTextMode(0);
Chris@0 229 return $this->endTag();
Chris@0 230 }
Chris@0 231
Chris@0 232 /**
Chris@0 233 * If the document is read, emit an EOF event.
Chris@0 234 */
Chris@0 235 protected function eof()
Chris@0 236 {
Chris@0 237 if ($this->scanner->current() === false) {
Chris@0 238 // fprintf(STDOUT, "EOF");
Chris@0 239 $this->flushBuffer();
Chris@0 240 $this->events->eof();
Chris@0 241 $this->carryOn = false;
Chris@0 242 return true;
Chris@0 243 }
Chris@0 244 return false;
Chris@0 245 }
Chris@0 246
Chris@0 247 /**
Chris@0 248 * Handle character references (aka entities).
Chris@0 249 *
Chris@0 250 * This version is specific to PCDATA, as it buffers data into the
Chris@0 251 * text buffer. For a generic version, see decodeCharacterReference().
Chris@0 252 *
Chris@0 253 * HTML5 8.2.4.2
Chris@0 254 */
Chris@0 255 protected function characterReference()
Chris@0 256 {
Chris@0 257 $ref = $this->decodeCharacterReference();
Chris@0 258 if ($ref !== false) {
Chris@0 259 $this->buffer($ref);
Chris@0 260 return true;
Chris@0 261 }
Chris@0 262 return false;
Chris@0 263 }
Chris@0 264
Chris@0 265 /**
Chris@0 266 * Emit a tagStart event on encountering a tag.
Chris@0 267 *
Chris@0 268 * 8.2.4.8
Chris@0 269 */
Chris@0 270 protected function tagOpen()
Chris@0 271 {
Chris@0 272 if ($this->scanner->current() != '<') {
Chris@0 273 return false;
Chris@0 274 }
Chris@0 275
Chris@0 276 // Any buffered text data can go out now.
Chris@0 277 $this->flushBuffer();
Chris@0 278
Chris@0 279 $this->scanner->next();
Chris@0 280
Chris@0 281 return $this->markupDeclaration() || $this->endTag() || $this->processingInstruction() || $this->tagName() ||
Chris@0 282 /* This always returns false. */
Chris@0 283 $this->parseError("Illegal tag opening") || $this->characterData();
Chris@0 284 }
Chris@0 285
Chris@0 286 /**
Chris@0 287 * Look for markup.
Chris@0 288 */
Chris@0 289 protected function markupDeclaration()
Chris@0 290 {
Chris@0 291 if ($this->scanner->current() != '!') {
Chris@0 292 return false;
Chris@0 293 }
Chris@0 294
Chris@0 295 $tok = $this->scanner->next();
Chris@0 296
Chris@0 297 // Comment:
Chris@0 298 if ($tok == '-' && $this->scanner->peek() == '-') {
Chris@0 299 $this->scanner->next(); // Consume the other '-'
Chris@0 300 $this->scanner->next(); // Next char.
Chris@0 301 return $this->comment();
Chris@0 302 }
Chris@0 303
Chris@0 304 elseif ($tok == 'D' || $tok == 'd') { // Doctype
Chris@0 305 return $this->doctype();
Chris@0 306 }
Chris@0 307
Chris@0 308 elseif ($tok == '[') { // CDATA section
Chris@0 309 return $this->cdataSection();
Chris@0 310 }
Chris@0 311
Chris@0 312 // FINISH
Chris@0 313 $this->parseError("Expected <!--, <![CDATA[, or <!DOCTYPE. Got <!%s", $tok);
Chris@0 314 $this->bogusComment('<!');
Chris@0 315 return true;
Chris@0 316 }
Chris@0 317
Chris@0 318 /**
Chris@0 319 * Consume an end tag.
Chris@0 320 * 8.2.4.9
Chris@0 321 */
Chris@0 322 protected function endTag()
Chris@0 323 {
Chris@0 324 if ($this->scanner->current() != '/') {
Chris@0 325 return false;
Chris@0 326 }
Chris@0 327 $tok = $this->scanner->next();
Chris@0 328
Chris@0 329 // a-zA-Z -> tagname
Chris@0 330 // > -> parse error
Chris@0 331 // EOF -> parse error
Chris@0 332 // -> parse error
Chris@0 333 if (! ctype_alpha($tok)) {
Chris@0 334 $this->parseError("Expected tag name, got '%s'", $tok);
Chris@0 335 if ($tok == "\0" || $tok === false) {
Chris@0 336 return false;
Chris@0 337 }
Chris@0 338 return $this->bogusComment('</');
Chris@0 339 }
Chris@0 340
Chris@0 341 $name = $this->scanner->charsUntil("\n\f \t>");
Chris@0 342 $name = $this->mode === self::CONFORMANT_XML ? $name: strtolower($name);
Chris@0 343 // Trash whitespace.
Chris@0 344 $this->scanner->whitespace();
Chris@0 345
Chris@0 346 if ($this->scanner->current() != '>') {
Chris@0 347 $this->parseError("Expected >, got '%s'", $this->scanner->current());
Chris@0 348 // We just trash stuff until we get to the next tag close.
Chris@0 349 $this->scanner->charsUntil('>');
Chris@0 350 }
Chris@0 351
Chris@0 352 $this->events->endTag($name);
Chris@0 353 $this->scanner->next();
Chris@0 354 return true;
Chris@0 355 }
Chris@0 356
Chris@0 357 /**
Chris@0 358 * Consume a tag name and body.
Chris@0 359 * 8.2.4.10
Chris@0 360 */
Chris@0 361 protected function tagName()
Chris@0 362 {
Chris@0 363 $tok = $this->scanner->current();
Chris@0 364 if (! ctype_alpha($tok)) {
Chris@0 365 return false;
Chris@0 366 }
Chris@0 367
Chris@0 368 // We know this is at least one char.
Chris@0 369 $name = $this->scanner->charsWhile(":_-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz");
Chris@0 370 $name = $this->mode === self::CONFORMANT_XML ? $name : strtolower($name);
Chris@0 371 $attributes = array();
Chris@0 372 $selfClose = false;
Chris@0 373
Chris@0 374 // Handle attribute parse exceptions here so that we can
Chris@0 375 // react by trying to build a sensible parse tree.
Chris@0 376 try {
Chris@0 377 do {
Chris@0 378 $this->scanner->whitespace();
Chris@0 379 $this->attribute($attributes);
Chris@0 380 } while (! $this->isTagEnd($selfClose));
Chris@0 381 } catch (ParseError $e) {
Chris@0 382 $selfClose = false;
Chris@0 383 }
Chris@0 384
Chris@0 385 $mode = $this->events->startTag($name, $attributes, $selfClose);
Chris@0 386 // Should we do this? What does this buy that selfClose doesn't?
Chris@0 387 if ($selfClose) {
Chris@0 388 $this->events->endTag($name);
Chris@0 389 } elseif (is_int($mode)) {
Chris@0 390 // fprintf(STDOUT, "Event response says move into mode %d for tag %s", $mode, $name);
Chris@0 391 $this->setTextMode($mode, $name);
Chris@0 392 }
Chris@0 393
Chris@0 394 $this->scanner->next();
Chris@0 395
Chris@0 396 return true;
Chris@0 397 }
Chris@0 398
Chris@0 399 /**
Chris@0 400 * Check if the scanner has reached the end of a tag.
Chris@0 401 */
Chris@0 402 protected function isTagEnd(&$selfClose)
Chris@0 403 {
Chris@0 404 $tok = $this->scanner->current();
Chris@0 405 if ($tok == '/') {
Chris@0 406 $this->scanner->next();
Chris@0 407 $this->scanner->whitespace();
Chris@0 408 $tok = $this->scanner->current();
Chris@0 409
Chris@0 410 if ($tok == '>') {
Chris@0 411 $selfClose = true;
Chris@0 412 return true;
Chris@0 413 }
Chris@0 414 if ($tok === false) {
Chris@0 415 $this->parseError("Unexpected EOF inside of tag.");
Chris@0 416 return true;
Chris@0 417 }
Chris@0 418 // Basically, we skip the / token and go on.
Chris@0 419 // See 8.2.4.43.
Chris@0 420 $this->parseError("Unexpected '%s' inside of a tag.", $tok);
Chris@0 421 return false;
Chris@0 422 }
Chris@0 423
Chris@0 424 if ($tok == '>') {
Chris@0 425 return true;
Chris@0 426 }
Chris@0 427 if ($tok === false) {
Chris@0 428 $this->parseError("Unexpected EOF inside of tag.");
Chris@0 429 return true;
Chris@0 430 }
Chris@0 431
Chris@0 432 return false;
Chris@0 433 }
Chris@0 434
Chris@0 435 /**
Chris@0 436 * Parse attributes from inside of a tag.
Chris@0 437 */
Chris@0 438 protected function attribute(&$attributes)
Chris@0 439 {
Chris@0 440 $tok = $this->scanner->current();
Chris@0 441 if ($tok == '/' || $tok == '>' || $tok === false) {
Chris@0 442 return false;
Chris@0 443 }
Chris@0 444
Chris@0 445 if ($tok == '<') {
Chris@0 446 $this->parseError("Unexepcted '<' inside of attributes list.");
Chris@0 447 // Push the < back onto the stack.
Chris@0 448 $this->scanner->unconsume();
Chris@0 449 // Let the caller figure out how to handle this.
Chris@0 450 throw new ParseError("Start tag inside of attribute.");
Chris@0 451 }
Chris@0 452
Chris@0 453 $name = strtolower($this->scanner->charsUntil("/>=\n\f\t "));
Chris@0 454
Chris@0 455 if (strlen($name) == 0) {
Chris@0 456 $this->parseError("Expected an attribute name, got %s.", $this->scanner->current());
Chris@0 457 // Really, only '=' can be the char here. Everything else gets absorbed
Chris@0 458 // under one rule or another.
Chris@0 459 $name = $this->scanner->current();
Chris@0 460 $this->scanner->next();
Chris@0 461 }
Chris@0 462
Chris@0 463 $isValidAttribute = true;
Chris@0 464 // Attribute names can contain most Unicode characters for HTML5.
Chris@0 465 // But method "DOMElement::setAttribute" is throwing exception
Chris@0 466 // because of it's own internal restriction so these have to be filtered.
Chris@0 467 // see issue #23: https://github.com/Masterminds/html5-php/issues/23
Chris@0 468 // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name
Chris@0 469 if (preg_match("/[\x1-\x2C\\/\x3B-\x40\x5B-\x5E\x60\x7B-\x7F]/u", $name)) {
Chris@0 470 $this->parseError("Unexpected characters in attribute name: %s", $name);
Chris@0 471 $isValidAttribute = false;
Chris@0 472 } // There is no limitation for 1st character in HTML5.
Chris@0 473 // But method "DOMElement::setAttribute" is throwing exception for the
Chris@0 474 // characters below so they have to be filtered.
Chris@0 475 // see issue #23: https://github.com/Masterminds/html5-php/issues/23
Chris@0 476 // and http://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name
Chris@0 477 else
Chris@0 478 if (preg_match("/^[0-9.-]/u", $name)) {
Chris@0 479 $this->parseError("Unexpected character at the begining of attribute name: %s", $name);
Chris@0 480 $isValidAttribute = false;
Chris@0 481 }
Chris@0 482 // 8.1.2.3
Chris@0 483 $this->scanner->whitespace();
Chris@0 484
Chris@0 485 $val = $this->attributeValue();
Chris@0 486 if ($isValidAttribute) {
Chris@0 487 $attributes[$name] = $val;
Chris@0 488 }
Chris@0 489 return true;
Chris@0 490 }
Chris@0 491
Chris@0 492 /**
Chris@0 493 * Consume an attribute value.
Chris@0 494 * 8.2.4.37 and after.
Chris@0 495 */
Chris@0 496 protected function attributeValue()
Chris@0 497 {
Chris@0 498 if ($this->scanner->current() != '=') {
Chris@0 499 return null;
Chris@0 500 }
Chris@0 501 $this->scanner->next();
Chris@0 502 // 8.1.2.3
Chris@0 503 $this->scanner->whitespace();
Chris@0 504
Chris@0 505 $tok = $this->scanner->current();
Chris@0 506 switch ($tok) {
Chris@0 507 case "\n":
Chris@0 508 case "\f":
Chris@0 509 case " ":
Chris@0 510 case "\t":
Chris@0 511 // Whitespace here indicates an empty value.
Chris@0 512 return null;
Chris@0 513 case '"':
Chris@0 514 case "'":
Chris@0 515 $this->scanner->next();
Chris@0 516 return $this->quotedAttributeValue($tok);
Chris@0 517 case '>':
Chris@0 518 // case '/': // 8.2.4.37 seems to allow foo=/ as a valid attr.
Chris@0 519 $this->parseError("Expected attribute value, got tag end.");
Chris@0 520 return null;
Chris@0 521 case '=':
Chris@0 522 case '`':
Chris@0 523 $this->parseError("Expecting quotes, got %s.", $tok);
Chris@0 524 return $this->unquotedAttributeValue();
Chris@0 525 default:
Chris@0 526 return $this->unquotedAttributeValue();
Chris@0 527 }
Chris@0 528 }
Chris@0 529
Chris@0 530 /**
Chris@0 531 * Get an attribute value string.
Chris@0 532 *
Chris@0 533 * @param string $quote
Chris@0 534 * IMPORTANT: This is a series of chars! Any one of which will be considered
Chris@0 535 * termination of an attribute's value. E.g. "\"'" will stop at either
Chris@0 536 * ' or ".
Chris@0 537 * @return string The attribute value.
Chris@0 538 */
Chris@0 539 protected function quotedAttributeValue($quote)
Chris@0 540 {
Chris@0 541 $stoplist = "\f" . $quote;
Chris@0 542 $val = '';
Chris@0 543
Chris@0 544 while (true) {
Chris@0 545 $tokens = $this->scanner->charsUntil($stoplist.'&');
Chris@0 546 if ($tokens !== false) {
Chris@0 547 $val .= $tokens;
Chris@0 548 } else {
Chris@0 549 break;
Chris@0 550 }
Chris@0 551
Chris@0 552 $tok = $this->scanner->current();
Chris@0 553 if ($tok == '&') {
Chris@0 554 $val .= $this->decodeCharacterReference(true, $tok);
Chris@0 555 continue;
Chris@0 556 }
Chris@0 557 break;
Chris@0 558 }
Chris@0 559 $this->scanner->next();
Chris@0 560 return $val;
Chris@0 561 }
Chris@0 562
Chris@0 563 protected function unquotedAttributeValue()
Chris@0 564 {
Chris@0 565 $stoplist = "\t\n\f >";
Chris@0 566 $val = '';
Chris@0 567 $tok = $this->scanner->current();
Chris@0 568 while (strspn($tok, $stoplist) == 0 && $tok !== false) {
Chris@0 569 if ($tok == '&') {
Chris@0 570 $val .= $this->decodeCharacterReference(true);
Chris@0 571 $tok = $this->scanner->current();
Chris@0 572 } else {
Chris@0 573 if (strspn($tok, "\"'<=`") > 0) {
Chris@0 574 $this->parseError("Unexpected chars in unquoted attribute value %s", $tok);
Chris@0 575 }
Chris@0 576 $val .= $tok;
Chris@0 577 $tok = $this->scanner->next();
Chris@0 578 }
Chris@0 579 }
Chris@0 580 return $val;
Chris@0 581 }
Chris@0 582
Chris@0 583 /**
Chris@0 584 * Consume malformed markup as if it were a comment.
Chris@0 585 * 8.2.4.44
Chris@0 586 *
Chris@0 587 * The spec requires that the ENTIRE tag-like thing be enclosed inside of
Chris@0 588 * the comment. So this will generate comments like:
Chris@0 589 *
Chris@0 590 * &lt;!--&lt/+foo&gt;--&gt;
Chris@0 591 *
Chris@0 592 * @param string $leading
Chris@0 593 * Prepend any leading characters. This essentially
Chris@0 594 * negates the need to backtrack, but it's sort of
Chris@0 595 * a hack.
Chris@0 596 */
Chris@0 597 protected function bogusComment($leading = '')
Chris@0 598 {
Chris@0 599 $comment = $leading;
Chris@0 600 $tokens = $this->scanner->charsUntil('>');
Chris@0 601 if ($tokens !== false) {
Chris@0 602 $comment .= $tokens;
Chris@0 603 }
Chris@0 604 $tok = $this->scanner->current();
Chris@0 605 if ($tok !== false) {
Chris@0 606 $comment .= $tok;
Chris@0 607 }
Chris@0 608
Chris@0 609 $this->flushBuffer();
Chris@0 610 $this->events->comment($comment);
Chris@0 611 $this->scanner->next();
Chris@0 612
Chris@0 613 return true;
Chris@0 614 }
Chris@0 615
Chris@0 616 /**
Chris@0 617 * Read a comment.
Chris@0 618 *
Chris@0 619 * Expects the first tok to be inside of the comment.
Chris@0 620 */
Chris@0 621 protected function comment()
Chris@0 622 {
Chris@0 623 $tok = $this->scanner->current();
Chris@0 624 $comment = '';
Chris@0 625
Chris@0 626 // <!-->. Emit an empty comment because 8.2.4.46 says to.
Chris@0 627 if ($tok == '>') {
Chris@0 628 // Parse error. Emit the comment token.
Chris@0 629 $this->parseError("Expected comment data, got '>'");
Chris@0 630 $this->events->comment('');
Chris@0 631 $this->scanner->next();
Chris@0 632 return true;
Chris@0 633 }
Chris@0 634
Chris@0 635 // Replace NULL with the replacement char.
Chris@0 636 if ($tok == "\0") {
Chris@0 637 $tok = UTF8Utils::FFFD;
Chris@0 638 }
Chris@0 639 while (! $this->isCommentEnd()) {
Chris@0 640 $comment .= $tok;
Chris@0 641 $tok = $this->scanner->next();
Chris@0 642 }
Chris@0 643
Chris@0 644 $this->events->comment($comment);
Chris@0 645 $this->scanner->next();
Chris@0 646 return true;
Chris@0 647 }
Chris@0 648
Chris@0 649 /**
Chris@0 650 * Check if the scanner has reached the end of a comment.
Chris@0 651 */
Chris@0 652 protected function isCommentEnd()
Chris@0 653 {
Chris@0 654 $tok = $this->scanner->current();
Chris@0 655
Chris@0 656 // EOF
Chris@0 657 if ($tok === false) {
Chris@0 658 // Hit the end.
Chris@0 659 $this->parseError("Unexpected EOF in a comment.");
Chris@0 660 return true;
Chris@0 661 }
Chris@0 662
Chris@0 663 // If it doesn't start with -, not the end.
Chris@0 664 if ($tok != '-') {
Chris@0 665 return false;
Chris@0 666 }
Chris@0 667
Chris@0 668 // Advance one, and test for '->'
Chris@0 669 if ($this->scanner->next() == '-' && $this->scanner->peek() == '>') {
Chris@0 670 $this->scanner->next(); // Consume the last '>'
Chris@0 671 return true;
Chris@0 672 }
Chris@0 673 // Unread '-';
Chris@0 674 $this->scanner->unconsume(1);
Chris@0 675 return false;
Chris@0 676 }
Chris@0 677
Chris@0 678 /**
Chris@0 679 * Parse a DOCTYPE.
Chris@0 680 *
Chris@0 681 * Parse a DOCTYPE declaration. This method has strong bearing on whether or
Chris@0 682 * not Quirksmode is enabled on the event handler.
Chris@0 683 *
Chris@0 684 * @todo This method is a little long. Should probably refactor.
Chris@0 685 */
Chris@0 686 protected function doctype()
Chris@0 687 {
Chris@0 688 if (strcasecmp($this->scanner->current(), 'D')) {
Chris@0 689 return false;
Chris@0 690 }
Chris@0 691 // Check that string is DOCTYPE.
Chris@0 692 $chars = $this->scanner->charsWhile("DOCTYPEdoctype");
Chris@0 693 if (strcasecmp($chars, 'DOCTYPE')) {
Chris@0 694 $this->parseError('Expected DOCTYPE, got %s', $chars);
Chris@0 695 return $this->bogusComment('<!' . $chars);
Chris@0 696 }
Chris@0 697
Chris@0 698 $this->scanner->whitespace();
Chris@0 699 $tok = $this->scanner->current();
Chris@0 700
Chris@0 701 // EOF: die.
Chris@0 702 if ($tok === false) {
Chris@0 703 $this->events->doctype('html5', EventHandler::DOCTYPE_NONE, '', true);
Chris@0 704 return $this->eof();
Chris@0 705 }
Chris@0 706
Chris@0 707 $doctypeName = '';
Chris@0 708
Chris@0 709 // NULL char: convert.
Chris@0 710 if ($tok === "\0") {
Chris@0 711 $this->parseError("Unexpected null character in DOCTYPE.");
Chris@0 712 $doctypeName .= UTF8::FFFD;
Chris@0 713 $tok = $this->scanner->next();
Chris@0 714 }
Chris@0 715
Chris@0 716 $stop = " \n\f>";
Chris@0 717 $doctypeName = $this->scanner->charsUntil($stop);
Chris@0 718 // Lowercase ASCII, replace \0 with FFFD
Chris@0 719 $doctypeName = strtolower(strtr($doctypeName, "\0", UTF8Utils::FFFD));
Chris@0 720
Chris@0 721 $tok = $this->scanner->current();
Chris@0 722
Chris@0 723 // If false, emit a parse error, DOCTYPE, and return.
Chris@0 724 if ($tok === false) {
Chris@0 725 $this->parseError('Unexpected EOF in DOCTYPE declaration.');
Chris@0 726 $this->events->doctype($doctypeName, EventHandler::DOCTYPE_NONE, null, true);
Chris@0 727 return true;
Chris@0 728 }
Chris@0 729
Chris@0 730 // Short DOCTYPE, like <!DOCTYPE html>
Chris@0 731 if ($tok == '>') {
Chris@0 732 // DOCTYPE without a name.
Chris@0 733 if (strlen($doctypeName) == 0) {
Chris@0 734 $this->parseError("Expected a DOCTYPE name. Got nothing.");
Chris@0 735 $this->events->doctype($doctypeName, 0, null, true);
Chris@0 736 $this->scanner->next();
Chris@0 737 return true;
Chris@0 738 }
Chris@0 739 $this->events->doctype($doctypeName);
Chris@0 740 $this->scanner->next();
Chris@0 741 return true;
Chris@0 742 }
Chris@0 743 $this->scanner->whitespace();
Chris@0 744
Chris@0 745 $pub = strtoupper($this->scanner->getAsciiAlpha());
Chris@0 746 $white = strlen($this->scanner->whitespace());
Chris@0 747
Chris@0 748 // Get ID, and flag it as pub or system.
Chris@0 749 if (($pub == 'PUBLIC' || $pub == 'SYSTEM') && $white > 0) {
Chris@0 750 // Get the sys ID.
Chris@0 751 $type = $pub == 'PUBLIC' ? EventHandler::DOCTYPE_PUBLIC : EventHandler::DOCTYPE_SYSTEM;
Chris@0 752 $id = $this->quotedString("\0>");
Chris@0 753 if ($id === false) {
Chris@0 754 $this->events->doctype($doctypeName, $type, $pub, false);
Chris@0 755 return false;
Chris@0 756 }
Chris@0 757
Chris@0 758 // Premature EOF.
Chris@0 759 if ($this->scanner->current() === false) {
Chris@0 760 $this->parseError("Unexpected EOF in DOCTYPE");
Chris@0 761 $this->events->doctype($doctypeName, $type, $id, true);
Chris@0 762 return true;
Chris@0 763 }
Chris@0 764
Chris@0 765 // Well-formed complete DOCTYPE.
Chris@0 766 $this->scanner->whitespace();
Chris@0 767 if ($this->scanner->current() == '>') {
Chris@0 768 $this->events->doctype($doctypeName, $type, $id, false);
Chris@0 769 $this->scanner->next();
Chris@0 770 return true;
Chris@0 771 }
Chris@0 772
Chris@0 773 // If we get here, we have <!DOCTYPE foo PUBLIC "bar" SOME_JUNK
Chris@0 774 // Throw away the junk, parse error, quirks mode, return true.
Chris@0 775 $this->scanner->charsUntil(">");
Chris@0 776 $this->parseError("Malformed DOCTYPE.");
Chris@0 777 $this->events->doctype($doctypeName, $type, $id, true);
Chris@0 778 $this->scanner->next();
Chris@0 779 return true;
Chris@0 780 }
Chris@0 781
Chris@0 782 // Else it's a bogus DOCTYPE.
Chris@0 783 // Consume to > and trash.
Chris@0 784 $this->scanner->charsUntil('>');
Chris@0 785
Chris@0 786 $this->parseError("Expected PUBLIC or SYSTEM. Got %s.", $pub);
Chris@0 787 $this->events->doctype($doctypeName, 0, null, true);
Chris@0 788 $this->scanner->next();
Chris@0 789 return true;
Chris@0 790 }
Chris@0 791
Chris@0 792 /**
Chris@0 793 * Utility for reading a quoted string.
Chris@0 794 *
Chris@0 795 * @param string $stopchars
Chris@0 796 * Characters (in addition to a close-quote) that should stop the string.
Chris@0 797 * E.g. sometimes '>' is higher precedence than '"' or "'".
Chris@0 798 * @return mixed String if one is found (quotations omitted)
Chris@0 799 */
Chris@0 800 protected function quotedString($stopchars)
Chris@0 801 {
Chris@0 802 $tok = $this->scanner->current();
Chris@0 803 if ($tok == '"' || $tok == "'") {
Chris@0 804 $this->scanner->next();
Chris@0 805 $ret = $this->scanner->charsUntil($tok . $stopchars);
Chris@0 806 if ($this->scanner->current() == $tok) {
Chris@0 807 $this->scanner->next();
Chris@0 808 } else {
Chris@0 809 // Parse error because no close quote.
Chris@0 810 $this->parseError("Expected %s, got %s", $tok, $this->scanner->current());
Chris@0 811 }
Chris@0 812 return $ret;
Chris@0 813 }
Chris@0 814 return false;
Chris@0 815 }
Chris@0 816
Chris@0 817 /**
Chris@0 818 * Handle a CDATA section.
Chris@0 819 */
Chris@0 820 protected function cdataSection()
Chris@0 821 {
Chris@0 822 if ($this->scanner->current() != '[') {
Chris@0 823 return false;
Chris@0 824 }
Chris@0 825 $cdata = '';
Chris@0 826 $this->scanner->next();
Chris@0 827
Chris@0 828 $chars = $this->scanner->charsWhile('CDAT');
Chris@0 829 if ($chars != 'CDATA' || $this->scanner->current() != '[') {
Chris@0 830 $this->parseError('Expected [CDATA[, got %s', $chars);
Chris@0 831 return $this->bogusComment('<![' . $chars);
Chris@0 832 }
Chris@0 833
Chris@0 834 $tok = $this->scanner->next();
Chris@0 835 do {
Chris@0 836 if ($tok === false) {
Chris@0 837 $this->parseError('Unexpected EOF inside CDATA.');
Chris@0 838 $this->bogusComment('<![CDATA[' . $cdata);
Chris@0 839 return true;
Chris@0 840 }
Chris@0 841 $cdata .= $tok;
Chris@0 842 $tok = $this->scanner->next();
Chris@0 843 } while (! $this->sequenceMatches(']]>'));
Chris@0 844
Chris@0 845 // Consume ]]>
Chris@0 846 $this->scanner->consume(3);
Chris@0 847
Chris@0 848 $this->events->cdata($cdata);
Chris@0 849 return true;
Chris@0 850 }
Chris@0 851
Chris@0 852 // ================================================================
Chris@0 853 // Non-HTML5
Chris@0 854 // ================================================================
Chris@0 855 /**
Chris@0 856 * Handle a processing instruction.
Chris@0 857 *
Chris@0 858 * XML processing instructions are supposed to be ignored in HTML5,
Chris@0 859 * treated as "bogus comments". However, since we're not a user
Chris@0 860 * agent, we allow them. We consume until ?> and then issue a
Chris@0 861 * EventListener::processingInstruction() event.
Chris@0 862 */
Chris@0 863 protected function processingInstruction()
Chris@0 864 {
Chris@0 865 if ($this->scanner->current() != '?') {
Chris@0 866 return false;
Chris@0 867 }
Chris@0 868
Chris@0 869 $tok = $this->scanner->next();
Chris@0 870 $procName = $this->scanner->getAsciiAlpha();
Chris@0 871 $white = strlen($this->scanner->whitespace());
Chris@0 872
Chris@0 873 // If not a PI, send to bogusComment.
Chris@0 874 if (strlen($procName) == 0 || $white == 0 || $this->scanner->current() == false) {
Chris@0 875 $this->parseError("Expected processing instruction name, got $tok");
Chris@0 876 $this->bogusComment('<?' . $tok . $procName);
Chris@0 877 return true;
Chris@0 878 }
Chris@0 879
Chris@0 880 $data = '';
Chris@0 881 // As long as it's not the case that the next two chars are ? and >.
Chris@0 882 while (! ($this->scanner->current() == '?' && $this->scanner->peek() == '>')) {
Chris@0 883 $data .= $this->scanner->current();
Chris@0 884
Chris@0 885 $tok = $this->scanner->next();
Chris@0 886 if ($tok === false) {
Chris@0 887 $this->parseError("Unexpected EOF in processing instruction.");
Chris@0 888 $this->events->processingInstruction($procName, $data);
Chris@0 889 return true;
Chris@0 890 }
Chris@0 891 }
Chris@0 892
Chris@0 893 $this->scanner->next(); // >
Chris@0 894 $this->scanner->next(); // Next token.
Chris@0 895 $this->events->processingInstruction($procName, $data);
Chris@0 896 return true;
Chris@0 897 }
Chris@0 898
Chris@0 899 // ================================================================
Chris@0 900 // UTILITY FUNCTIONS
Chris@0 901 // ================================================================
Chris@0 902
Chris@0 903 /**
Chris@0 904 * Read from the input stream until we get to the desired sequene
Chris@0 905 * or hit the end of the input stream.
Chris@0 906 */
Chris@0 907 protected function readUntilSequence($sequence)
Chris@0 908 {
Chris@0 909 $buffer = '';
Chris@0 910
Chris@0 911 // Optimization for reading larger blocks faster.
Chris@0 912 $first = substr($sequence, 0, 1);
Chris@0 913 while ($this->scanner->current() !== false) {
Chris@0 914 $buffer .= $this->scanner->charsUntil($first);
Chris@0 915
Chris@0 916 // Stop as soon as we hit the stopping condition.
Chris@0 917 if ($this->sequenceMatches($sequence, false)) {
Chris@0 918 return $buffer;
Chris@0 919 }
Chris@0 920 $buffer .= $this->scanner->current();
Chris@0 921 $this->scanner->next();
Chris@0 922 }
Chris@0 923
Chris@0 924 // If we get here, we hit the EOF.
Chris@0 925 $this->parseError("Unexpected EOF during text read.");
Chris@0 926 return $buffer;
Chris@0 927 }
Chris@0 928
Chris@0 929 /**
Chris@0 930 * Check if upcomming chars match the given sequence.
Chris@0 931 *
Chris@0 932 * This will read the stream for the $sequence. If it's
Chris@0 933 * found, this will return true. If not, return false.
Chris@0 934 * Since this unconsumes any chars it reads, the caller
Chris@0 935 * will still need to read the next sequence, even if
Chris@0 936 * this returns true.
Chris@0 937 *
Chris@0 938 * Example: $this->sequenceMatches('</script>') will
Chris@0 939 * see if the input stream is at the start of a
Chris@0 940 * '</script>' string.
Chris@0 941 */
Chris@0 942 protected function sequenceMatches($sequence, $caseSensitive = true)
Chris@0 943 {
Chris@0 944 $len = strlen($sequence);
Chris@0 945 $buffer = '';
Chris@0 946 for ($i = 0; $i < $len; ++ $i) {
Chris@0 947 $tok = $this->scanner->current();
Chris@0 948 $buffer .= $tok;
Chris@0 949
Chris@0 950 // EOF. Rewind and let the caller handle it.
Chris@0 951 if ($tok === false) {
Chris@0 952 $this->scanner->unconsume($i);
Chris@0 953 return false;
Chris@0 954 }
Chris@0 955 $this->scanner->next();
Chris@0 956 }
Chris@0 957
Chris@0 958 $this->scanner->unconsume($len);
Chris@0 959 return $caseSensitive ? $buffer == $sequence : strcasecmp($buffer, $sequence) === 0;
Chris@0 960 }
Chris@0 961
Chris@0 962 /**
Chris@0 963 * Send a TEXT event with the contents of the text buffer.
Chris@0 964 *
Chris@0 965 * This emits an EventHandler::text() event with the current contents of the
Chris@0 966 * temporary text buffer. (The buffer is used to group as much PCDATA
Chris@0 967 * as we can instead of emitting lots and lots of TEXT events.)
Chris@0 968 */
Chris@0 969 protected function flushBuffer()
Chris@0 970 {
Chris@0 971 if ($this->text === '') {
Chris@0 972 return;
Chris@0 973 }
Chris@0 974 $this->events->text($this->text);
Chris@0 975 $this->text = '';
Chris@0 976 }
Chris@0 977
Chris@0 978 /**
Chris@0 979 * Add text to the temporary buffer.
Chris@0 980 *
Chris@0 981 * @see flushBuffer()
Chris@0 982 */
Chris@0 983 protected function buffer($str)
Chris@0 984 {
Chris@0 985 $this->text .= $str;
Chris@0 986 }
Chris@0 987
Chris@0 988 /**
Chris@0 989 * Emit a parse error.
Chris@0 990 *
Chris@0 991 * A parse error always returns false because it never consumes any
Chris@0 992 * characters.
Chris@0 993 */
Chris@0 994 protected function parseError($msg)
Chris@0 995 {
Chris@0 996 $args = func_get_args();
Chris@0 997
Chris@0 998 if (count($args) > 1) {
Chris@0 999 array_shift($args);
Chris@0 1000 $msg = vsprintf($msg, $args);
Chris@0 1001 }
Chris@0 1002
Chris@0 1003 $line = $this->scanner->currentLine();
Chris@0 1004 $col = $this->scanner->columnOffset();
Chris@0 1005 $this->events->parseError($msg, $line, $col);
Chris@0 1006 return false;
Chris@0 1007 }
Chris@0 1008
Chris@0 1009 /**
Chris@0 1010 * Decode a character reference and return the string.
Chris@0 1011 *
Chris@0 1012 * Returns false if the entity could not be found. If $inAttribute is set
Chris@0 1013 * to true, a bare & will be returned as-is.
Chris@0 1014 *
Chris@0 1015 * @param boolean $inAttribute
Chris@0 1016 * Set to true if the text is inside of an attribute value.
Chris@0 1017 * false otherwise.
Chris@0 1018 */
Chris@0 1019 protected function decodeCharacterReference($inAttribute = false)
Chris@0 1020 {
Chris@0 1021
Chris@0 1022 // If it fails this, it's definitely not an entity.
Chris@0 1023 if ($this->scanner->current() != '&') {
Chris@0 1024 return false;
Chris@0 1025 }
Chris@0 1026
Chris@0 1027 // Next char after &.
Chris@0 1028 $tok = $this->scanner->next();
Chris@0 1029 $entity = '';
Chris@0 1030 $start = $this->scanner->position();
Chris@0 1031
Chris@0 1032 if ($tok == false) {
Chris@0 1033 return '&';
Chris@0 1034 }
Chris@0 1035
Chris@0 1036 // These indicate not an entity. We return just
Chris@0 1037 // the &.
Chris@0 1038 if (strspn($tok, static::WHITE . "&<") == 1) {
Chris@0 1039 // $this->scanner->next();
Chris@0 1040 return '&';
Chris@0 1041 }
Chris@0 1042
Chris@0 1043 // Numeric entity
Chris@0 1044 if ($tok == '#') {
Chris@0 1045 $tok = $this->scanner->next();
Chris@0 1046
Chris@0 1047 // Hexidecimal encoding.
Chris@0 1048 // X[0-9a-fA-F]+;
Chris@0 1049 // x[0-9a-fA-F]+;
Chris@0 1050 if ($tok == 'x' || $tok == 'X') {
Chris@0 1051 $tok = $this->scanner->next(); // Consume x
Chris@0 1052
Chris@0 1053 // Convert from hex code to char.
Chris@0 1054 $hex = $this->scanner->getHex();
Chris@0 1055 if (empty($hex)) {
Chris@0 1056 $this->parseError("Expected &#xHEX;, got &#x%s", $tok);
Chris@0 1057 // We unconsume because we don't know what parser rules might
Chris@0 1058 // be in effect for the remaining chars. For example. '&#>'
Chris@0 1059 // might result in a specific parsing rule inside of tag
Chris@0 1060 // contexts, while not inside of pcdata context.
Chris@0 1061 $this->scanner->unconsume(2);
Chris@0 1062 return '&';
Chris@0 1063 }
Chris@0 1064 $entity = CharacterReference::lookupHex($hex);
Chris@0 1065 } // Decimal encoding.
Chris@0 1066 // [0-9]+;
Chris@0 1067 else {
Chris@0 1068 // Convert from decimal to char.
Chris@0 1069 $numeric = $this->scanner->getNumeric();
Chris@0 1070 if ($numeric === false) {
Chris@0 1071 $this->parseError("Expected &#DIGITS;, got &#%s", $tok);
Chris@0 1072 $this->scanner->unconsume(2);
Chris@0 1073 return '&';
Chris@0 1074 }
Chris@0 1075 $entity = CharacterReference::lookupDecimal($numeric);
Chris@0 1076 }
Chris@0 1077 } elseif ($tok === '=' && $inAttribute) {
Chris@0 1078 return '&';
Chris@0 1079 } else { // String entity.
Chris@0 1080
Chris@0 1081 // Attempt to consume a string up to a ';'.
Chris@0 1082 // [a-zA-Z0-9]+;
Chris@0 1083 $cname = $this->scanner->getAsciiAlphaNum();
Chris@0 1084 $entity = CharacterReference::lookupName($cname);
Chris@0 1085
Chris@0 1086 // When no entity is found provide the name of the unmatched string
Chris@0 1087 // and continue on as the & is not part of an entity. The & will
Chris@0 1088 // be converted to &amp; elsewhere.
Chris@0 1089 if ($entity == null) {
Chris@0 1090 if (!$inAttribute || strlen($cname) === 0) {
Chris@0 1091 $this->parseError("No match in entity table for '%s'", $cname);
Chris@0 1092 }
Chris@0 1093 $this->scanner->unconsume($this->scanner->position() - $start);
Chris@0 1094 return '&';
Chris@0 1095 }
Chris@0 1096 }
Chris@0 1097
Chris@0 1098 // The scanner has advanced the cursor for us.
Chris@0 1099 $tok = $this->scanner->current();
Chris@0 1100
Chris@0 1101 // We have an entity. We're done here.
Chris@0 1102 if ($tok == ';') {
Chris@0 1103 $this->scanner->next();
Chris@0 1104 return $entity;
Chris@0 1105 }
Chris@0 1106
Chris@0 1107 // If in an attribute, then failing to match ; means unconsume the
Chris@0 1108 // entire string. Otherwise, failure to match is an error.
Chris@0 1109 if ($inAttribute) {
Chris@0 1110 $this->scanner->unconsume($this->scanner->position() - $start);
Chris@0 1111 return '&';
Chris@0 1112 }
Chris@0 1113
Chris@0 1114 $this->parseError("Expected &ENTITY;, got &ENTITY%s (no trailing ;) ", $tok);
Chris@0 1115 return '&' . $entity;
Chris@0 1116 }
Chris@0 1117 }