Completed
Push — master ( 93a6a9...9f2b08 )
by Josh
15:59
created

Parser::setLinkAttributes()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 17
ccs 11
cts 11
cp 1
rs 9.2
cc 4
eloc 8
nc 6
nop 3
crap 4
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 212
	public function parse($text, array $matches)
35
	{
36 212
		$this->init($text);
37
38
		// Match block-level markup as well as forced line breaks
39 212
		$this->matchBlockLevelMarkup();
40
41
		// Inline code must be done first to avoid false positives in other markup
42 212
		$this->matchInlineCode();
43
44
		// Images must be matched before links
45 212
		$this->matchImages();
46
47
		// Do the rest of inline markup
48 212
		$this->matchLinks();
49 212
		$this->matchStrikethrough();
50 212
		$this->matchSuperscript();
51 212
		$this->matchEmphasis();
52 212
		$this->matchForcedLineBreaks();
53
54
		// Unset the text to free its memory
55 212
		unset($this->text);
56 212
	}
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 49
	protected function decode($str)
106
	{
107 49
		if ($this->config['decodeHtmlEntities'] && strpos($str, '&') !== false)
108 49
		{
109 1
			$str = html_entity_decode($str, ENT_QUOTES, 'UTF-8');
110 1
		}
111 49
		$str = stripslashes(str_replace("\x1A", '', $str));
112
113 49
		if ($this->hasEscapedChars)
114 49
		{
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 49
		return $str;
127
	}
128
129
	/**
130
	* Decode the optional attribute portion of a link
131
	*
132
	* @param  string $str Encoded string, possibly surrounded by quotes and whitespace
133
	* @return string      Decoded string
134
	*/
135 31
	protected function decodeQuotedString($str)
136
	{
137 31
		return $this->decode(preg_replace('/^([\'"])(.*)\\1$/', '$2', trim($str)));
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 20
	protected function getReferenceLinkAttributes($label)
181
	{
182 20
		if (!isset($this->links))
183 20
		{
184 20
			$this->matchLinkReferences();
185 20
		}
186
187 20
		$label = strtolower($label);
188
189 20
		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 212
	protected function getSetextLines()
198
	{
199 212
		$setextLines = [];
200
201 212
		if (strpos($this->text, '-') === false && strpos($this->text, '=') === false)
202 212
		{
203 182
			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 30
		$regexp = '/^(?=[-=>])(?:> ?)*(?=[-=])(?:-+|=+) *$/m';
209 30
		if (preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE))
210 30
		{
211 17
			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 17
				$endTagPos = $matchPos - 1;
216 17
				while ($endTagPos > 0 && $this->text[$endTagPos - 1] === ' ')
217
				{
218 4
					--$endTagPos;
219 4
				}
220
221
				// Store at the offset of the LF character
222 17
				$setextLines[$matchPos - 1] = [
223 17
					'endTagLen'  => $matchPos + strlen($match) - $endTagPos,
224 17
					'endTagPos'  => $endTagPos,
225 17
					'quoteDepth' => substr_count($match, '>'),
226 17
					'tagName'    => ($match[0] === '=') ? 'H1' : 'H2'
227 17
				];
228 17
			}
229 17
		}
230
231 30
		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 31
	protected function getInlineLinkAttributes(array $m)
279
	{
280 31
		$attrValues = [$this->decode($m[3][0])];
281 31
		if (!empty($m[4][0]))
282 31
		{
283 13
			$title = $this->decodeQuotedString($m[4][0]);
284 13
			if ($title > '')
285 13
			{
286 12
				$attrValues[] = $title;
287 12
			}
288 13
		}
289
290 31
		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 212
	protected function init($text)
318
	{
319 212
		if (strpos($text, '\\') === false || !preg_match('/\\\\[!")*[\\\\\\]^_`~]/', $text))
320 212
		{
321 202
			$this->hasEscapedChars = false;
322 202
		}
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 212
		$text .= "\n\n\x17";
335
336 212
		$this->text = $text;
337 212
		unset($this->links);
338 212
	}
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 212
	protected function markBoundary($pos)
370
	{
371 212
		$this->text[$pos] = "\x17";
372 212
	}
373
374
	/**
375
	* Match block-level markup, as well as forced line breaks and headers
376
	*
377
	* @return void
378
	*/
379 212
	protected function matchBlockLevelMarkup()
380
	{
381 212
		$codeFence    = null;
382 212
		$codeIndent   = 4;
383 212
		$codeTag      = null;
384 212
		$lineIsEmpty  = true;
385 212
		$lists        = [];
386 212
		$listsCnt     = 0;
387 212
		$newContext   = false;
388 212
		$quotes       = [];
389 212
		$quotesCnt    = 0;
390 212
		$setextLines  = $this->getSetextLines();
391 212
		$textBoundary = 0;
392
393 212
		$regexp = '/^(?:(?=[-*+\\d \\t>`~#_])((?: {0,3}> ?)+)?([ \\t]+)?(\\* *\\* *\\*[* ]*$|- *- *-[- ]*$|_ *_ *_[_ ]*$|=+$)?((?:[-*+]|\\d+\\.)[ \\t]+(?=\\S))?[ \\t]*(#{1,6}[ \\t]+|```+[^`\\n]*$|~~~+[^~\\n]*$)?)?/m';
394 212
		preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
395
396 212
		foreach ($matches as $m)
397
		{
398 212
			$matchPos   = $m[0][1];
399 212
			$matchLen   = strlen($m[0][0]);
400 212
			$ignoreLen  = 0;
401 212
			$quoteDepth = 0;
402
403
			// If the last line was empty then this is not a continuation, and vice-versa
404 212
			$continuation = !$lineIsEmpty;
405
406
			// Capture the position of the end of the line and determine whether the line is empty
407 212
			$lfPos       = strpos($this->text, "\n", $matchPos);
408 212
			$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 212
			$breakParagraph = ($lineIsEmpty && $continuation);
412
413
			// Count quote marks
414 212
			if (!empty($m[1][0]))
415 212
			{
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 212
			if ($quoteDepth < $quotesCnt && !$continuation && !$lineIsEmpty)
427 212
			{
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 212
			if ($quoteDepth > $quotesCnt && !$lineIsEmpty)
440 212
			{
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 212
			$indentWidth = 0;
455 212
			$indentPos   = 0;
456 212
			if (!empty($m[2][0]) && !$codeFence)
457 212
			{
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 212
			if (isset($codeTag) && !$codeFence && $indentWidth < $codeIndent && !$lineIsEmpty)
476 212
			{
477 16
				$newContext = true;
478 16
			}
479
480
			if ($newContext)
481 212
			{
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 212
			if ($indentWidth >= $codeIndent)
513 212
			{
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 212
				$hasListItem = !empty($m[4][0]);
532
533 212
				if (!$indentWidth && !$continuation && !$hasListItem && !$lineIsEmpty)
534 212
				{
535
					// Start of a new paragraph
536 212
					$listIndex = -1;
537 212
				}
538 212
				elseif ($continuation && !$hasListItem)
539
				{
540
					// Continuation of current list item or paragraph
541 212
					$listIndex = $listsCnt - 1;
542 212
				}
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 212
				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 212
				if ($listIndex === $listsCnt && !$hasListItem)
579 212
				{
580 1
					--$listIndex;
581 1
				}
582
583 212
				if ($hasListItem && $listIndex >= 0)
584 212
				{
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 212
				if ($listsCnt && !$continuation && !$lineIsEmpty)
653 212
				{
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 212
				$codeIndent = ($listsCnt + 1) * 4;
667
			}
668
669 212
			if (isset($m[5]))
670 212
			{
671
				// Headers
672 30
				if ($m[5][0][0] === '#')
673 30
				{
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 13
				elseif ($m[5][0][0] === '`' || $m[5][0][0] === '~')
692
				{
693 13
					$tagPos = $matchPos + $ignoreLen;
694 13
					$tagLen = $lfPos - $tagPos;
695
696 13
					if (isset($codeTag) && $m[5][0][0] === $codeFence)
697 13
					{
698 13
						$endTag = $this->parser->addEndTag('CODE', $tagPos, $tagLen);
699 13
						$endTag->pairWith($codeTag);
700 13
						$endTag->setSortPriority(-1);
701
702 13
						$this->parser->addIgnoreTag($textBoundary, $tagPos - $textBoundary);
703
704
						// Overwrite the whole block
705 13
						$this->overwrite($codeTag->getPos(), $tagPos + $tagLen - $codeTag->getPos());
706 13
						$codeTag = null;
707 13
						$codeFence = null;
708 13
					}
709 13
					elseif (!isset($codeTag))
710
					{
711
						// Create code block
712 13
						$codeTag   = $this->parser->addStartTag('CODE', $tagPos, $tagLen);
713 13
						$codeFence = $m[5][0][0];
714 13
						$codeTag->setAttribute('quoteDepth', $quoteDepth);
715
716
						// Ignore the next character, which should be a newline
717 13
						$this->parser->addIgnoreTag($tagPos + $tagLen, 1);
718
719
						// Add the language if present, e.g. ```php
720 13
						$lang = trim(trim($m[5][0], '`~'));
721 13
						if ($lang !== '')
722 13
						{
723 2
							$codeTag->setAttribute('lang', $lang);
724 2
						}
725 13
					}
726 13
				}
727 30
			}
728 212
			elseif (!empty($m[3][0]) && !$listsCnt)
729
			{
730
				// Horizontal rule
731 12
				$this->parser->addSelfClosingTag('HR', $matchPos + $ignoreLen, $matchLen - $ignoreLen);
732 12
				$breakParagraph = true;
733
734
				// Mark the end of the line as a boundary
735 12
				$this->markBoundary($lfPos);
736 12
			}
737 212
			elseif (isset($setextLines[$lfPos]) && $setextLines[$lfPos]['quoteDepth'] === $quoteDepth && !$lineIsEmpty && !$listsCnt && !isset($codeTag))
738
			{
739
				// Setext-style header
740 9
				$this->parser->addTagPair(
741 9
					$setextLines[$lfPos]['tagName'],
742 9
					$matchPos + $ignoreLen,
743 9
					0,
744 9
					$setextLines[$lfPos]['endTagPos'],
745 9
					$setextLines[$lfPos]['endTagLen']
746 9
				);
747
748
				// Overwrite the LF to prevent forced line breaks from matching
749 9
				$this->overwrite($lfPos, 1);
750
751
				// Mark the end of the Setext line
752 9
				$this->markBoundary($setextLines[$lfPos]['endTagPos'] + $setextLines[$lfPos]['endTagLen']);
753 9
			}
754
755
			if ($breakParagraph)
756 212
			{
757 212
				$this->parser->addParagraphBreak($textBoundary);
758 212
				$this->markBoundary($textBoundary);
759 212
			}
760
761 212
			if (!$lineIsEmpty)
762 212
			{
763 212
				$textBoundary = $lfPos;
764 212
			}
765
766
			if ($ignoreLen)
767 212
			{
768 38
				$this->parser->addIgnoreTag($matchPos, $ignoreLen)->setSortPriority(1000);
769 38
			}
770 212
		}
771 212
	}
772
773
	/**
774
	* Match all forms of emphasis (emphasis and strong, using underscores or asterisks)
775
	*
776
	* @return void
777
	*/
778 212
	protected function matchEmphasis()
779
	{
780 212
		$this->matchEmphasisByCharacter('*', '/\\*+/');
781 212
		$this->matchEmphasisByCharacter('_', '/_+/');
782 212
	}
783
784
	/**
785
	* Match emphasis and strong applied using given character
786
	*
787
	* @param  string $character Markup character, either * or _
788
	* @param  string $regexp    Regexp used to match the series of emphasis character
789
	* @return void
790
	*/
791 212
	protected function matchEmphasisByCharacter($character, $regexp)
792
	{
793 212
		$pos = strpos($this->text, $character);
794 212
		if ($pos === false)
795 212
		{
796 212
			return;
797
		}
798
799 52
		foreach ($this->getEmphasisByBlock($regexp, $pos) as $block)
800
		{
801 52
			$this->processEmphasisBlock($block);
802 52
		}
803 52
	}
804
805
	/**
806
	* Match forced line breaks
807
	*
808
	* @return void
809
	*/
810 212
	protected function matchForcedLineBreaks()
811
	{
812 212
		$pos = strpos($this->text, "  \n");
813 212
		while ($pos !== false)
814
		{
815 4
			$this->parser->addBrTag($pos + 2);
816 4
			$pos = strpos($this->text, "  \n", $pos + 3);
817 4
		}
818 212
	}
819
820
	/**
821
	* Match images markup
822
	*
823
	* @return void
824
	*/
825 212
	protected function matchImages()
826
	{
827 212
		$pos = strpos($this->text, '![');
828 212
		if ($pos === false)
829 212
		{
830 196
			return;
831
		}
832
833 16
		preg_match_all(
834 16
			'/!\\[([^\\x17]*?(?=] ?\\()|[^\\x17\\]]*)](?: ?\\[([^\\x17\\]]+)\\]| ?\\(([^\\x17 ")]+)( *(?:"[^\\x17"]*"|\'[^\\x17\']*\'|[^\\x17\\)]*))?\\))?/',
835 16
			$this->text,
836 16
			$matches,
837 16
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
838
			$pos
839 16
		);
840 16
		foreach ($matches as $m)
841
		{
842 16
			$matchPos    = $m[0][1];
843 16
			$matchLen    = strlen($m[0][0]);
844 16
			$contentLen  = strlen($m[1][0]);
845 16
			$startTagPos = $matchPos;
846 16
			$startTagLen = 2;
847 16
			$endTagPos   = $startTagPos + $startTagLen + $contentLen;
848 16
			$endTagLen   = $matchLen - $startTagLen - $contentLen;
849
850 16
			$tag = $this->parser->addTagPair('IMG', $startTagPos, $startTagLen, $endTagPos, $endTagLen);
851 16
			$tag->setAttribute('alt', $this->decode($m[1][0]));
852 16
			$this->setLinkAttributes($tag, $m, ['src', 'title']);
853
854
			// Overwrite the markup
855 16
			$this->overwrite($matchPos, $matchLen);
856 16
		}
857 16
	}
858
859
	/**
860
	* Match inline code
861
	*
862
	* @return void
863
	*/
864 212
	protected function matchInlineCode()
865
	{
866 212
		$pos = strpos($this->text, '`');
867 212
		if ($pos === false)
868 212
		{
869 195
			return;
870
		}
871
872 17
		preg_match_all(
873 17
			'/((`+)(?!`)\\s*)(?:[^\\x17]*?[^`\\s])?(\\s*\\2)(?!`)/',
874 17
			$this->text,
875 17
			$matches,
876 17
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
877
			$pos
878 17
		);
879
880 17
		foreach ($matches as $m)
881
		{
882 16
			$matchLen    = strlen($m[0][0]);
883 16
			$matchPos    = $m[0][1];
884 16
			$startTagLen = strlen($m[1][0]);
885 16
			$endTagLen   = strlen($m[3][0]);
886
887 16
			$this->parser->addTagPair('C', $matchPos, $startTagLen, $matchPos + $matchLen - $endTagLen, $endTagLen);
888
889
			// Overwrite the markup
890 16
			$this->overwrite($matchPos, $matchLen);
891 17
		}
892 17
	}
893
894
	/**
895
	* Capture link reference definitions in current text
896
	*
897
	* @return void
898
	*/
899 20
	protected function matchLinkReferences()
900
	{
901 20
		$this->links = [];
902
903 20
		$regexp = '/^(?:> ?)* {0,3}\\[([^\\x17\\]]+)\\]: *([^\\s\\x17]+)([^\\n\\x17]*)\\n?/m';
904 20
		preg_match_all($regexp, $this->text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
905 20
		foreach ($matches as $m)
906
		{
907 18
			$this->parser->addIgnoreTag($m[0][1], strlen($m[0][0]))->setSortPriority(-2);
908
909
			// Ignore the reference if it already exists
910 18
			$label = strtolower($m[1][0]);
911 18
			if (isset($this->links[$label]))
912 18
			{
913 2
				continue;
914
			}
915
916 18
			$this->links[$label] = [$this->decode($m[2][0])];
917 18
			$title = $this->decodeQuotedString($m[3][0]);
918 18
			if ($title > '')
919 18
			{
920 5
				$this->links[$label][] = $title;
921 5
			}
922 20
		}
923 20
	}
924
925
	/**
926
	* Match inline and reference links
927
	*
928
	* @return void
929
	*/
930 212
	protected function matchLinks()
931
	{
932 212
		$pos = strpos($this->text, '[');
933 212
		if ($pos === false)
934 212
		{
935 169
			return;
936
		}
937
938 43
		preg_match_all(
939 43
			'/\\[([^\\x17]*?(?=] ?\\()|[^\\x17\\]]*)](?: ?\\[([^\\x17\\]]+)\\]| ?\\(([^\\x17 ()]+(?:\\([^\\x17 ()]+\\)[^\\x17 ()]*)*[^\\x17 )]*)( *(?:"[^\\x17"]*"|\'[^\\x17\']*\'|[^\\x17\\)]*))?\\))?/',
940 43
			$this->text,
941 43
			$matches,
942 43
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
943
			$pos
944 43
		);
945 43
		foreach ($matches as $m)
946
		{
947 43
			$matchPos    = $m[0][1];
948 43
			$matchLen    = strlen($m[0][0]);
949 43
			$contentLen  = strlen($m[1][0]);
950 43
			$startTagPos = $matchPos;
951 43
			$startTagLen = 1;
952 43
			$endTagPos   = $startTagPos + $startTagLen + $contentLen;
953 43
			$endTagLen   = $matchLen - $startTagLen - $contentLen;
954
955 43
			$tag = $this->parser->addTagPair('URL', $startTagPos, $startTagLen, $endTagPos, $endTagLen);
956 43
			$this->setLinkAttributes($tag, $m, ['url', 'title']);
957
958
			// Give the link a slightly better priority to give it precedence over
959
			// possible BBCodes such as [b](https://en.wikipedia.org/wiki/B)
960 43
			$tag->setSortPriority(-1);
961
962
			// Overwrite the markup without touching the link's text
963 43
			$this->overwrite($startTagPos, $startTagLen);
964 43
			$this->overwrite($endTagPos,   $endTagLen);
965 43
		}
966 43
	}
967
968
	/**
969
	* Match strikethrough
970
	*
971
	* @return void
972
	*/
973 212
	protected function matchStrikethrough()
974
	{
975 212
		$pos = strpos($this->text, '~~');
976 212
		if ($pos === false)
977 212
		{
978 205
			return;
979
		}
980
981 7
		preg_match_all(
982 7
			'/~~[^\\x17]+?~~/',
983 7
			$this->text,
984 7
			$matches,
985 7
			PREG_OFFSET_CAPTURE,
986
			$pos
987 7
		);
988
989 7
		foreach ($matches[0] as list($match, $matchPos))
990
		{
991 5
			$matchLen = strlen($match);
992
993 5
			$this->parser->addTagPair('DEL', $matchPos, 2, $matchPos + $matchLen - 2, 2);
994 7
		}
995 7
	}
996
997
	/**
998
	* Match superscript
999
	*
1000
	* @return void
1001
	*/
1002 212
	protected function matchSuperscript()
1003
	{
1004 212
		$pos = strpos($this->text, '^');
1005 212
		if ($pos === false)
1006 212
		{
1007 210
			return;
1008
		}
1009
1010 2
		preg_match_all(
1011 2
			'/\\^[^\\x17\\s]++/',
1012 2
			$this->text,
1013 2
			$matches,
1014 2
			PREG_OFFSET_CAPTURE,
1015
			$pos
1016 2
		);
1017
1018 2
		foreach ($matches[0] as list($match, $matchPos))
1019
		{
1020 1
			$matchLen    = strlen($match);
1021 1
			$startTagPos = $matchPos;
1022 1
			$endTagPos   = $matchPos + $matchLen;
1023
1024 1
			$parts = explode('^', $match);
1025 1
			unset($parts[0]);
1026
1027 1
			foreach ($parts as $part)
1028
			{
1029 1
				$this->parser->addTagPair('SUP', $startTagPos, 1, $endTagPos, 0);
1030 1
				$startTagPos += 1 + strlen($part);
1031 1
			}
1032 2
		}
1033 2
	}
1034
1035
	/**
1036
	* Overwrite part of the text with substitution characters ^Z (0x1A)
1037
	*
1038
	* @param  integer $pos Start of the range
1039
	* @param  integer $len Length of text to overwrite
1040
	* @return void
1041
	*/
1042 125
	protected function overwrite($pos, $len)
1043
	{
1044 125
		$this->text = substr($this->text, 0, $pos) . str_repeat("\x1A", $len) . substr($this->text, $pos + $len);
1045 125
	}
1046
1047
	/**
1048
	* Process a list of emphasis markup strings
1049
	*
1050
	* @param  array[] $block List of [matchPos, matchLen] pairs
1051
	* @return void
1052
	*/
1053 52
	protected function processEmphasisBlock(array $block)
1054
	{
1055 52
		$buffered  = 0;
1056 52
		$emPos     = -1;
1057 52
		$strongPos = -1;
1058 52
		foreach ($block as list($matchPos, $matchLen))
1059
		{
1060 51
			$closeLen     = min(3, $matchLen);
1061 51
			$closeEm      = $closeLen & $buffered & 1;
1062 51
			$closeStrong  = $closeLen & $buffered & 2;
1063 51
			$emEndPos     = $matchPos;
1064 51
			$strongEndPos = $matchPos;
1065
1066 51
			if ($buffered > 2 && $emPos === $strongPos)
1067 51
			{
1068
				if ($closeEm)
1069 13
				{
1070 11
					$emPos += 2;
1071 11
				}
1072
				else
1073
				{
1074 2
					++$strongPos;
1075
				}
1076 13
			}
1077
1078 51
			if ($closeEm && $closeStrong)
1079 51
			{
1080 11
				if ($emPos < $strongPos)
1081 11
				{
1082 1
					$emEndPos += 2;
1083 1
				}
1084
				else
1085
				{
1086 10
					++$strongEndPos;
1087
				}
1088 11
			}
1089
1090 51
			$remaining = $matchLen;
1091
			if ($closeEm)
1092 51
			{
1093 34
				--$buffered;
1094 34
				--$remaining;
1095 34
				$this->parser->addTagPair('EM', $emPos, 1, $emEndPos, 1);
1096 34
			}
1097
			if ($closeStrong)
1098 51
			{
1099 22
				$buffered  -= 2;
1100 22
				$remaining -= 2;
1101 22
				$this->parser->addTagPair('STRONG', $strongPos, 2, $strongEndPos, 2);
1102 22
			}
1103
1104 51
			$remaining = min(3, $remaining);
1105 51
			if ($remaining & 1)
1106 51
			{
1107 46
				$emPos = $matchPos + $matchLen - $remaining;
1108 46
			}
1109 51
			if ($remaining & 2)
1110 51
			{
1111 30
				$strongPos = $matchPos + $matchLen - $remaining;
1112 30
			}
1113 51
			$buffered += $remaining;
1114 52
		}
1115 52
	}
1116
1117
	/**
1118
	* Set a URL or IMG tag's attributes
1119
	*
1120
	* @param  Tag      $tag       URL or IMG tag
1121
	* @param  array    $m         Regexp captures
1122
	* @param  string[] $attrNames List of attribute names
1123
	* @return void
1124
	*/
1125 50
	protected function setLinkAttributes(Tag $tag, array $m, array $attrNames)
1126
	{
1127 50
		if (isset($m[3]))
1128 50
		{
1129 31
			$attrValues = $this->getInlineLinkAttributes($m);
1130 31
		}
1131
		else
1132
		{
1133 20
			$label      = (isset($m[2])) ? $m[2][0] : $m[1][0];
1134 20
			$attrValues = $this->getReferenceLinkAttributes($label);
1135
		}
1136
1137 50
		foreach ($attrValues as $k => $attrValue)
1138
		{
1139 49
			$tag->setAttribute($attrNames[$k], $attrValue);
1140 50
		}
1141
	}
1142
}