Completed
Push — master ( a13416...c2a3a1 )
by Josh
19:54
created

Parser::decodeTitle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 4
ccs 2
cts 2
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 1
crap 1
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2016 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter\Plugins\Litedown;
9
10
use s9e\TextFormatter\Parser as Rules;
11
use s9e\TextFormatter\Parser\Tag;
12
use s9e\TextFormatter\Plugins\ParserBase;
13
14
class Parser extends ParserBase
15
{
16
	/**
17
	* @var bool Whether current text contains escape characters
18
	*/
19
	protected $hasEscapedChars;
20
21
	/**
22
	* @var array Array of [label => attributes]
23
	*/
24
	protected $links;
25
26
	/**
27
	* @var string Text being parsed
28
	*/
29
	protected $text;
30
31
	/**
32
	* {@inheritdoc}
33
	*/
34 216
	public function parse($text, array $matches)
35
	{
36 216
		$this->init($text);
37
38
		// Match block-level markup as well as forced line breaks
39 216
		$this->matchBlockLevelMarkup();
40
41
		// Inline code must be done first to avoid false positives in other markup
42 216
		$this->matchInlineCode();
43
44
		// Images must be matched before links
45 216
		$this->matchImages();
46
47
		// Do the rest of inline markup
48 216
		$this->matchLinks();
49 216
		$this->matchStrikethrough();
50 216
		$this->matchSuperscript();
51 216
		$this->matchEmphasis();
52 216
		$this->matchForcedLineBreaks();
53
54
		// Unset the text to free its memory
55 216
		unset($this->text);
56 216
	}
57
58
	/**
59
	* Close a list at given offset
60
	*
61
	* @param  array   $list
62
	* @param  integer $textBoundary
63
	* @return void
64
	*/
65 23
	protected function closeList(array $list, $textBoundary)
66
	{
67 23
		$this->parser->addEndTag('LIST', $textBoundary, 0)->pairWith($list['listTag']);
68 23
		$this->parser->addEndTag('LI',   $textBoundary, 0)->pairWith($list['itemTag']);
69
70 23
		if ($list['tight'])
71 23
		{
72 21
			foreach ($list['itemTags'] as $itemTag)
73
			{
74 21
				$itemTag->removeFlags(Rules::RULE_CREATE_PARAGRAPHS);
75 21
			}
76 21
		}
77 23
	}
78
79
	/**
80
	* Compute the amount of text to ignore at the start of a quote line
81
	*
82
	* @param  string  $str           Original quote markup
83
	* @param  integer $maxQuoteDepth Maximum quote depth
84
	* @return integer                Number of characters to ignore
85
	*/
86 4
	protected function computeQuoteIgnoreLen($str, $maxQuoteDepth)
87
	{
88 4
		$remaining = $str;
89 4
		while (--$maxQuoteDepth >= 0)
90
		{
91 3
			$remaining = preg_replace('/^ *> ?/', '', $remaining);
92 3
		}
93
94 4
		return strlen($str) - strlen($remaining);
95
	}
96
97
	/**
98
	* Decode a chunk of encoded text to be used as an attribute value
99
	*
100
	* Decodes escaped literals and removes slashes and 0x1A characters
101
	*
102
	* @param  string $str Encoded text
103
	* @return string      Decoded text
104
	*/
105 47
	protected function decode($str)
106
	{
107 47
		if ($this->config['decodeHtmlEntities'] && strpos($str, '&') !== false)
108 47
		{
109 1
			$str = html_entity_decode($str, ENT_QUOTES, 'UTF-8');
110 1
		}
111 47
		$str = stripslashes(str_replace("\x1A", '', $str));
112
113 47
		if ($this->hasEscapedChars)
114 47
		{
115 4
			$str = strtr(
116 4
				$str,
117
				[
118 4
					"\x1B0" => '!', "\x1B1" => '"', "\x1B2" => ')',
119 4
					"\x1B3" => '*', "\x1B4" => '[', "\x1B5" => '\\',
120 4
					"\x1B6" => ']', "\x1B7" => '^', "\x1B8" => '_',
121 4
					"\x1B9" => '`', "\x1BA" => '~'
122 4
				]
123 4
			);
124 4
		}
125
126 47
		return $str;
127
	}
128
129
	/**
130
	* Decode the optional attribute portion of a link or image
131
	*
132
	* @param  string $str Encoded string in quotes, potentially surrounded by whitespace
133
	* @return string      Decoded string
134
	*/
135 16
	protected function decodeTitle($str)
136
	{
137 16
		return $this->decode(substr(trim($str), 1, -1));
138
	}
139
140
	/**
141
	* Encode escaped literals that have a special meaning
142
	*
143
	* @param  string $str Original text
144
	* @return string      Encoded text
145
	*/
146 10
	protected function encode($str)
147
	{
148 10
		return strtr(
149 10
			$str,
150
			[
151 10
				'\\!' => "\x1B0", '\\"' => "\x1B1", '\\)'  => "\x1B2",
152 10
				'\\*' => "\x1B3", '\\[' => "\x1B4", '\\\\' => "\x1B5",
153 10
				'\\]' => "\x1B6", '\\^' => "\x1B7", '\\_'  => "\x1B8",
154 10
				'\\`' => "\x1B9", '\\~' => "\x1BA"
155 10
			]
156 10
		);
157
	}
158
159
	/**
160
	* Return the length of the markup at the end of an ATX header
161
	*
162
	* @param  integer $startPos Start of the header's text
163
	* @param  integer $endPos   End of the header's text
164
	* @return integer
165
	*/
166 17
	protected function getAtxHeaderEndTagLen($startPos, $endPos)
167
	{
168 17
		$content = substr($this->text, $startPos, $endPos - $startPos);
169 17
		preg_match('/[ \\t]*#*[ \\t]*$/', $content, $m);
170
171 17
		return strlen($m[0]);
172
	}
173
174
	/**
175
	* Get the attribute values from given reference
176
	*
177
	* @param  string   $label Link label
178
	* @return string[]
179
	*/
180 21
	protected function getReferenceLinkAttributes($label)
181
	{
182 21
		if (!isset($this->links))
183 21
		{
184 21
			$this->matchLinkReferences();
185 21
		}
186
187 21
		$label = strtolower($label);
188
189 21
		return (isset($this->links[$label])) ? $this->links[$label] : [];
190
	}
191
192
	/**
193
	* Capture lines that contain a Setext-tyle header
194
	*
195
	* @return array
196
	*/
197 216
	protected function getSetextLines()
198
	{
199 216
		$setextLines = [];
200
201 216
		if (strpos($this->text, '-') === false && strpos($this->text, '=') === false)
202 216
		{
203 184
			return $setextLines;
204
		}
205
206
		// Capture the any series of - or = alone on a line, optionally preceded with the
207
		// angle brackets notation used in blockquotes
208 32
		$regexp = '/^(?=[-=>])(?:> ?)*(?=[-=])(?:-+|=+) *$/m';
209 32
		if (preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE))
210 32
		{
211 19
			foreach ($matches[0] as list($match, $matchPos))
212
			{
213
				// Compute the position of the end tag. We start on the LF character before the
214
				// match and keep rewinding until we find a non-space character
215 19
				$endTagPos = $matchPos - 1;
216 19
				while ($endTagPos > 0 && $this->text[$endTagPos - 1] === ' ')
217
				{
218 5
					--$endTagPos;
219 5
				}
220
221
				// Store at the offset of the LF character
222 19
				$setextLines[$matchPos - 1] = [
223 19
					'endTagLen'  => $matchPos + strlen($match) - $endTagPos,
224 19
					'endTagPos'  => $endTagPos,
225 19
					'quoteDepth' => substr_count($match, '>'),
226 19
					'tagName'    => ($match[0] === '=') ? 'H1' : 'H2'
227 19
				];
228 19
			}
229 19
		}
230
231 32
		return $setextLines;
232
	}
