Completed
Push — master ( b82f54...a36553 )
by Josh
18:58
created

Parser::matchBlockLevelMarkup()   F

Complexity

Conditions 80
Paths > 20000

Size

Total Lines 393
Code Lines 184

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 244
CRAP Score 80

Importance

Changes 4
Bugs 3 Features 0
Metric Value
cc 80
eloc 184
c 4
b 3
f 0
nc 55987201
nop 0
dl 0
loc 393
ccs 244
cts 244
cp 1
crap 80
rs 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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