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