233
234
	/**
235
	* Get emphasis markup split by block
236
	*
237
	* @param  string  $regexp Regexp used to match emphasis
238
	* @param  integer $pos    Position in the text of the first emphasis character
239
	* @return array[]         Each array contains a list of [matchPos, matchLen] pairs
240
	*/
241 52
	protected function getEmphasisByBlock($regexp, $pos)
242
	{
243 52
		$block    = [];
244 52
		$blocks   = [];
245 52
		$breakPos = strpos($this->text, "\x17", $pos);
246
247 52
		preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE, $pos);
248 52
		foreach ($matches[0] as $m)
249
		{
250 52
			$matchPos = $m[1];
251 52
			$matchLen = strlen($m[0]);
252
253
			// Test whether we've just passed the limits of a block
254 52
			if ($matchPos > $breakPos)
255 52
			{
256 9
				$blocks[] = $block;
257 9
				$block    = [];
258 9
				$breakPos = strpos($this->text, "\x17", $matchPos);
259 9
			}
260
261
			// Test whether we should ignore this markup
262 52
			if (!$this->ignoreEmphasis($matchPos, $matchLen))
263 52
			{
264 51
				$block[] = [$matchPos, $matchLen];
265 51
			}
266 52
		}
267 52
		$blocks[] = $block;
268
269 52
		return $blocks;
270
	}
271
272
	/**
273
	* Get the attribute values of an inline link or image
274
	*
275
	* @param  array    $m Regexp captures
276
	* @return string[]    List of attribute values
277
	*/
278 29
	protected function getInlineLinkAttributes(array $m)
279
	{
280 29
		$attrValues = [$this->decode($m[3][0])];
281 29
		if (!empty($m[4][0]))
282 29
		{
283 12
			$title = $this->decodeTitle($m[4][0]);
284 12
			if ($title > '')
285 12
			{
286 11
				$attrValues[] = $title;
287 11
			}
288 12
		}
289
290 29
		return $attrValues;
291
	}
292
293
	/**
294
	* Test whether emphasis should be ignored at the given position in the text
295
	*
296
	* @param  integer $matchPos Position of the emphasis in the text
297
	* @param  integer $matchLen Length of the emphasis
298
	* @return bool
299
	*/
300 52
	protected function ignoreEmphasis($matchPos, $matchLen)
