Completed
Push — master ( efd1ce...65d80c )
by Josh
18:34
created

Parser::matchLinkReferences()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5

Importance

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