Completed
Push — master ( c025ac...30aab1 )
by Asmir
05:47
created

Tokenizer::processingInstruction()   C

Complexity

Conditions 8
Paths 5

Size

Total Lines 37
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 8.1105

Importance

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