301
	{
302
		// Ignore single underscores between alphanumeric characters
303 52
		if ($this->text[$matchPos] === '_' && $matchLen === 1 && $this->isSurroundedByAlnum($matchPos, $matchLen))
304 52
		{
305 1
			return true;
306
		}
307
308 51
		return false;
309
	}
310
311
	/**
312
	* Initialize this parser with given text
313
	*
314
	* @param  string $text Text to be parsed
315
	* @return void
316
	*/
317 216
	protected function init($text)
318
	{
319 216
		if (strpos($text, '\\') === false || !preg_match('/\\\\[!")*[\\\\\\]^_`~]/', $text))
320 216
		{
321 206
			$this->hasEscapedChars = false;
322 206
		}
323
		else
324
		{
325 10
			$this->hasEscapedChars = true;
326
327
			// Encode escaped literals that have a special meaning otherwise, so that we don't have
328
			// to take them into account in regexps
329 10
			$text = $this->encode($text);
330
		}
331
332
		// We append a couple of lines and a non-whitespace character at the end of the text in
333
		// order to trigger the closure of all open blocks such as quotes and lists
334 216
		$text .= "\n\n\x17";
335
336 216
		$this->text = $text;
337 216
		unset($this->links);
338 216
	}
339
340
	/**
341
	* Test whether given character is alphanumeric
342
	*
343
	* @param  string $chr
344
	* @return bool
345
	*/
346 7
	protected function isAlnum($chr)
347
	{
348 7
		return (strpos(' abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', $chr) > 0);
349
	}
350
351
	/**
352
	* Test whether a length of text is surrounded by alphanumeric characters
353
	*
354
	* @param  integer $matchPos Start of the text
355
	* @param  integer $matchLen Length of the text
356
	* @return bool
357
	*/
358 7
	protected function isSurroundedByAlnum($matchPos, $matchLen)
359
	{
360 7
		return ($matchPos > 0 && $this->isAlnum($this->text[$matchPos - 1]) && $this->isAlnum($this->text[$matchPos + $matchLen]));
361
	}
362
363
	/**
364
	* Mark the boundary of a block in the original text
365
	*
366
	* @param  integer $pos
367
	* @return void
368
	*/
369 216
	protected function markBoundary($pos)
370
	{
371 216
		$this->text[$pos] = "\x17";
372 216
	}
373
374
	/**
375
	* Match block-level markup, as well as forced line breaks and headers
376
	*
377
	* @return void
378
	*/
379 216
	protected function matchBlockLevelMarkup()
