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 * <!--</+foo>-->
|
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 & 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 }
|