Completed
Branch LitedownLinks (ba16de)
by Josh
04:22
created

Parser::matchLinks()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

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