380
	{
381 216
		$codeFence    = null;
382 216
		$codeIndent   = 4;
383 216
		$codeTag      = null;
384 216
		$lineIsEmpty  = true;
385 216
		$lists        = [];
386 216
		$listsCnt     = 0;
387 216
		$newContext   = false;
388 216
		$quotes       = [];
389 216
		$quotesCnt    = 0;
390 216
		$setextLines  = $this->getSetextLines();
391 216
		$textBoundary = 0;
392
393 216
		$regexp = '/^(?:(?=[-*+\\d \\t>`~#_])((?: {0,3}> ?)+)?([ \\t]+)?(\\* *\\* *\\*[* ]*$|- *- *-[- ]*$|_ *_ *_[_ ]*$|=+$)?((?:[-*+]|\\d+\\.)[ \\t]+(?=\\S))?[ \\t]*(#{1,6}[ \\t]+|```+[^`\\n]*$|~~~+[^~\\n]*$)?)?/m';
394 216
		preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
395
396 216
		foreach ($matches as $m)
397
		{
398 216
			$matchPos   = $m[0][1];
399 216
			$matchLen   = strlen($m[0][0]);
400 216
			$ignoreLen  = 0;
401 216
			$quoteDepth = 0;
402
403
			// If the last line was empty then this is not a continuation, and vice-versa
404 216
			$continuation = !$lineIsEmpty;
405
406
			// Capture the position of the end of the line and determine whether the line is empty
407 216
			$lfPos       = strpos($this->text, "\n", $matchPos);
408 216
			$lineIsEmpty = ($lfPos === $matchPos + $matchLen && empty($m[3][0]) && empty($m[4][0]) && empty($m[5][0]));
409
410
			// If the line is empty and it's the first empty line then we break current paragraph.
411 216
			$breakParagraph = ($lineIsEmpty && $continuation);
412
413
			// Count quote marks
414 216
			if (!empty($m[1][0]))
415 216
			{
416 26
				$quoteDepth = substr_count($m[1][0], '>');
417 26
				$ignoreLen  = strlen($m[1][0]);
418 26
				if (isset($codeTag) && $codeTag->hasAttribute('quoteDepth'))
419 26
				{
420 4
					$quoteDepth = min($quoteDepth, $codeTag->getAttribute('quoteDepth'));
421 4
					$ignoreLen  = $this->computeQuoteIgnoreLen($m[1][0], $quoteDepth);
422 4
				}
423 26
			}
424
425
			// Close supernumerary quotes
426 216
			if ($quoteDepth < $quotesCnt && !$continuation && !$lineIsEmpty)
427 216
			{
428 25
				$newContext = true;
429
430
				do
431
				{
432 25
					$this->parser->addEndTag('QUOTE', $textBoundary, 0)
433 25
					             ->pairWith(array_pop($quotes));
434
				}
435 25
				while ($quoteDepth < --$quotesCnt);
436 25
			}
437
438
			// Open new quotes
439 216
			if ($quoteDepth > $quotesCnt && !$lineIsEmpty)
440 216
			{
441 25
				$newContext = true;
442
443
				do
444
				{
445 25
					$tag = $this->parser->addStartTag('QUOTE', $matchPos, 0);
446 25
					$tag->setSortPriority($quotesCnt);
447
448 25
					$quotes[] = $tag;
449
				}
450 25
				while ($quoteDepth > ++$quotesCnt);
451 25
			}
452
453
			// Compute the width of the indentation
454 216
			$indentWidth = 0;
455 216
			$indentPos   = 0;
456 216
			if (!empty($m[2][0]) && !$codeFence)
457 216
			{
458 34
				$indentStr = $m[2][0];
459 34
				$indentLen = strlen($indentStr);
460
				do
461
				{
462 34
					if ($indentStr[$indentPos] === ' ')
463 34
					{
464 34
						++$indentWidth;
465 34
					}
466
					else
467
					{
468 2
						$indentWidth = ($indentWidth + 4) & ~3;
469
					}
470
				}
471 34
				while (++$indentPos < $indentLen && $indentWidth < $codeIndent);
472 34
			}
473
474
			// Test whether we're out of a code block
475 216
			if (isset($codeTag) && !$codeFence && $indentWidth < $codeIndent && !$lineIsEmpty)
476 216
			{
477 16
				$newContext = true;
478 16
			}
479
480
			if ($newContext)
481 216
			{
482 38
				$newContext = false;
483
484
				// Close the code block if applicable
485 38
				if (isset($codeTag))
486 38
				{
487
					// Overwrite the whole block
488 16
					$this->overwrite($codeTag->getPos(), $textBoundary - $codeTag->getPos());
489
490 16
					$endTag = $this->parser->addEndTag('CODE', $textBoundary, 0);
491 16
					$endTag->pairWith($codeTag);
492 16
					$endTag->setSortPriority(-1);
493 16
					$codeTag = null;
494 16
					$codeFence = null;
495 16
				}
496
497
				// Close all the lists
498 38
				foreach ($lists as $list)
499
				{
500 1
					$this->closeList($list, $textBoundary);
501 38
				}
502 38
				$lists    = [];
503 38
				$listsCnt = 0;
504
505
				// Mark the block boundary
506
				if ($matchPos)
507 38
				{
508 38
					$this->markBoundary($matchPos - 1);
509 38
				}
510 38
			}
511
512 216
			if ($indentWidth >= $codeIndent)
513 216
			{
514 17
				if (isset($codeTag) || !$continuation)
515 17
				{
516
					// Adjust the amount of text being ignored
517 16
					$ignoreLen += $indentPos;
518
519 16
					if (!isset($codeTag))
520 16
					{
521
						// Create code block
522 16
						$codeTag = $this->parser->addStartTag('CODE', $matchPos + $ignoreLen, 0);
523 16
					}
524
525
					// Clear the captures to prevent any further processing
526 16
					$m = [];
527 16
				}
528 17
			}
529
			else
530
			{
531 216
				$hasListItem = !empty($m[4][0]);
532
533 216
				if (!$indentWidth && !$continuation && !$hasListItem && !$lineIsEmpty)
534 216
				{
535
					// Start of a new paragraph
536 216
					$listIndex = -1;
537 216
				}
538 216
				elseif ($continuation && !$hasListItem)
539
				{
540
					// Continuation of current list item or paragraph
541 216
					$listIndex = $listsCnt - 1;
542 216
				}
543 26
				elseif (!$listsCnt)
544
				{
545
					// We're not inside of a list already, we can start one if there's a list item
546
					// and it's either not in continuation of a paragraph or immediately after a
547
					// block
548 26
					if ($hasListItem && (!$continuation || $this->text[$matchPos - 1] === "\x17"))
549 26
					{
550
						// Start of a new list
551 23
						$listIndex = 0;
552 23
					}
553
					else
554
					{
555
						// We're in a normal paragraph
556 3
						$listIndex = -1;
557
					}
558 26
				}
559
				else
560
				{
561
					// We're inside of a list but we need to compute the depth
562 17
					$listIndex = 0;
563 17
					while ($listIndex < $listsCnt && $indentWidth > $lists[$listIndex]['maxIndent'])
564
					{
565 6
						++$listIndex;
566 6
					}
567
				}
568
569
				// Close deeper lists
570 216
				while ($listIndex < $listsCnt - 1)
571
				{
572 22
					$this->closeList(array_pop($lists), $textBoundary);
573 22
					--$listsCnt;
574 22
				}
575
576
				// If there's no list item at current index, we'll need to either create one or
577
				// drop down to previous index, in which case we have to adjust maxIndent
578 216
				if ($listIndex === $listsCnt && !$hasListItem)
579 216
				{
580 1
					--$listIndex;
581 1
				}
582
583 216
				if ($hasListItem && $listIndex >= 0)
584 216
				{
585 23
					$breakParagraph = true;
586
587
					// Compute the position and amount of text consumed by the item tag
588 23
					$tagPos = $matchPos + $ignoreLen + $indentPos;
589 23
					$tagLen = strlen($m[4][0]);
590
591
					// Create a LI tag that consumes its markup
592 23
					$itemTag = $this->parser->addStartTag('LI', $tagPos, $tagLen);
593
594
					// Overwrite the markup
595 23
					$this->overwrite($tagPos, $tagLen);
596
597
					// If the list index is within current lists count it means this is not a new
598
					// list and we have to close the last item. Otherwise, it's a new list that we
599
					// have to create
600 23
					if ($listIndex < $listsCnt)
601 23
					{
602 17
						$this->parser->addEndTag('LI', $textBoundary, 0)
603 17
						             ->pairWith($lists[$listIndex]['itemTag']);
604
605
						// Record the item in the list
606 17
						$lists[$listIndex]['itemTag']    = $itemTag;
607 17
						$lists[$listIndex]['itemTags'][] = $itemTag;
608 17
					}
609
					else
610
					{
611 23
						++$listsCnt;
612
613
						if ($listIndex)
614 23
						{
615 5
							$minIndent = $lists[$listIndex - 1]['maxIndent'] + 1;
616 5
							$maxIndent = max($minIndent, $listIndex * 4);
617 5
						}
618
						else
619
						{
620 23
							$minIndent = 0;
621 23
							$maxIndent = $indentWidth;
622
						}
623
624
						// Create a 0-width LIST tag right before the item tag LI
625 23
						$listTag = $this->parser->addStartTag('LIST', $tagPos, 0);
626
627
						// Test whether the list item ends with a dot, as in "1."
628 23
						if (strpos($m[4][0], '.') !== false)
629 23
						{
630 8
							$listTag->setAttribute('type', 'decimal');
631
632 8
							$start = (int) $m[4][0];
633 8
							if ($start !== 1)
634 8
							{
635 2
								$listTag->setAttribute('start', $start);
636 2
							}
637 8
						}
638
639
						// Record the new list depth
640 23
						$lists[] = [
641 23
							'listTag'   => $listTag,
642 23
							'itemTag'   => $itemTag,
643 23
							'itemTags'  => [$itemTag],
644 23
							'minIndent' => $minIndent,
645 23
							'maxIndent' => $maxIndent,
646
							'tight'     => true
647 23
						];
648
					}
649 23
				}
650
651
				// If we're in a list, on a non-empty line preceded with a blank line...
652 216
				if ($listsCnt && !$continuation && !$lineIsEmpty)
653 216
				{
654
					// ...and this is not the first item of the list...
655 19
					if (count($lists[0]['itemTags']) > 1 || !$hasListItem)
656 19
					{
657
						// ...every list that is currently open becomes loose
658 5
						foreach ($lists as &$list)
659
						{
660 5
							$list['tight'] = false;
661 5
						}
662 5
						unset($list);
663 5
					}
664 19
				}
665
666 216
				$codeIndent = ($listsCnt + 1) * 4;
667
			}
668
669 216
			if (isset($m[5]))
670 216
			{
671
				// Headers
672 35
				if ($m[5][0][0] === '#')
673 35
				{
674 17
					$startTagLen = strlen($m[5][0]);
675 17
					$startTagPos = $matchPos + $matchLen - $startTagLen;
676 17
					$endTagLen   = $this->getAtxHeaderEndTagLen($matchPos + $matchLen, $lfPos);
677 17
					$endTagPos   = $lfPos - $endTagLen;
678
679 17
					$this->parser->addTagPair('H' . strspn($m[5][0], '#', 0, 6), $startTagPos, $startTagLen, $endTagPos, $endTagLen);
680
681
					// Mark the start and the end of the header as boundaries
682 17
					$this->markBoundary($startTagPos);
683 17
					$this->markBoundary($lfPos);
684
685
					if ($continuation)
686 17
					{
687 2
						$breakParagraph = true;
688 2
					}
689 17
				}
690
				// Code fence
691 18
				elseif ($m[5][0][0] === '`' || $m[5][0][0] === '~')
692
				{
693 18
					$tagPos = $matchPos + $ignoreLen;
694 18
					$tagLen = $lfPos - $tagPos;
695
696 18
					if (isset($codeTag) && $m[5][0] === $codeFence)
697 18
					{
698 18
						$endTag = $this->parser->addEndTag('CODE', $tagPos, $tagLen);
699 18
						$endTag->pairWith($codeTag);
700 18
						$endTag->setSortPriority(-1);
701
702 18
						$this->parser->addIgnoreTag($textBoundary, $tagPos - $textBoundary);
703
704
						// Overwrite the whole block
705 18
						$this->overwrite($codeTag->getPos(), $tagPos + $tagLen - $codeTag->getPos());
706 18
						$codeTag = null;
707 18
						$codeFence = null;
708 18
					}
709 18
					elseif (!isset($codeTag))
710
					{
711
						// Create code block
712 18
						$codeTag   = $this->parser->addStartTag('CODE', $tagPos, $tagLen);
713 18
						$codeFence = substr($m[5][0], 0, strspn($m[5][0], '`~'));
714 18
						$codeTag->setAttribute('quoteDepth', $quoteDepth);
715
716
						// Ignore the next character, which should be a newline
717 18
						$this->parser->addIgnoreTag($tagPos + $tagLen, 1);
718
719
						// Add the language if present, e.g. ```php
720 18
						$lang = trim(trim($m[5][0], '`~'));
721 18
						if ($lang !== '')
722 18
						{
723 4
							$codeTag->setAttribute('lang', $lang);
724 4
						}
725 18
					}
726 18
				}
727 35
			}
728 216
			elseif (!empty($m[3][0]) && !$listsCnt && $this->text[$matchPos + $matchLen] !== "\x17")
729
			{
730
				// Horizontal rule
731 9
				$this->parser->addSelfClosingTag('HR', $matchPos + $ignoreLen, $matchLen - $ignoreLen);
732 9
				$breakParagraph = true;
733
734
				// Mark the end of the line as a boundary
735 9
				$this->markBoundary($lfPos);
736 9
			}
737 216
			elseif (isset($setextLines[$lfPos]) && $setextLines[$lfPos]['quoteDepth'] === $quoteDepth && !$lineIsEmpty && !$listsCnt && !isset($codeTag))
738
			{
739
				// Setext-style header
740 11
				$this->parser->addTagPair(
741 11
					$setextLines[$lfPos]['tagName'],
742 11
					$matchPos + $ignoreLen,
743 11
					0,
744 11
					$setextLines[$lfPos]['endTagPos'],
745 11
					$setextLines[$lfPos]['endTagLen']
746 11
				);
747
748
				// Mark the end of the Setext line
749 11
				$this->markBoundary($setextLines[$lfPos]['endTagPos'] + $setextLines[$lfPos]['endTagLen']);
750 11
			}
751
752
			if ($breakParagraph)
753 216
			{
754 216
				$this->parser->addParagraphBreak($textBoundary);
755 216
				$this->markBoundary($textBoundary);
756 216
			}
757
758 216
			if (!$lineIsEmpty)
759 216
			{
760 216
				$textBoundary = $lfPos;
761 216
			}
762
763
			if ($ignoreLen)
764 216
			{
765 38
				$this->parser->addIgnoreTag($matchPos, $ignoreLen)->setSortPriority(1000);
766 38
			}
767 216
		}
768 216
	}
