annotate vendor/masterminds/html5/src/HTML5/Parser/Tokenizer.php @ 19:fa3358dc1485 tip

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