769
770
	/**
771
	* Match all forms of emphasis (emphasis and strong, using underscores or asterisks)
772
	*
773
	* @return void
774
	*/
775 216
	protected function matchEmphasis()
776
	{
777 216
		$this->matchEmphasisByCharacter('*', '/\\*+/');
778 216
		$this->matchEmphasisByCharacter('_', '/_+/');
779 216
	}
780
781
	/**
782
	* Match emphasis and strong applied using given character
783
	*
784
	* @param  string $character Markup character, either * or _
785
	* @param  string $regexp    Regexp used to match the series of emphasis character
786
	* @return void
787
	*/
788 216
	protected function matchEmphasisByCharacter($character, $regexp)
789
	{
790 216
		$pos = strpos($this->text, $character);
791 216
		if ($pos === false)
792 216
		{
793 216
			return;
794
		}
795
796 52
		foreach ($this->getEmphasisByBlock($regexp, $pos) as $block)
797
		{
798 52
			$this->processEmphasisBlock($block);
799 52
		}
800 52
	}
801
802
	/**
803
	* Match forced line breaks
804
	*
805
	* @return void
806
	*/
807 216
	protected function matchForcedLineBreaks()
808
	{
809 216
		$pos = strpos($this->text, "  \n");
810 216
		while ($pos !== false)
811
		{
812 6
			$this->parser->addBrTag($pos + 2);
813 6
			$pos = strpos($this->text, "  \n", $pos + 3);
814 6
		}
815 216
	}
816
817
	/**
818
	* Match images markup
819
	*
820
	* @return void
821
	*/
822 216
	protected function matchImages()
823
	{
824 216
		$pos = strpos($this->text, '![');
825 216
		if ($pos === false)
826 216
		{
827 201
			return;
828
		}
829
830 15
		preg_match_all(
831 15
			'/!\\[([^\\x17]*?(?=] ?\\()|[^\\x17\\]]*)](?: ?\\[([^\\x17\\]]+)\\]| ?\\(([^\\x17 ")]+)( *(?:"[^\\x17]*?"|\'[^\\x17]*?\'))?\\))?/',
832 15
			$this->text,
833 15
			$matches,
834 15
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
835
			$pos
836 15
		);
837 15
		foreach ($matches as $m)
838
		{
839 15
			$matchPos    = $m[0][1];
840 15
			$matchLen    = strlen($m[0][0]);
841 15
			$contentLen  = strlen($m[1][0]);
842 15
			$startTagPos = $matchPos;
843 15
			$startTagLen = 2;
844 15
			$endTagPos   = $startTagPos + $startTagLen + $contentLen;
845 15
			$endTagLen   = $matchLen - $startTagLen - $contentLen;
846
847 15
			$tag = $this->parser->addTagPair('IMG', $startTagPos, $startTagLen, $endTagPos, $endTagLen);
848 15
			$tag->setAttribute('alt', $this->decode($m[1][0]));
849 15
			$this->setLinkAttributes($tag, $m, ['src', 'title']);
850
851
			// Overwrite the markup
852 15
			$this->overwrite($matchPos, $matchLen);
853 15
		}
854 15
	}
855
856
	/**
857
	* Match inline code
858
	*
859
	* @return void
860
	*/
861 216
	protected function matchInlineCode()
862
	{
863 216
		$pos = strpos($this->text, '`');
864 216
		if ($pos === false)
865 216
		{
866 199
			return;
867
		}
868
869 17
		preg_match_all(
870 17
			'/((`+)(?!`)\\s*)(?:[^\\x17]*?[^`\\s])?(\\s*\\2)(?!`)/',
871 17
			$this->text,
872 17
			$matches,
873 17
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
874
			$pos
875 17
		);
876
877 17
		foreach ($matches as $m)
878
		{
879 16
			$matchLen    = strlen($m[0][0]);
880 16
			$matchPos    = $m[0][1];
881 16
			$startTagLen = strlen($m[1][0]);
882 16
			$endTagLen   = strlen($m[3][0]);
883
884 16
			$this->parser->addTagPair('C', $matchPos, $startTagLen, $matchPos + $matchLen - $endTagLen, $endTagLen);
885
886
			// Overwrite the markup
887 16
			$this->overwrite($matchPos, $matchLen);
888 17
		}
889 17
	}
890
891
	/**
892
	* Capture link reference definitions in current text
893
	*
894
	* @return void
895
	*/
896 21
	protected function matchLinkReferences()
897
	{
898 21
		$this->links = [];
899
900 21
		$regexp = '/^(?:> ?)* {0,3}\\[([^\\x17\\]]+)\\]: *([^\\s\\x17]+)\\s*("[^\\x17]*?"|\'[^\\x17]*?\')?[^\\x17]*\\n?/m';
901 21
		preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
902 21
		foreach ($matches as $m)
903
		{
904 18
			$this->parser->addIgnoreTag($m[0][1], strlen($m[0][0]))->setSortPriority(-2);
905
906
			// Ignore the reference if it already exists
907 18
			$label = strtolower($m[1][0]);
908 18
			if (isset($this->links[$label]))
909 18
			{
910 1
				continue;
911
			}
912
913 18
			$this->links[$label] = [$this->decode($m[2][0])];
914 18
			if (isset($m[3]) && strlen($m[3][0]) > 2)
915 18
			{
916 4
				$this->links[$label][] = $this->decodeTitle($m[3][0]);
917 4
			}
918 21
		}
919 21
	}
920
921
	/**
922
	* Match inline and reference links
923
	*
924
	* @return void
925
	*/
926 216
	protected function matchLinks()
927
	{
928 216
		$pos = strpos($this->text, '[');
929 216
		if ($pos === false)
930 216
		{
931 174
			return;
932
		}
933
934 42
		preg_match_all(
935 42
			'/\\[([^\\x17]*?(?=]\\()|[^\\x17\\]]*)](?: ?\\[([^\\x17\\]]+)\\]|\\(([^\\x17 ()]+(?:\\([^\\x17 ()]+\\)[^\\x17 ()]*)*[^\\x17 )]*)( *(?:"[^\\x17]*?"|\'[^\\x17]*?\'))?\\))?/',
936 42
			$this->text,
937 42
			$matches,
938 42
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
939
			$pos
940 42
		);
941 42
		foreach ($matches as $m)
942
		{
943 42
			$matchPos    = $m[0][1];
944 42
			$matchLen    = strlen($m[0][0]);
945 42
			$contentLen  = strlen($m[1][0]);
946 42
			$startTagPos = $matchPos;
947 42
			$startTagLen = 1;
948 42
			$endTagPos   = $startTagPos + $startTagLen + $contentLen;
949 42
			$endTagLen   = $matchLen - $startTagLen - $contentLen;
950
951 42
			$tag = $this->parser->addTagPair('URL', $startTagPos, $startTagLen, $endTagPos, $endTagLen);
952 42
			$this->setLinkAttributes($tag, $m, ['url', 'title']);
953
954
			// Give the link a slightly better priority to give it precedence over
955
			// possible BBCodes such as [b](https://en.wikipedia.org/wiki/B)
956 42
			$tag->setSortPriority(-1);
957
958
			// Overwrite the markup without touching the link's text
959 42
			$this->overwrite($startTagPos, $startTagLen);
960 42
			$this->overwrite($endTagPos,   $endTagLen);
961 42
		}
962 42
	}
963
964
	/**
965
	* Match strikethrough
966
	*
967
	* @return void
968
	*/
969 216
	protected function matchStrikethrough()
970
	{
971 216
		$pos = strpos($this->text, '~~');
972 216
		if ($pos === false)
973 216
		{
974 209
			return;
975
		}
976
977 7
		preg_match_all(
978 7
			'/~~[^\\x17]+?~~/',
979 7
			$this->text,
980 7
			$matches,
981 7
			PREG_OFFSET_CAPTURE,
982
			$pos
983 7
		);
984
985 7
		foreach ($matches[0] as list($match, $matchPos))
986
		{
987 5
			$matchLen = strlen($match);
988
989 5
			$this->parser->addTagPair('DEL', $matchPos, 2, $matchPos + $matchLen - 2, 2);
990 7
		}
991 7
	}
992
993
	/**
994
	* Match superscript
995
	*
996
	* @return void
997
	*/
998 216
	protected function matchSuperscript()
999
	{
1000 216
		$pos = strpos($this->text, '^');
1001 216
		if ($pos === false)
1002 216
		{
1003 214
			return;
1004
		}
1005
1006 2
		preg_match_all(
1007 2
			'/\\^[^\\x17\\s]++/',
1008 2
			$this->text,
1009 2
			$matches,
1010 2
			PREG_OFFSET_CAPTURE,
1011
			$pos
1012 2
		);
1013
1014 2
		foreach ($matches[0] as list($match, $matchPos))
1015
		{
1016 1
			$matchLen    = strlen($match);
1017 1
			$startTagPos = $matchPos;
1018 1
			$endTagPos   = $matchPos + $matchLen;
1019
1020 1
			$parts = explode('^', $match);
1021 1
			unset($parts[0]);
1022
1023 1
			foreach ($parts as $part)
1024
			{
1025 1
				$this->parser->addTagPair('SUP', $startTagPos, 1, $endTagPos, 0);
1026 1
				$startTagPos += 1 + strlen($part);
1027 1
			}
1028 2
		}
1029 2
	}
1030
1031
	/**
1032
	* Overwrite part of the text with substitution characters ^Z (0x1A)
1033
	*
1034
	* @param  integer $pos Start of the range
1035
	* @param  integer $len Length of text to overwrite
1036
	* @return void
1037
	*/
1038 121
	protected function overwrite($pos, $len)
1039
	{
1040 121
		$this->text = substr($this->text, 0, $pos) . str_repeat("\x1A", $len) . substr($this->text, $pos + $len);
1041 121
	}
1042
1043
	/**
1044
	* Process a list of emphasis markup strings
1045
	*
1046
	* @param  array[] $block List of [matchPos, matchLen] pairs
1047
	* @return void
1048
	*/
1049 52
	protected function processEmphasisBlock(array $block)
1050
	{
1051 52
		$buffered  = 0;
1052 52
		$emPos     = -1;
1053 52
		$strongPos = -1;
1054 52
		foreach ($block as list($matchPos, $matchLen))
1055
		{
1056 51
			$closeLen     = min(3, $matchLen);
1057 51
			$closeEm      = $closeLen & $buffered & 1;
1058 51
			$closeStrong  = $closeLen & $buffered & 2;
1059 51
			$emEndPos     = $matchPos;
1060 51
			$strongEndPos = $matchPos;
1061
1062 51
			if ($buffered > 2 && $emPos === $strongPos)
1063 51
			{
1064
				if ($closeEm)
1065 13
				{
1066 11
					$emPos += 2;
1067 11
				}
1068
				else
1069
				{
1070 2
					++$strongPos;
1071
				}
1072 13
			}
1073
1074 51
			if ($closeEm && $closeStrong)
1075 51
			{
1076 11
				if ($emPos < $strongPos)
1077 11
				{
1078 1
					$emEndPos += 2;
1079 1
				}
1080
				else
1081
				{
1082 10
					++$strongEndPos;
1083
				}
1084 11
			}
1085
1086 51
			$remaining = $matchLen;
1087
			if ($closeEm)
1088 51
			{
1089 34
				--$buffered;
1090 34
				--$remaining;
1091 34
				$this->parser->addTagPair('EM', $emPos, 1, $emEndPos, 1);
1092 34
			}
1093
			if ($closeStrong)
1094 51
			{
1095 22
				$buffered  -= 2;
1096 22
				$remaining -= 2;
1097 22
				$this->parser->addTagPair('STRONG', $strongPos, 2, $strongEndPos, 2);
1098 22
			}
1099
1100 51
			$remaining = min(3, $remaining);
1101 51
			if ($remaining & 1)
1102 51
			{
1103 46
				$emPos = $matchPos + $matchLen - $remaining;
1104 46
			}
1105 51
			if ($remaining & 2)
1106 51
			{
1107 30
				$strongPos = $matchPos + $matchLen - $remaining;
1108 30
			}
1109 51
			$buffered += $remaining;
1110 52
		}
1111 52
	}
1112
1113
	/**
1114
	* Set a URL or IMG tag's attributes
1115
	*
1116
	* @param  Tag      $tag       URL or IMG tag
1117
	* @param  array    $m         Regexp captures
1118
	* @param  string[] $attrNames List of attribute names
1119
	* @return void
1120
	*/
1121 49
	protected function setLinkAttributes(Tag $tag, array $m, array $attrNames)
1122
	{
1123 49
		if (isset($m[3]))
1124 49
		{
1125 29
			$attrValues = $this->getInlineLinkAttributes($m);
1126 29
		}
1127
		else
1128
		{
1129 21
			$label      = (isset($m[2])) ? $m[2][0] : $m[1][0];
1130 21
			$attrValues = $this->getReferenceLinkAttributes($label);
1131
		}
1132
1133 49
		foreach ($attrValues as $k => $attrValue)
1134
		{
1135 47
			$tag->setAttribute($attrNames[$k], $attrValue);
1136 49
		}
1137
	}
1138
}