Completed
Branch TemplateInspector (5726eb)
by Josh
09:25
created

Parser::setNestingLimit()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 3
cp 0
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 2
crap 2
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2017 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter;
9
10
use InvalidArgumentException;
11
use RuntimeException;
12
use s9e\TextFormatter\Parser\Logger;
13
use s9e\TextFormatter\Parser\Tag;
14
15
class Parser
16
{
17
	/**#@+
18
	* Boolean rules bitfield
19
	*/
20
	const RULE_AUTO_CLOSE        = 1 << 0;
21
	const RULE_AUTO_REOPEN       = 1 << 1;
22
	const RULE_BREAK_PARAGRAPH   = 1 << 2;
23
	const RULE_CREATE_PARAGRAPHS = 1 << 3;
24
	const RULE_DISABLE_AUTO_BR   = 1 << 4;
25
	const RULE_ENABLE_AUTO_BR    = 1 << 5;
26
	const RULE_IGNORE_TAGS       = 1 << 6;
27
	const RULE_IGNORE_TEXT       = 1 << 7;
28
	const RULE_IGNORE_WHITESPACE = 1 << 8;
29
	const RULE_IS_TRANSPARENT    = 1 << 9;
30
	const RULE_PREVENT_BR        = 1 << 10;
31
	const RULE_SUSPEND_AUTO_BR   = 1 << 11;
32
	const RULE_TRIM_FIRST_LINE   = 1 << 12;
33
	/**#@-*/
34
35
	/**
36
	* Bitwise disjunction of rules related to automatic line breaks
37
	*/
38
	const RULES_AUTO_LINEBREAKS = self::RULE_DISABLE_AUTO_BR | self::RULE_ENABLE_AUTO_BR | self::RULE_SUSPEND_AUTO_BR;
39
40
	/**
41
	* Bitwise disjunction of rules that are inherited by subcontexts
42
	*/
43
	const RULES_INHERITANCE = self::RULE_ENABLE_AUTO_BR;
44
45
	/**
46
	* All the characters that are considered whitespace
47
	*/
48
	const WHITESPACE = " \n\t";
49
50
	/**
51
	* @var array Number of open tags for each tag name
52
	*/
53
	protected $cntOpen;
54
55
	/**
56
	* @var array Number of times each tag has been used
57
	*/
58
	protected $cntTotal;
59
60
	/**
61
	* @var array Current context
62
	*/
63
	protected $context;
64
65
	/**
66
	* @var integer How hard the parser has worked on fixing bad markup so far
67
	*/
68
	protected $currentFixingCost;
69
70
	/**
71
	* @var Tag Current tag being processed
72
	*/
73
	protected $currentTag;
74
75
	/**
76
	* @var bool Whether the output contains "rich" tags, IOW any tag that is not <p> or <br/>
77
	*/
78
	protected $isRich;
79
80
	/**
81
	* @var Logger This parser's logger
82
	*/
83
	protected $logger;
84
85
	/**
86
	* @var integer How hard the parser should work on fixing bad markup
87
	*/
88
	public $maxFixingCost = 10000;
89
90
	/**
91
	* @var array Associative array of namespace prefixes in use in document (prefixes used as key)
92
	*/
93
	protected $namespaces;
94
95
	/**
96
	* @var array Stack of open tags (instances of Tag)
97
	*/
98
	protected $openTags;
99
100
	/**
101
	* @var string This parser's output
102
	*/
103
	protected $output;
104
105
	/**
106
	* @var integer Position of the cursor in the original text
107
	*/
108
	protected $pos;
109
110
	/**
111
	* @var array Array of callbacks, using plugin names as keys
112
	*/
113
	protected $pluginParsers = [];
114
115
	/**
116
	* @var array Associative array of [pluginName => pluginConfig]
117
	*/
118
	protected $pluginsConfig;
119
120
	/**
121
	* @var array Variables registered for use in filters
122
	*/
123
	public $registeredVars = [];
124
125
	/**
126
	* @var array Root context, used at the root of the document
127
	*/
128
	protected $rootContext;
129
130
	/**
131
	* @var array Tags' config
132
	*/
133
	protected $tagsConfig;
134
135
	/**
136
	* @var array Tag storage
137
	*/
138
	protected $tagStack;
139
140
	/**
141
	* @var bool Whether the tags in the stack are sorted
142
	*/
143
	protected $tagStackIsSorted;
144
145
	/**
146
	* @var string Text being parsed
147
	*/
148
	protected $text;
149
150
	/**
151
	* @var integer Length of the text being parsed
152
	*/
153
	protected $textLen;
154
155
	/**
156
	* @var integer Counter incremented everytime the parser is reset. Used to as a canary to detect
157
	*              whether the parser was reset during execution
158
	*/
159
	protected $uid = 0;
160
161
	/**
162
	* @var integer Position before which we output text verbatim, without paragraphs or linebreaks
163
	*/
164
	protected $wsPos;
165
166
	/**
167
	* Constructor
168
	*/
169 86
	public function __construct(array $config)
170
	{
171 86
		$this->pluginsConfig  = $config['plugins'];
172 86
		$this->registeredVars = $config['registeredVars'];
173 86
		$this->rootContext    = $config['rootContext'];
174 86
		$this->tagsConfig     = $config['tags'];
175
176 86
		$this->__wakeup();
177 86
	}
178
179
	/**
180
	* Serializer
181
	*
182
	* Returns the properties that need to persist through serialization.
183
	*
184
	* NOTE: using __sleep() is preferable to implementing Serializable because it leaves the choice
185
	* of the serializer to the user (e.g. igbinary)
186
	*
187
	* @return array
188
	*/
189
	public function __sleep()
190
	{
191
		return ['pluginsConfig', 'registeredVars', 'rootContext', 'tagsConfig'];
192
	}
193
194
	/**
195
	* Unserializer
196
	*
197
	* @return void
198
	*/
199 86
	public function __wakeup()
200
	{
201 86
		$this->logger = new Logger;
202 86
	}
203
204
	/**
205
	* Reset the parser for a new parsing
206
	*
207
	* @param  string $text Text to be parsed
208
	* @return void
209
	*/
210 86
	protected function reset($text)
211
	{
212
		// Normalize CR/CRLF to LF, remove control characters that aren't allowed in XML
213 86
		$text = preg_replace('/\\r\\n?/', "\n", $text);
214 86
		$text = preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]+/S', '', $text);
215
216
		// Clear the logs
217 86
		$this->logger->clear();
218
219
		// Initialize the rest
220 86
		$this->cntOpen           = [];
221 86
		$this->cntTotal          = [];
222 86
		$this->currentFixingCost = 0;
223 86
		$this->currentTag        = null;
224 86
		$this->isRich            = false;
225 86
		$this->namespaces        = [];
226 86
		$this->openTags          = [];
227 86
		$this->output            = '';
228 86
		$this->pos               = 0;
229 86
		$this->tagStack          = [];
230 86
		$this->tagStackIsSorted  = false;
231 86
		$this->text              = $text;
232 86
		$this->textLen           = strlen($text);
233 86
		$this->wsPos             = 0;
234
235
		// Initialize the root context
236 86
		$this->context = $this->rootContext;
237 86
		$this->context['inParagraph'] = false;
238
239
		// Bump the UID
240 86
		++$this->uid;
241 86
	}
242
243
	/**
244
	* Set a tag's option
245
	*
246
	* This method ensures that the tag's config is a value and not a reference, to prevent
247
	* potential side-effects. References contained *inside* the tag's config are left untouched
248
	*
249
	* @param  string $tagName     Tag's name
250
	* @param  string $optionName  Option's name
251
	* @param  mixed  $optionValue Option's value
252
	* @return void
253
	*/
254
	protected function setTagOption($tagName, $optionName, $optionValue)
255
	{
256
		if (isset($this->tagsConfig[$tagName]))
257
		{
258
			// Copy the tag's config and remove it. That will destroy the reference
259
			$tagConfig = $this->tagsConfig[$tagName];
260
			unset($this->tagsConfig[$tagName]);
261
262
			// Set the new value and replace the tag's config
263
			$tagConfig[$optionName]     = $optionValue;
264
			$this->tagsConfig[$tagName] = $tagConfig;
265
		}
266
	}
267
268
	//==========================================================================
269
	// Public API
270
	//==========================================================================
271
272
	/**
273
	* Disable a tag
274
	*
275
	* @param  string $tagName Name of the tag
276
	* @return void
277
	*/
278
	public function disableTag($tagName)
279
	{
280
		$this->setTagOption($tagName, 'isDisabled', true);
281
	}
282
283
	/**
284
	* Enable a tag
285
	*
286
	* @param  string $tagName Name of the tag
287
	* @return void
288
	*/
289
	public function enableTag($tagName)
290
	{
291
		if (isset($this->tagsConfig[$tagName]))
292
		{
293
			unset($this->tagsConfig[$tagName]['isDisabled']);
294
		}
295
	}
296
297
	/**
298
	* Get this parser's Logger instance
299
	*
300
	* @return Logger
301
	*/
302 6
	public function getLogger()
303
	{
304 6
		return $this->logger;
305
	}
306
307
	/**
308
	* Return the last text parsed
309
	*
310
	* This method returns the normalized text, which may be slightly different from the original
311
	* text in that EOLs are normalized to LF and other control codes are stripped. This method is
312
	* meant to be used in support of processing log entries, which contain offsets based on the
313
	* normalized text
314
	*
315
	* @see Parser::reset()
316
	*
317
	* @return string
318
	*/
319
	public function getText()
320
	{
321
		return $this->text;
322
	}
323
324
	/**
325
	* Parse a text
326
	*
327
	* @param  string $text Text to parse
328
	* @return string       XML representation
329
	*/
330 86
	public function parse($text)
331
	{
332
		// Reset the parser and save the uid
333 86
		$this->reset($text);
334 86
		$uid = $this->uid;
335
336
		// Do the heavy lifting
337 86
		$this->executePluginParsers();
338 86
		$this->processTags();
339
340
		// Finalize the document
341 86
		$this->finalizeOutput();
342
343
		// Check the uid in case a plugin or a filter reset the parser mid-execution
344 86
		if ($this->uid !== $uid)
345
		{
346
			throw new RuntimeException('The parser has been reset during execution');
347
		}
348
349
		// Log a warning if the fixing cost limit was exceeded
350 86
		if ($this->currentFixingCost > $this->maxFixingCost)
351
		{
352 1
			$this->logger->warn('Fixing cost limit exceeded');
353
		}
354
355 86
		return $this->output;
356
	}
357
358
	/**
359
	* Change a tag's tagLimit
360
	*
361
	* NOTE: the default tagLimit should generally be set during configuration instead
362
	*
363
	* @param  string  $tagName  The tag's name, in UPPERCASE
364
	* @param  integer $tagLimit
365
	* @return void
366
	*/
367
	public function setTagLimit($tagName, $tagLimit)
368
	{
369
		$this->setTagOption($tagName, 'tagLimit', $tagLimit);
370
	}
371
372
	/**
373
	* Change a tag's nestingLimit
374
	*
375
	* NOTE: the default nestingLimit should generally be set during configuration instead
376
	*
377
	* @param  string  $tagName      The tag's name, in UPPERCASE
378
	* @param  integer $nestingLimit
379
	* @return void
380
	*/
381
	public function setNestingLimit($tagName, $nestingLimit)
382
	{
383
		$this->setTagOption($tagName, 'nestingLimit', $nestingLimit);
384
	}
385
386
	//==========================================================================
387
	// Filter processing
388
	//==========================================================================
389
390
	/**
391
	* Execute all the attribute preprocessors of given tag
392
	*
393
	* @private
394
	*
395
	* @param  Tag   $tag       Source tag
396
	* @param  array $tagConfig Tag's config
397
	* @return bool             Unconditionally TRUE
398
	*/
399 6
	public static function executeAttributePreprocessors(Tag $tag, array $tagConfig)
400
	{
401 6
		if (!empty($tagConfig['attributePreprocessors']))
402
		{
403 6
			foreach ($tagConfig['attributePreprocessors'] as list($attrName, $regexp, $map))
404
			{
405 6
				if (!$tag->hasAttribute($attrName))
406
				{
407 1
					continue;
408
				}
409
410 5
				self::executeAttributePreprocessor($tag, $attrName, $regexp, $map);
411
			}
412
		}
413
414 6
		return true;
415
	}
416
417
	/**
418
	* Execute an attribute preprocessor
419
	*
420
	* @param  Tag      $tag
421
	* @param  string   $attrName
422
	* @param  string   $regexp
423
	* @param  string[] $map
424
	* @return void
425
	*/
426 5
	protected static function executeAttributePreprocessor(Tag $tag, $attrName, $regexp, $map)
427
	{
428 5
		$attrValue = $tag->getAttribute($attrName);
429 5
		$captures  = self::getNamedCaptures($attrValue, $regexp, $map);
430 5
		foreach ($captures as $k => $v)
431
		{
432
			// Attribute preprocessors cannot overwrite other attributes but they can
433
			// overwrite themselves
434 4
			if ($k === $attrName || !$tag->hasAttribute($k))
435
			{
436 4
				$tag->setAttribute($k, $v);
437
			}
438
		}
439 5
	}
440
441
	/**
442
	* Execute a regexp and return the values of the mapped captures
443
	*
444
	* @param  string   $attrValue
445
	* @param  string   $regexp
446
	* @param  string[] $map
447
	* @return array
448
	*/
449 5
	protected static function getNamedCaptures($attrValue, $regexp, $map)
450
	{
451 5
		if (!preg_match($regexp, $attrValue, $m))
452
		{
453 1
			return [];
454
		}
455
456 4
		$values = [];
457 4
		foreach ($map as $i => $k)
458
		{
459 4
			if (isset($m[$i]) && $m[$i] !== '')
460
			{
461 4
				$values[$k] = $m[$i];
462
			}
463
		}
464
465 4
		return $values;
466
	}
467
468
	/**
469
	* Execute a filter
470
	*
471
	* @see s9e\TextFormatter\Configurator\Items\ProgrammableCallback
472
	*
473
	* @param  array $filter Programmed callback
474
	* @param  array $vars   Variables to be used when executing the callback
475
	* @return mixed         Whatever the callback returns
476
	*/
477 82
	protected static function executeFilter(array $filter, array $vars)
478
	{
479 82
		$callback = $filter['callback'];
480 82
		$params   = (isset($filter['params'])) ? $filter['params'] : [];
481
482 82
		$args = [];
483 82
		foreach ($params as $k => $v)
484
		{
485 81
			if (is_numeric($k))
486
			{
487
				// By-value param
488 5
				$args[] = $v;
489
			}
490 80
			elseif (isset($vars[$k]))
491
			{
492
				// By-name param using a supplied var
493 78
				$args[] = $vars[$k];
494
			}
495 2
			elseif (isset($vars['registeredVars'][$k]))
496
			{
497
				// By-name param using a registered var
498 1
				$args[] = $vars['registeredVars'][$k];
499
			}
500
			else
501
			{
502
				// Unknown param
503 81
				$args[] = null;
504
			}
505
		}
506
507 82
		return call_user_func_array($callback, $args);
508
	}
509
510
	/**
511
	* Filter the attributes of given tag
512
	*
513
	* @private
514
	*
515
	* @param  Tag    $tag            Tag being checked
516
	* @param  array  $tagConfig      Tag's config
517
	* @param  array  $registeredVars Array of registered vars for use in attribute filters
518
	* @param  Logger $logger         This parser's Logger instance
519
	* @return bool                   Whether the whole attribute set is valid
520
	*/
521 81
	public static function filterAttributes(Tag $tag, array $tagConfig, array $registeredVars, Logger $logger)
522
	{
523 81
		if (empty($tagConfig['attributes']))
524
		{
525 71
			$tag->setAttributes([]);
526
527 71
			return true;
528
		}
529
530
		// Generate values for attributes with a generator set
531 12
		foreach ($tagConfig['attributes'] as $attrName => $attrConfig)
532
		{
533 12
			if (isset($attrConfig['generator']))
534
			{
535 1
				$tag->setAttribute(
536 1
					$attrName,
537 1
					self::executeFilter(
538 1
						$attrConfig['generator'],
539
						[
540 1
							'attrName'       => $attrName,
541 1
							'logger'         => $logger,
542 12
							'registeredVars' => $registeredVars
543
						]
544
					)
545
				);
546
			}
547
		}
548
549
		// Filter and remove invalid attributes
550 12
		foreach ($tag->getAttributes() as $attrName => $attrValue)
551
		{
552
			// Test whether this attribute exists and remove it if it doesn't
553 11
			if (!isset($tagConfig['attributes'][$attrName]))
554
			{
555 1
				$tag->removeAttribute($attrName);
556 1
				continue;
557
			}
558
559 11
			$attrConfig = $tagConfig['attributes'][$attrName];
560
561
			// Test whether this attribute has a filterChain
562 11
			if (!isset($attrConfig['filterChain']))
563
			{
564 6
				continue;
565
			}
566
567
			// Record the name of the attribute being filtered into the logger
568 5
			$logger->setAttribute($attrName);
569
570 5
			foreach ($attrConfig['filterChain'] as $filter)
571
			{
572 5
				$attrValue = self::executeFilter(
573 5
					$filter,
574
					[
575 5
						'attrName'       => $attrName,
576 5
						'attrValue'      => $attrValue,
577 5
						'logger'         => $logger,
578 5
						'registeredVars' => $registeredVars
579
					]
580
				);
581
582 5
				if ($attrValue === false)
583
				{
584 3
					$tag->removeAttribute($attrName);
585 5
					break;
586
				}
587
			}
588
589
			// Update the attribute value if it's valid
590 5
			if ($attrValue !== false)
591
			{
592 2
				$tag->setAttribute($attrName, $attrValue);
593
			}
594
595
			// Remove the attribute's name from the logger
596 5
			$logger->unsetAttribute();
597
		}
598
599
		// Iterate over the attribute definitions to handle missing attributes
600 12
		foreach ($tagConfig['attributes'] as $attrName => $attrConfig)
601
		{
602
			// Test whether this attribute is missing
603 12
			if (!$tag->hasAttribute($attrName))
604
			{
605 5
				if (isset($attrConfig['defaultValue']))
606
				{
607
					// Use the attribute's default value
608 2
					$tag->setAttribute($attrName, $attrConfig['defaultValue']);
609
				}
610 3
				elseif (!empty($attrConfig['required']))
611
				{
612
					// This attribute is missing, has no default value and is required, which means
613
					// the attribute set is invalid
614 12
					return false;
615
				}
616
			}
617
		}
618
619 9
		return true;
620
	}
621
622
	/**
623
	* Execute given tag's filterChain
624
	*
625
	* @param  Tag  $tag Tag to filter
626
	* @return bool      Whether the tag is valid
627
	*/
628 73
	protected function filterTag(Tag $tag)
629
	{
630 73
		$tagName   = $tag->getName();
631 73
		$tagConfig = $this->tagsConfig[$tagName];
632 73
		$isValid   = true;
633
634 73
		if (!empty($tagConfig['filterChain']))
635
		{
636
			// Record the tag being processed into the logger it can be added to the context of
637
			// messages logged during the execution
638 72
			$this->logger->setTag($tag);
639
640
			// Prepare the variables that are accessible to filters
641
			$vars = [
642 72
				'logger'         => $this->logger,
643 72
				'openTags'       => $this->openTags,
644 72
				'parser'         => $this,
645 72
				'registeredVars' => $this->registeredVars,
646 72
				'tag'            => $tag,
647 72
				'tagConfig'      => $tagConfig,
648 72
				'text'           => $this->text
649
			];
650
651 72
			foreach ($tagConfig['filterChain'] as $filter)
652
			{
653 72
				if (!self::executeFilter($filter, $vars))
654
				{
655 1
					$isValid = false;
656 72
					break;
657
				}
658
			}
659
660
			// Remove the tag from the logger
661 72
			$this->logger->unsetTag();
662
		}
663
664 73
		return $isValid;
665
	}
666
667
	//==========================================================================
668
	// Output handling
669
	//==========================================================================
670
671
	/**
672
	* Finalize the output by appending the rest of the unprocessed text and create the root node
673
	*
674
	* @return void
675
	*/
676 86
	protected function finalizeOutput()
677
	{
678
		// Output the rest of the text and close the last paragraph
679 86
		$this->outputText($this->textLen, 0, true);
680
681
		// Remove empty tag pairs, e.g. <I><U></U></I> as well as empty paragraphs
682
		do
683
		{
684 86
			$this->output = preg_replace('(<([^ />]++)[^>]*></\\1>)', '', $this->output, -1, $cnt);
685
		}
686 86
		while ($cnt > 0);
687
688
		// Merge consecutive <i> tags
689 86
		if (strpos($this->output, '</i><i>') !== false)
690
		{
691
			$this->output = str_replace('</i><i>', '', $this->output);
692
		}
693
694
		// Encode Unicode characters that are outside of the BMP
695 86
		$this->output = Utils::encodeUnicodeSupplementaryCharacters($this->output);
696
697
		// Use a <r> root if the text is rich, or <t> for plain text (including <p></p> and <br/>)
698 86
		$tagName = ($this->isRich) ? 'r' : 't';
699
700
		// Prepare the root node with all the namespace declarations
701 86
		$tmp = '<' . $tagName;
702 86
		foreach (array_keys($this->namespaces) as $prefix)
703
		{
704
			$tmp .= ' xmlns:' . $prefix . '="urn:s9e:TextFormatter:' . $prefix . '"';
705
		}
706
707 86
		$this->output = $tmp . '>' . $this->output . '</' . $tagName . '>';
708 86
	}
709
710
	/**
711
	* Append a tag to the output
712
	*
713
	* @param  Tag  $tag Tag to append
714
	* @return void
715
	*/
716 71
	protected function outputTag(Tag $tag)
717
	{
718 71
		$this->isRich = true;
719
720 71
		$tagName  = $tag->getName();
721 71
		$tagPos   = $tag->getPos();
722 71
		$tagLen   = $tag->getLen();
723 71
		$tagFlags = $tag->getFlags();
724
725 71
		if ($tagFlags & self::RULE_IGNORE_WHITESPACE)
726
		{
727 5
			$skipBefore = 1;
728 5
			$skipAfter  = ($tag->isEndTag()) ? 2 : 1;
729
		}
730
		else
731
		{
732 71
			$skipBefore = $skipAfter = 0;
733
		}
734
735
		// Current paragraph must end before the tag if:
736
		//  - the tag is a start (or self-closing) tag and it breaks paragraphs, or
737
		//  - the tag is an end tag (but not self-closing)
738 71
		$closeParagraph = false;
739 71
		if ($tag->isStartTag())
740
		{
741 71
			if ($tagFlags & self::RULE_BREAK_PARAGRAPH)
742
			{
743 71
				$closeParagraph = true;
744
			}
745
		}
746
		else
747
		{
748 59
			$closeParagraph = true;
749
		}
750
751
		// Let the cursor catch up with this tag's position
752 71
		$this->outputText($tagPos, $skipBefore, $closeParagraph);
753
754
		// Capture the text consumed by the tag
755 71
		$tagText = ($tagLen)
756 56
		         ? htmlspecialchars(substr($this->text, $tagPos, $tagLen), ENT_NOQUOTES, 'UTF-8')
757 71
		         : '';
758
759
		// Output current tag
760 71
		if ($tag->isStartTag())
761
		{
762
			// Handle paragraphs before opening the tag
763 71
			if (!($tagFlags & self::RULE_BREAK_PARAGRAPH))
764
			{
765 71
				$this->outputParagraphStart($tagPos);
766
			}
767
768
			// Record this tag's namespace, if applicable
769 71
			$colonPos = strpos($tagName, ':');
770 71
			if ($colonPos)
771
			{
772
				$this->namespaces[substr($tagName, 0, $colonPos)] = 0;
773
			}
774
775
			// Open the start tag and add its attributes, but don't close the tag
776 71
			$this->output .= '<' . $tagName;
777
778
			// We output the attributes in lexical order. Helps canonicalizing the output and could
779
			// prove useful someday
780 71
			$attributes = $tag->getAttributes();
781 71
			ksort($attributes);
782
783 71
			foreach ($attributes as $attrName => $attrValue)
784
			{
785 4
				$this->output .= ' ' . $attrName . '="' . str_replace("\n", '&#10;', htmlspecialchars($attrValue, ENT_COMPAT, 'UTF-8')) . '"';
786
			}
787
788 71
			if ($tag->isSelfClosingTag())
789
			{
790 20
				if ($tagLen)
791
				{
792 19
					$this->output .= '>' . $tagText . '</' . $tagName . '>';
793
				}
794
				else
795
				{
796 20
					$this->output .= '/>';
797
				}
798
			}
799 59
			elseif ($tagLen)
800
			{
801 42
				$this->output .= '><s>' . $tagText . '</s>';
802
			}
803
			else
804
			{
805 71
				$this->output .= '>';
806
			}
807
		}
808
		else
809
		{
810 59
			if ($tagLen)
811
			{
812 39
				$this->output .= '<e>' . $tagText . '</e>';
813
			}
814
815 59
			$this->output .= '</' . $tagName . '>';
816
		}
817
818
		// Move the cursor past the tag
819 71
		$this->pos = $tagPos + $tagLen;
820
821
		// Skip newlines (no other whitespace) after this tag
822 71
		$this->wsPos = $this->pos;
823 71
		while ($skipAfter && $this->wsPos < $this->textLen && $this->text[$this->wsPos] === "\n")
824
		{
825
			// Decrement the number of lines to skip
826 3
			--$skipAfter;
827
828
			// Move the cursor past the newline
829 3
			++$this->wsPos;
830
		}
831 71
	}
832
833
	/**
834
	* Output the text between the cursor's position (included) and given position (not included)
835
	*
836
	* @param  integer $catchupPos     Position we're catching up to
837
	* @param  integer $maxLines       Maximum number of lines to ignore at the end of the text
838
	* @param  bool    $closeParagraph Whether to close the paragraph at the end, if applicable
839
	* @return void
840
	*/
841 86
	protected function outputText($catchupPos, $maxLines, $closeParagraph)
842
	{
843 86
		if ($closeParagraph)
844
		{
845 86
			if (!($this->context['flags'] & self::RULE_CREATE_PARAGRAPHS))
846
			{
847 84
				$closeParagraph = false;
848
			}
849
			else
850
			{
851
				// Ignore any number of lines at the end if we're closing a paragraph
852 3
				$maxLines = -1;
853
			}
854
		}
855
856 86
		if ($this->pos >= $catchupPos)
857
		{
858
			// We're already there, close the paragraph if applicable and return
859 71
			if ($closeParagraph)
860
			{
861
				$this->outputParagraphEnd();
862
			}
863
864 71
			return;
865
		}
866
867
		// Skip over previously identified whitespace if applicable
868 76
		if ($this->wsPos > $this->pos)
869
		{
870 3
			$skipPos       = min($catchupPos, $this->wsPos);
871 3
			$this->output .= substr($this->text, $this->pos, $skipPos - $this->pos);
872 3
			$this->pos     = $skipPos;
873
874 3
			if ($this->pos >= $catchupPos)
875
			{
876
				// Skipped everything. Close the paragraph if applicable and return
877
				if ($closeParagraph)
878
				{
879
					$this->outputParagraphEnd();
880
				}
881
882
				return;
883
			}
884
		}
885
886
		// Test whether we're even supposed to output anything
887 76
		if ($this->context['flags'] & self::RULE_IGNORE_TEXT)
888
		{
889
			$catchupLen  = $catchupPos - $this->pos;
890
			$catchupText = substr($this->text, $this->pos, $catchupLen);
891
892
			// If the catchup text is not entirely composed of whitespace, we put it inside ignore
893
			// tags
894
			if (strspn($catchupText, " \n\t") < $catchupLen)
895
			{
896
				$catchupText = '<i>' . $catchupText . '</i>';
897
			}
898
899
			$this->output .= $catchupText;
900
			$this->pos = $catchupPos;
901
902
			if ($closeParagraph)
903
			{
904
				$this->outputParagraphEnd();
905
			}
906
907
			return;
908
		}
909
910
		// Compute the amount of text to ignore at the end of the output
911 76
		$ignorePos = $catchupPos;
912 76
		$ignoreLen = 0;
913
914
		// Ignore as many lines (including whitespace) as specified
915 76
		while ($maxLines && --$ignorePos >= $this->pos)
916
		{
917 7
			$c = $this->text[$ignorePos];
918 7
			if (strpos(self::WHITESPACE, $c) === false)
919
			{
920 5
				break;
921
			}
922
923 4
			if ($c === "\n")
924
			{
925 3
				--$maxLines;
926
			}
927
928 4
			++$ignoreLen;
929
		}
930
931
		// Adjust $catchupPos to ignore the text at the end
932 76
		$catchupPos -= $ignoreLen;
933
934
		// Break down the text in paragraphs if applicable
935 76
		if ($this->context['flags'] & self::RULE_CREATE_PARAGRAPHS)
936
		{
937 3
			if (!$this->context['inParagraph'])
938
			{
939 3
				$this->outputWhitespace($catchupPos);
940
941 3
				if ($catchupPos > $this->pos)
942
				{
943 3
					$this->outputParagraphStart($catchupPos);
944
				}
945
			}
946
947
			// Look for a paragraph break in this text
948 3
			$pbPos = strpos($this->text, "\n\n", $this->pos);
949
950 3
			while ($pbPos !== false && $pbPos < $catchupPos)
951
			{
952
				$this->outputText($pbPos, 0, true);
953
				$this->outputParagraphStart($catchupPos);
954
955
				$pbPos = strpos($this->text, "\n\n", $this->pos);
956
			}
957
		}
958
959
		// Capture, escape and output the text
960 76
		if ($catchupPos > $this->pos)
961
		{
962 76
			$catchupText = htmlspecialchars(
963 76
				substr($this->text, $this->pos, $catchupPos - $this->pos),
964 76
				ENT_NOQUOTES,
965 76
				'UTF-8'
966
			);
967
968
			// Format line breaks if applicable
969 76
			if (($this->context['flags'] & self::RULES_AUTO_LINEBREAKS) === self::RULE_ENABLE_AUTO_BR)
970
			{
971 8
				$catchupText = str_replace("\n", "<br/>\n", $catchupText);
972
			}
973
974 76
			$this->output .= $catchupText;
975
		}
976
977
		// Close the paragraph if applicable
978 76
		if ($closeParagraph)
979
		{
980 3
			$this->outputParagraphEnd();
981
		}
982
983
		// Add the ignored text if applicable
984 76
		if ($ignoreLen)
985
		{
986 4
			$this->output .= substr($this->text, $catchupPos, $ignoreLen);
987
		}
988
989
		// Move the cursor past the text
990 76
		$this->pos = $catchupPos + $ignoreLen;
991 76
	}
992
993
	/**
994
	* Output a linebreak tag
995
	*
996
	* @param  Tag  $tag
997
	* @return void
998
	*/
999 3
	protected function outputBrTag(Tag $tag)
1000
	{
1001 3
		$this->outputText($tag->getPos(), 0, false);
1002 3
		$this->output .= '<br/>';
1003 3
	}
1004
1005
	/**
1006
	* Output an ignore tag
1007
	*
1008
	* @param  Tag  $tag
1009
	* @return void
1010
	*/
1011 13
	protected function outputIgnoreTag(Tag $tag)
1012
	{
1013 13
		$tagPos = $tag->getPos();
1014 13
		$tagLen = $tag->getLen();
1015
1016
		// Capture the text to ignore
1017 13
		$ignoreText = substr($this->text, $tagPos, $tagLen);
1018
1019
		// Catch up with the tag's position then output the tag
1020 13
		$this->outputText($tagPos, 0, false);
1021 13
		$this->output .= '<i>' . htmlspecialchars($ignoreText, ENT_NOQUOTES, 'UTF-8') . '</i>';
1022 13
		$this->isRich = true;
1023
1024
		// Move the cursor past this tag
1025 13
		$this->pos = $tagPos + $tagLen;
1026 13
	}
1027
1028
	/**
1029
	* Start a paragraph between current position and given position, if applicable
1030
	*
1031
	* @param  integer $maxPos Rightmost position at which the paragraph can be opened
1032
	* @return void
1033
	*/
1034 73
	protected function outputParagraphStart($maxPos)
1035
	{
1036
		// Do nothing if we're already in a paragraph, or if we don't use paragraphs
1037 73
		if ($this->context['inParagraph']
1038 73
		 || !($this->context['flags'] & self::RULE_CREATE_PARAGRAPHS))
1039
		{
1040 71
			return;
1041
		}
1042
1043
		// Output the whitespace between $this->pos and $maxPos if applicable
1044 3
		$this->outputWhitespace($maxPos);
1045
1046
		// Open the paragraph, but only if it's not at the very end of the text
1047 3
		if ($this->pos < $this->textLen)
1048
		{
1049 3
			$this->output .= '<p>';
1050 3
			$this->context['inParagraph'] = true;
1051
		}
1052 3
	}
1053
1054
	/**
1055
	* Close current paragraph at current position if applicable
1056
	*
1057
	* @return void
1058
	*/
1059 3
	protected function outputParagraphEnd()
1060
	{
1061
		// Do nothing if we're not in a paragraph
1062 3
		if (!$this->context['inParagraph'])
1063
		{
1064
			return;
1065
		}
1066
1067 3
		$this->output .= '</p>';
1068 3
		$this->context['inParagraph'] = false;
1069 3
	}
1070
1071
	/**
1072
	* Output the content of a verbatim tag
1073
	*
1074
	* @param  Tag  $tag
1075
	* @return void
1076
	*/
1077 3
	protected function outputVerbatim(Tag $tag)
1078
	{
1079 3
		$flags = $this->context['flags'];
1080 3
		$this->context['flags'] = $tag->getFlags();
1081 3
		$this->outputText($this->currentTag->getPos() + $this->currentTag->getLen(), 0, false);
1082 3
		$this->context['flags'] = $flags;
1083 3
	}
1084
1085
	/**
1086
	* Skip as much whitespace after current position as possible
1087
	*
1088
	* @param  integer $maxPos Rightmost character to be skipped
1089
	* @return void
1090
	*/
1091 3
	protected function outputWhitespace($maxPos)
1092
	{
1093 3
		if ($maxPos > $this->pos)
1094
		{
1095 3
			$spn = strspn($this->text, self::WHITESPACE, $this->pos, $maxPos - $this->pos);
1096
1097 3
			if ($spn)
1098
			{
1099
				$this->output .= substr($this->text, $this->pos, $spn);
1100
				$this->pos += $spn;
1101
			}
1102
		}
1103 3
	}
1104
1105
	//==========================================================================
1106
	// Plugins handling
1107
	//==========================================================================
1108
1109
	/**
1110
	* Disable a plugin
1111
	*
1112
	* @param  string $pluginName Name of the plugin
1113
	* @return void
1114
	*/
1115 5
	public function disablePlugin($pluginName)
1116
	{
1117 5
		if (isset($this->pluginsConfig[$pluginName]))
1118
		{
1119
			// Copy the plugin's config to remove the reference
1120 4
			$pluginConfig = $this->pluginsConfig[$pluginName];
1121 4
			unset($this->pluginsConfig[$pluginName]);
1122
1123
			// Update the value and replace the plugin's config
1124 4
			$pluginConfig['isDisabled'] = true;
1125 4
			$this->pluginsConfig[$pluginName] = $pluginConfig;
1126
		}
1127 5
	}
1128
1129
	/**
1130
	* Enable a plugin
1131
	*
1132
	* @param  string $pluginName Name of the plugin
1133
	* @return void
1134
	*/
1135 2
	public function enablePlugin($pluginName)
1136
	{
1137 2
		if (isset($this->pluginsConfig[$pluginName]))
1138
		{
1139 1
			$this->pluginsConfig[$pluginName]['isDisabled'] = false;
1140
		}
1141 2
	}
1142
1143
	/**
1144
	* Execute given plugin
1145
	*
1146
	* @param  string $pluginName Plugin's name
1147
	* @return void
1148
	*/
1149 96
	protected function executePluginParser($pluginName)
1150
	{
1151 96
		$pluginConfig = $this->pluginsConfig[$pluginName];
1152 96
		if (isset($pluginConfig['quickMatch']) && strpos($this->text, $pluginConfig['quickMatch']) === false)
1153
		{
1154 1
			return;
1155
		}
1156
1157 95
		$matches = [];
1158 95
		if (isset($pluginConfig['regexp']))
1159
		{
1160 4
			$matches = $this->getMatches($pluginConfig['regexp'], $pluginConfig['regexpLimit']);
1161 4
			if (empty($matches))
1162
			{
1163 1
				return;
1164
			}
1165
		}
1166
1167
		// Execute the plugin's parser, which will add tags via $this->addStartTag() and others
1168 94
		call_user_func($this->getPluginParser($pluginName), $this->text, $matches);
1169 94
	}
1170
1171
	/**
1172
	* Execute all the plugins
1173
	*
1174
	* @return void
1175
	*/
1176 97
	protected function executePluginParsers()
1177
	{
1178 97
		foreach ($this->pluginsConfig as $pluginName => $pluginConfig)
1179
		{
1180 97
			if (empty($pluginConfig['isDisabled']))
1181
			{
1182 97
				$this->executePluginParser($pluginName);
1183
			}
1184
		}
1185 97
	}
1186
1187
	/**
1188
	* Execute given regexp and returns as many matches as given limit
1189
	*
1190
	* @param  string  $regexp
1191
	* @param  integer $limit
1192
	* @return array
1193
	*/
1194 4
	protected function getMatches($regexp, $limit)
1195
	{
1196 4
		$cnt = preg_match_all($regexp, $this->text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
1197 4
		if ($cnt > $limit)
1198
		{
1199 1
			$matches = array_slice($matches, 0, $limit);
1200
		}
1201
1202 4
		return $matches;
1203
	}
1204
1205
	/**
1206
	* Get the cached callback for given plugin's parser
1207
	*
1208
	* @param  string $pluginName Plugin's name
1209
	* @return callable
1210
	*/
1211 94
	protected function getPluginParser($pluginName)
1212
	{
1213
		// Cache a new instance of this plugin's parser if there isn't one already
1214 94
		if (!isset($this->pluginParsers[$pluginName]))
1215
		{
1216 1
			$pluginConfig = $this->pluginsConfig[$pluginName];
1217 1
			$className = (isset($pluginConfig['className']))
1218 1
			           ? $pluginConfig['className']
1219 1
			           : 's9e\\TextFormatter\\Plugins\\' . $pluginName . '\\Parser';
1220
1221
			// Register the parser as a callback
1222 1
			$this->pluginParsers[$pluginName] = [new $className($this, $pluginConfig), 'parse'];
1223
		}
1224
1225 94
		return $this->pluginParsers[$pluginName];
1226
	}
1227
1228
	/**
1229
	* Register a parser
1230
	*
1231
	* Can be used to add a new parser with no plugin config, or pre-generate a parser for an
1232
	* existing plugin
1233
	*
1234
	* @param  string   $pluginName
1235
	* @param  callback $parser
1236
	* @return void
1237
	*/
1238 89
	public function registerParser($pluginName, $parser, $regexp = null, $limit = PHP_INT_MAX)
1239
	{
1240 89
		if (!is_callable($parser))
1241
		{
1242 1
			throw new InvalidArgumentException('Argument 1 passed to ' . __METHOD__ . ' must be a valid callback');
1243
		}
1244
		// Create an empty config for this plugin to ensure it is executed
1245 88
		if (!isset($this->pluginsConfig[$pluginName]))
1246
		{
1247 87
			$this->pluginsConfig[$pluginName] = [];
1248
		}
1249 88
		if (isset($regexp))
1250
		{
1251
			$this->pluginsConfig[$pluginName]['regexp']      = $regexp;
1252
			$this->pluginsConfig[$pluginName]['regexpLimit'] = $limit;
1253
		}
1254 88
		$this->pluginParsers[$pluginName] = $parser;
1255 88
	}
1256
1257
	//==========================================================================
1258
	// Rules handling
1259
	//==========================================================================
1260
1261
	/**
1262
	* Apply closeAncestor rules associated with given tag
1263
	*
1264
	* @param  Tag  $tag Tag
1265
	* @return bool      Whether a new tag has been added
1266
	*/
1267 70
	protected function closeAncestor(Tag $tag)
1268
	{
1269 70
		if (!empty($this->openTags))
1270
		{
1271 40
			$tagName   = $tag->getName();
1272 40
			$tagConfig = $this->tagsConfig[$tagName];
1273
1274 40
			if (!empty($tagConfig['rules']['closeAncestor']))
1275
			{
1276 1
				$i = count($this->openTags);
1277
1278 1
				while (--$i >= 0)
1279
				{
1280 1
					$ancestor     = $this->openTags[$i];
1281 1
					$ancestorName = $ancestor->getName();
1282
1283 1
					if (isset($tagConfig['rules']['closeAncestor'][$ancestorName]))
1284
					{
1285 1
						++$this->currentFixingCost;
1286
1287
						// We have to close this ancestor. First we reinsert this tag...
1288 1
						$this->tagStack[] = $tag;
1289
1290
						// ...then we add a new end tag for it with a better priority
1291 1
						$this->addMagicEndTag($ancestor, $tag->getPos(), $tag->getSortPriority() - 1);
1292
1293 1
						return true;
1294
					}
1295
				}
1296
			}
1297
		}
1298
1299 70
		return false;
1300
	}
1301
1302
	/**
1303
	* Apply closeParent rules associated with given tag
1304
	*
1305
	* @param  Tag  $tag Tag
1306
	* @return bool      Whether a new tag has been added
1307
	*/
1308 70
	protected function closeParent(Tag $tag)
1309
	{
1310 70
		if (!empty($this->openTags))
1311
		{
1312 41
			$tagName   = $tag->getName();
1313 41
			$tagConfig = $this->tagsConfig[$tagName];
1314
1315 41
			if (!empty($tagConfig['rules']['closeParent']))
1316
			{
1317 3
				$parent     = end($this->openTags);
1318 3
				$parentName = $parent->getName();
1319
1320 3
				if (isset($tagConfig['rules']['closeParent'][$parentName]))
1321
				{
1322 3
					++$this->currentFixingCost;
1323
1324
					// We have to close that parent. First we reinsert the tag...
1325 3
					$this->tagStack[] = $tag;
1326
1327
					// ...then we add a new end tag for it with a better priority
1328 3
					$this->addMagicEndTag($parent, $tag->getPos(), $tag->getSortPriority() - 1);
1329
1330 3
					return true;
1331
				}
1332
			}
1333
		}
1334
1335 70
		return false;
1336
	}
1337
1338
	/**
1339
	* Apply the createChild rules associated with given tag
1340
	*
1341
	* @param  Tag  $tag Tag
1342
	* @return void
1343
	*/
1344 71
	protected function createChild(Tag $tag)
1345
	{
1346 71
		$tagConfig = $this->tagsConfig[$tag->getName()];
1347 71
		if (isset($tagConfig['rules']['createChild']))
1348
		{
1349
			$priority = -1000;
1350
			$tagPos   = $this->pos + strspn($this->text, " \n\r\t", $this->pos);
1351
			foreach ($tagConfig['rules']['createChild'] as $tagName)
1352
			{
1353
				$this->addStartTag($tagName, $tagPos, 0, ++$priority);
1354
			}
1355
		}
1356 71
	}
1357
1358
	/**
1359
	* Apply fosterParent rules associated with given tag
1360
	*
1361
	* NOTE: this rule has the potential for creating an unbounded loop, either if a tag tries to
1362
	*       foster itself or two or more tags try to foster each other in a loop. We mitigate the
1363
	*       risk by preventing a tag from creating a child of itself (the parent still gets closed)
1364
	*       and by checking and increasing the currentFixingCost so that a loop of multiple tags
1365
	*       do not run indefinitely. The default tagLimit and nestingLimit also serve to prevent the
1366
	*       loop from running indefinitely
1367
	*
1368
	* @param  Tag  $tag Tag
1369
	* @return bool      Whether a new tag has been added
1370
	*/
1371 70
	protected function fosterParent(Tag $tag)
1372
	{
1373 70
		if (!empty($this->openTags))
1374
		{
1375 41
			$tagName   = $tag->getName();
1376 41
			$tagConfig = $this->tagsConfig[$tagName];
1377
1378 41
			if (!empty($tagConfig['rules']['fosterParent']))
1379
			{
1380 2
				$parent     = end($this->openTags);
1381 2
				$parentName = $parent->getName();
1382
1383 2
				if (isset($tagConfig['rules']['fosterParent'][$parentName]))
1384
				{
1385 2
					if ($parentName !== $tagName && $this->currentFixingCost < $this->maxFixingCost)
1386
					{
1387 2
						$this->addFosterTag($tag, $parent);
1388
					}
1389
1390
					// Reinsert current tag
1391 2
					$this->tagStack[] = $tag;
1392
1393
					// And finally close its parent with a priority that ensures it is processed
1394
					// before this tag
1395 2
					$this->addMagicEndTag($parent, $tag->getPos(), $tag->getSortPriority() - 1);
1396
1397
					// Adjust the fixing cost to account for the additional tags/processing
1398 2
					$this->currentFixingCost += 4;
1399
1400 2
					return true;
1401
				}
1402
			}
1403
		}
1404
1405 70
		return false;
1406
	}
1407
1408
	/**
1409
	* Apply requireAncestor rules associated with given tag
1410
	*
1411
	* @param  Tag  $tag Tag
1412
	* @return bool      Whether this tag has an unfulfilled requireAncestor requirement
1413
	*/
1414 72
	protected function requireAncestor(Tag $tag)
1415
	{
1416 72
		$tagName   = $tag->getName();
1417 72
		$tagConfig = $this->tagsConfig[$tagName];
1418
1419 72
		if (isset($tagConfig['rules']['requireAncestor']))
1420
		{
1421 1
			foreach ($tagConfig['rules']['requireAncestor'] as $ancestorName)
1422
			{
1423 1
				if (!empty($this->cntOpen[$ancestorName]))
1424
				{
1425 1
					return false;
1426
				}
1427
			}
1428
1429 1
			$this->logger->err('Tag requires an ancestor', [
1430 1
				'requireAncestor' => implode(',', $tagConfig['rules']['requireAncestor']),
1431 1
				'tag'             => $tag
1432
			]);
1433
1434 1
			return true;
1435
		}
1436
1437 71
		return false;
1438
	}
1439
1440
	//==========================================================================
1441
	// Tag processing
1442
	//==========================================================================
1443
1444
	/**
1445
	* Create and add a copy of a tag as a child of a given tag
1446
	*
1447
	* @param  Tag  $tag       Current tag
1448
	* @param  Tag  $fosterTag Tag to foster
1449
	* @return void
1450
	*/
1451 2
	protected function addFosterTag(Tag $tag, Tag $fosterTag)
1452
	{
1453 2
		list($childPos, $childPrio) = $this->getMagicStartCoords($tag->getPos() + $tag->getLen());
1454
1455
		// Add a 0-width copy of the parent tag after this tag and make it depend on this tag
1456 2
		$childTag = $this->addCopyTag($fosterTag, $childPos, 0, $childPrio);
1457 2
		$tag->cascadeInvalidationTo($childTag);
1458 2
	}
1459
1460
	/**
1461
	* Create and add an end tag for given start tag at given position
1462
	*
1463
	* @param  Tag     $startTag Start tag
1464
	* @param  integer $tagPos   End tag's position (will be adjusted for whitespace if applicable)
1465
	* @param  integer $prio     End tag's priority
1466
	* @return Tag
1467
	*/
1468 9
	protected function addMagicEndTag(Tag $startTag, $tagPos, $prio = 0)
1469
	{
1470 9
		$tagName = $startTag->getName();
1471
1472
		// Adjust the end tag's position if whitespace is to be minimized
1473 9
		if (($this->currentTag->getFlags() | $startTag->getFlags()) & self::RULE_IGNORE_WHITESPACE)
1474
		{
1475 3
			$tagPos = $this->getMagicEndPos($tagPos);
1476
		}
1477
1478
		// Add a 0-width end tag that is paired with the given start tag
1479 9
		$endTag = $this->addEndTag($tagName, $tagPos, 0, $prio);
1480 9
		$endTag->pairWith($startTag);
1481
1482 9
		return $endTag;
1483
	}
1484
1485
	/**
1486
	* Compute the position of a magic end tag, adjusted for whitespace
1487
	*
1488
	* @param  integer $tagPos Rightmost possible position for the tag
1489
	* @return integer
1490
	*/
1491 5
	protected function getMagicEndPos($tagPos)
1492
	{
1493
		// Back up from given position to the cursor's position until we find a character that
1494
		// is not whitespace
1495 5
		while ($tagPos > $this->pos && strpos(self::WHITESPACE, $this->text[$tagPos - 1]) !== false)
1496
		{
1497 5
			--$tagPos;
1498
		}
1499
1500 5
		return $tagPos;
1501
	}
1502
1503
	/**
1504
	* Compute the position and priority of a magic start tag, adjusted for whitespace
1505
	*
1506
	* @param  integer   $tagPos Leftmost possible position for the tag
1507
	* @return integer[]         [Tag pos, priority]
1508
	*/
1509 2
	protected function getMagicStartCoords($tagPos)
1510
	{
1511 2
		if (empty($this->tagStack))
1512
		{
1513
			// Set the next position outside the text boundaries
1514
			$nextPos  = $this->textLen + 1;
1515
			$nextPrio = 0;
1516
		}
1517
		else
1518
		{
1519 2
			$nextTag  = end($this->tagStack);
1520 2
			$nextPos  = $nextTag->getPos();
1521 2
			$nextPrio = $nextTag->getSortPriority();
1522
		}
1523
1524
		// Find the first non-whitespace position before next tag or the end of text
1525 2
		while ($tagPos < $nextPos && strpos(self::WHITESPACE, $this->text[$tagPos]) !== false)
1526
		{
1527
			++$tagPos;
1528
		}
1529
1530
		// Set a priority that ensures this tag appears before the next tag
1531 2
		$prio = ($tagPos === $nextPos) ? $nextPrio - 1 : 0;
1532
1533 2
		return [$tagPos, $prio];
1534
	}
1535
1536
	/**
1537
	* Test whether given start tag is immediately followed by a closing tag
1538
	*
1539
	* @param  Tag  $tag Start tag
1540
	* @return bool
1541
	*/
1542 3
	protected function isFollowedByClosingTag(Tag $tag)
1543
	{
1544 3
		return (empty($this->tagStack)) ? false : end($this->tagStack)->canClose($tag);
1545
	}
1546
1547
	/**
1548
	* Process all tags in the stack
1549
	*
1550
	* @return void
1551
	*/
1552 86
	protected function processTags()
1553
	{
1554 86
		if (empty($this->tagStack))
1555
		{
1556 6
			return;
1557
		}
1558
1559
		// Initialize the count tables
1560 80
		foreach (array_keys($this->tagsConfig) as $tagName)
1561
		{
1562 73
			$this->cntOpen[$tagName]  = 0;
1563 73
			$this->cntTotal[$tagName] = 0;
1564
		}
1565
1566
		// Process the tag stack, close tags that were left open and repeat until done
1567
		do
1568
		{
1569 80
			while (!empty($this->tagStack))
1570
			{
1571 80
				if (!$this->tagStackIsSorted)
1572
				{
1573 80
					$this->sortTags();
1574
				}
1575
1576 80
				$this->currentTag = array_pop($this->tagStack);
1577 80
				$this->processCurrentTag();
1578
			}
1579
1580
			// Close tags that were left open
1581 80
			foreach ($this->openTags as $startTag)
1582
			{
1583
				// NOTE: we add tags in hierarchical order (ancestors to descendants) but since
1584
				//       the stack is processed in LIFO order, it means that tags get closed in
1585
				//       the correct order, from descendants to ancestors
1586 5
				$this->addMagicEndTag($startTag, $this->textLen);
1587
			}
1588
		}
1589 80
		while (!empty($this->tagStack));
1590 80
	}
1591
1592
	/**
1593
	* Process current tag
1594
	*
1595
	* @return void
1596
	*/
1597 80
	protected function processCurrentTag()
1598
	{
1599
		// Invalidate current tag if tags are disabled and current tag would not close the last open
1600
		// tag and is not a system tag
1601 80
		if (($this->context['flags'] & self::RULE_IGNORE_TAGS)
1602 80
		 && !$this->currentTag->canClose(end($this->openTags))
1603 80
		 && !$this->currentTag->isSystemTag())
1604
		{
1605 4
			$this->currentTag->invalidate();
1606
		}
1607
1608 80
		$tagPos = $this->currentTag->getPos();
1609 80
		$tagLen = $this->currentTag->getLen();
1610
1611
		// Test whether the cursor passed this tag's position already
1612 80
		if ($this->pos > $tagPos && !$this->currentTag->isInvalid())
1613
		{
1614
			// Test whether this tag is paired with a start tag and this tag is still open
1615 15
			$startTag = $this->currentTag->getStartTag();
1616
1617 15
			if ($startTag && in_array($startTag, $this->openTags, true))
1618
			{
1619
				// Create an end tag that matches current tag's start tag, which consumes as much of
1620
				// the same text as current tag and is paired with the same start tag
1621 2
				$this->addEndTag(
1622 2
					$startTag->getName(),
1623 2
					$this->pos,
1624 2
					max(0, $tagPos + $tagLen - $this->pos)
1625 2
				)->pairWith($startTag);
1626
1627
				// Note that current tag is not invalidated, it's merely replaced
1628 2
				return;
1629
			}
1630
1631
			// If this is an ignore tag, try to ignore as much as the remaining text as possible
1632 13
			if ($this->currentTag->isIgnoreTag())
1633
			{
1634 2
				$ignoreLen = $tagPos + $tagLen - $this->pos;
1635
1636 2
				if ($ignoreLen > 0)
1637
				{
1638
					// Create a new ignore tag and move on
1639 1
					$this->addIgnoreTag($this->pos, $ignoreLen);
1640
1641 1
					return;
1642
				}
1643
			}
1644
1645
			// Skipped tags are invalidated
1646 12
			$this->currentTag->invalidate();
1647
		}
1648
1649 80
		if ($this->currentTag->isInvalid())
1650
		{
1651 16
			return;
1652
		}
1653
1654 80
		if ($this->currentTag->isIgnoreTag())
1655
		{
1656 5
			$this->outputIgnoreTag($this->currentTag);
1657
		}
1658 79
		elseif ($this->currentTag->isBrTag())
1659
		{
1660
			// Output the tag if it's allowed, ignore it otherwise
1661 4
			if (!($this->context['flags'] & self::RULE_PREVENT_BR))
1662
			{
1663 4
				$this->outputBrTag($this->currentTag);
1664
			}
1665
		}
1666 77
		elseif ($this->currentTag->isParagraphBreak())
1667
		{
1668 3
			$this->outputText($this->currentTag->getPos(), 0, true);
1669
		}
1670 75
		elseif ($this->currentTag->isVerbatim())
1671
		{
1672 3
			$this->outputVerbatim($this->currentTag);
1673
		}
1674 72
		elseif ($this->currentTag->isStartTag())
1675
		{
1676 72
			$this->processStartTag($this->currentTag);
1677
		}
1678
		else
1679
		{
1680 59
			$this->processEndTag($this->currentTag);
1681
		}
1682 80
	}
1683
1684
	/**
1685
	* Process given start tag (including self-closing tags) at current position
1686
	*
1687
	* @param  Tag  $tag Start tag (including self-closing)
1688
	* @return void
1689
	*/
1690 72
	protected function processStartTag(Tag $tag)
1691
	{
1692 72
		$tagName   = $tag->getName();
1693 72
		$tagConfig = $this->tagsConfig[$tagName];
1694
1695
		// 1. Check that this tag has not reached its global limit tagLimit
1696
		// 2. Execute this tag's filterChain, which will filter/validate its attributes
1697
		// 3. Apply closeParent, closeAncestor and fosterParent rules
1698
		// 4. Check for nestingLimit
1699
		// 5. Apply requireAncestor rules
1700
		//
1701
		// This order ensures that the tag is valid and within the set limits before we attempt to
1702
		// close parents or ancestors. We need to close ancestors before we can check for nesting
1703
		// limits, whether this tag is allowed within current context (the context may change
1704
		// as ancestors are closed) or whether the required ancestors are still there (they might
1705
		// have been closed by a rule.)
1706 72
		if ($this->cntTotal[$tagName] >= $tagConfig['tagLimit'])
1707
		{
1708 1
			$this->logger->err(
1709 1
				'Tag limit exceeded',
1710
				[
1711 1
					'tag'      => $tag,
1712 1
					'tagName'  => $tagName,
1713 1
					'tagLimit' => $tagConfig['tagLimit']
1714
				]
1715
			);
1716 1
			$tag->invalidate();
1717
1718 1
			return;
1719
		}
1720
1721 72
		if (!$this->filterTag($tag))
1722
		{
1723 1
			$tag->invalidate();
1724
1725 1
			return;
1726
		}
1727
1728 72
		if ($this->currentFixingCost < $this->maxFixingCost)
1729
		{
1730 70
			if ($this->fosterParent($tag) || $this->closeParent($tag) || $this->closeAncestor($tag))
1731
			{
1732
				// This tag parent/ancestor needs to be closed, we just return (the tag is still valid)
1733 5
				return;
1734
			}
1735
		}
1736
1737 72
		if ($this->cntOpen[$tagName] >= $tagConfig['nestingLimit'])
1738
		{
1739 1
			$this->logger->err(
1740 1
				'Nesting limit exceeded',
1741
				[
1742 1
					'tag'          => $tag,
1743 1
					'tagName'      => $tagName,
1744 1
					'nestingLimit' => $tagConfig['nestingLimit']
1745
				]
1746
			);
1747 1
			$tag->invalidate();
1748
1749 1
			return;
1750
		}
1751
1752 72
		if (!$this->tagIsAllowed($tagName))
1753
		{
1754 7
			$msg     = 'Tag is not allowed in this context';
1755 7
			$context = ['tag' => $tag, 'tagName' => $tagName];
1756 7
			if ($tag->getLen() > 0)
1757
			{
1758 6
				$this->logger->warn($msg, $context);
1759
			}
1760
			else
1761
			{
1762 1
				$this->logger->debug($msg, $context);
1763
			}
1764 7
			$tag->invalidate();
1765
1766 7
			return;
1767
		}
1768
1769 72
		if ($this->requireAncestor($tag))
1770
		{
1771 1
			$tag->invalidate();
1772
1773 1
			return;
1774
		}
1775
1776
		// If this tag has an autoClose rule and it's not paired with an end tag or followed by an
1777
		// end tag, we replace it with a self-closing tag with the same properties
1778 71
		if ($tag->getFlags() & self::RULE_AUTO_CLOSE
1779 71
		 && !$tag->getEndTag()
1780 71
		 && !$this->isFollowedByClosingTag($tag))
1781
		{
1782 2
			$newTag = new Tag(Tag::SELF_CLOSING_TAG, $tagName, $tag->getPos(), $tag->getLen());
1783 2
			$newTag->setAttributes($tag->getAttributes());
1784 2
			$newTag->setFlags($tag->getFlags());
1785
1786 2
			$tag = $newTag;
1787
		}
1788
1789 71
		if ($tag->getFlags() & self::RULE_TRIM_FIRST_LINE
1790 71
		 && !$tag->getEndTag()
1791 71
		 && substr($this->text, $tag->getPos() + $tag->getLen(), 1) === "\n")
1792
		{
1793
			$this->addIgnoreTag($tag->getPos() + $tag->getLen(), 1);
1794
		}
1795
1796
		// This tag is valid, output it and update the context
1797 71
		$this->outputTag($tag);
1798 71
		$this->pushContext($tag);
1799
1800
		// Apply the createChild rules if applicable
1801 71
		$this->createChild($tag);
1802 71
	}
1803
1804
	/**
1805
	* Process given end tag at current position
1806
	*
1807
	* @param  Tag  $tag end tag
1808
	* @return void
1809
	*/
1810 59
	protected function processEndTag(Tag $tag)
1811
	{
1812 59
		$tagName = $tag->getName();
1813
1814 59
		if (empty($this->cntOpen[$tagName]))
1815
		{
1816
			// This is an end tag with no start tag
1817 3
			return;
1818
		}
1819
1820
		/**
1821
		* @var array List of tags need to be closed before given tag
1822
		*/
1823 59
		$closeTags = [];
1824
1825
		// Iterate through all open tags from last to first to find a match for our tag
1826 59
		$i = count($this->openTags);
1827 59
		while (--$i >= 0)
1828
		{
1829 59
			$openTag = $this->openTags[$i];
1830
1831 59
			if ($tag->canClose($openTag))
1832
			{
1833 59
				break;
1834
			}
1835
1836 21
			$closeTags[] = $openTag;
1837 21
			++$this->currentFixingCost;
1838
		}
1839
1840 59
		if ($i < 0)
1841
		{
1842
			// Did not find a matching tag
1843 2
			$this->logger->debug('Skipping end tag with no start tag', ['tag' => $tag]);
1844
1845 2
			return;
1846
		}
1847
1848
		// Accumulate flags to determine whether whitespace should be trimmed
1849 59
		$flags = $tag->getFlags();
1850 59
		foreach ($closeTags as $openTag)
1851
		{
1852 20
			$flags |= $openTag->getFlags();
1853
		}
1854 59
		$ignoreWhitespace = (bool) ($flags & self::RULE_IGNORE_WHITESPACE);
1855
1856
		// Only reopen tags if we haven't exceeded our "fixing" budget
1857 59
		$keepReopening = (bool) ($this->currentFixingCost < $this->maxFixingCost);
1858
1859
		// Iterate over tags that are being closed, output their end tag and collect tags to be
1860
		// reopened
1861 59
		$reopenTags = [];
1862 59
		foreach ($closeTags as $openTag)
1863
		{
1864 20
			$openTagName = $openTag->getName();
1865
1866
			// Test whether this tag should be reopened automatically
1867 20
			if ($keepReopening)
1868
			{
1869 18
				if ($openTag->getFlags() & self::RULE_AUTO_REOPEN)
1870
				{
1871 11
					$reopenTags[] = $openTag;
1872
				}
1873
				else
1874
				{
1875 7
					$keepReopening = false;
1876
				}
1877
			}
1878
1879
			// Find the earliest position we can close this open tag
1880 20
			$tagPos = $tag->getPos();
1881 20
			if ($ignoreWhitespace)
1882
			{
1883 5
				$tagPos = $this->getMagicEndPos($tagPos);
1884
			}
1885
1886
			// Output an end tag to close this start tag, then update the context
1887 20
			$endTag = new Tag(Tag::END_TAG, $openTagName, $tagPos, 0);
1888 20
			$endTag->setFlags($openTag->getFlags());
1889 20
			$this->outputTag($endTag);
1890 20
			$this->popContext();
1891
		}
1892
1893
		// Output our tag, moving the cursor past it, then update the context
1894 59
		$this->outputTag($tag);
1895 59
		$this->popContext();
1896
1897
		// If our fixing budget allows it, peek at upcoming tags and remove end tags that would
1898
		// close tags that are already being closed now. Also, filter our list of tags being
1899
		// reopened by removing those that would immediately be closed
1900 59
		if (!empty($closeTags) && $this->currentFixingCost < $this->maxFixingCost)
1901
		{
1902
			/**
1903
			* @var integer Rightmost position of the portion of text to ignore
1904
			*/
1905 18
			$ignorePos = $this->pos;
1906
1907 18
			$i = count($this->tagStack);
1908 18
			while (--$i >= 0 && ++$this->currentFixingCost < $this->maxFixingCost)
1909
			{
1910 13
				$upcomingTag = $this->tagStack[$i];
1911
1912
				// Test whether the upcoming tag is positioned at current "ignore" position and it's
1913
				// strictly an end tag (not a start tag or a self-closing tag)
1914 13
				if ($upcomingTag->getPos() > $ignorePos
1915 13
				 || $upcomingTag->isStartTag())
1916
				{
1917 8
					break;
1918
				}
1919
1920
				// Test whether this tag would close any of the tags we're about to reopen
1921 9
				$j = count($closeTags);
1922
1923 9
				while (--$j >= 0 && ++$this->currentFixingCost < $this->maxFixingCost)
1924
				{
1925 9
					if ($upcomingTag->canClose($closeTags[$j]))
1926
					{
1927
						// Remove the tag from the lists and reset the keys
1928 8
						array_splice($closeTags, $j, 1);
1929
1930 8
						if (isset($reopenTags[$j]))
1931
						{
1932 7
							array_splice($reopenTags, $j, 1);
1933
						}
1934
1935
						// Extend the ignored text to cover this tag
1936 8
						$ignorePos = max(
1937 8
							$ignorePos,
1938 8
							$upcomingTag->getPos() + $upcomingTag->getLen()
1939
						);
1940
1941 8
						break;
1942
					}
1943
				}
1944
			}
1945
1946 18
			if ($ignorePos > $this->pos)
1947
			{
1948
				/**
1949
				* @todo have a method that takes (pos,len) rather than a Tag
1950
				*/
1951 8
				$this->outputIgnoreTag(new Tag(Tag::SELF_CLOSING_TAG, 'i', $this->pos, $ignorePos - $this->pos));
1952
			}
1953
		}
1954
1955
		// Re-add tags that need to be reopened, at current cursor position
1956 59
		foreach ($reopenTags as $startTag)
1957
		{
1958 7
			$newTag = $this->addCopyTag($startTag, $this->pos, 0);
1959
1960
			// Re-pair the new tag
1961 7
			$endTag = $startTag->getEndTag();
1962 7
			if ($endTag)
1963
			{
1964 7
				$newTag->pairWith($endTag);
1965
			}
1966
		}
1967 59
	}
1968
1969
	/**
1970
	* Update counters and replace current context with its parent context
1971
	*
1972
	* @return void
1973
	*/
1974 59
	protected function popContext()
1975
	{
1976 59
		$tag = array_pop($this->openTags);
1977 59
		--$this->cntOpen[$tag->getName()];
1978 59
		$this->context = $this->context['parentContext'];
1979 59
	}
1980
1981
	/**
1982
	* Update counters and replace current context with a new context based on given tag
1983
	*
1984
	* If given tag is a self-closing tag, the context won't change
1985
	*
1986
	* @param  Tag  $tag Start tag (including self-closing)
1987
	* @return void
1988
	*/
1989 71
	protected function pushContext(Tag $tag)
1990
	{
1991 71
		$tagName   = $tag->getName();
1992 71
		$tagFlags  = $tag->getFlags();
1993 71
		$tagConfig = $this->tagsConfig[$tagName];
1994
1995 71
		++$this->cntTotal[$tagName];
1996
1997
		// If this is a self-closing tag, the context remains the same
1998 71
		if ($tag->isSelfClosingTag())
1999
		{
2000 20
			return;
2001
		}
2002
2003
		// Recompute the allowed tags
2004 59
		$allowed = [];
2005 59
		if ($tagFlags & self::RULE_IS_TRANSPARENT)
2006
		{
2007 2
			foreach ($this->context['allowed'] as $k => $v)
2008
			{
2009 2
				$allowed[] = $tagConfig['allowed'][$k] & $v;
2010
			}
2011
		}
2012
		else
2013
		{
2014 58
			foreach ($this->context['allowed'] as $k => $v)
2015
			{
2016 58
				$allowed[] = $tagConfig['allowed'][$k] & (($v & 0xFF00) | ($v >> 8));
2017
			}
2018
		}
2019
2020
		// Use this tag's flags as a base for this context and add inherited rules
2021 59
		$flags = $tagFlags | ($this->context['flags'] & self::RULES_INHERITANCE);
2022
2023
		// RULE_DISABLE_AUTO_BR turns off RULE_ENABLE_AUTO_BR
2024 59
		if ($flags & self::RULE_DISABLE_AUTO_BR)
2025
		{
2026 2
			$flags &= ~self::RULE_ENABLE_AUTO_BR;
2027
		}
2028
2029 59
		++$this->cntOpen[$tagName];
2030 59
		$this->openTags[] = $tag;
2031 59
		$this->context = [
2032 59
			'allowed'       => $allowed,
2033 59
			'flags'         => $flags,
2034
			'inParagraph'   => false,
2035 59
			'parentContext' => $this->context
2036
		];
2037 59
	}
2038
2039
	/**
2040
	* Return whether given tag is allowed in current context
2041
	*
2042
	* @param  string $tagName
2043
	* @return bool
2044
	*/
2045 72
	protected function tagIsAllowed($tagName)
2046
	{
2047 72
		$n = $this->tagsConfig[$tagName]['bitNumber'];
2048
2049 72
		return (bool) ($this->context['allowed'][$n >> 3] & (1 << ($n & 7)));
2050
	}
2051
2052
	//==========================================================================
2053
	// Tag stack
2054
	//==========================================================================
2055
2056
	/**
2057
	* Add a start tag
2058
	*
2059
	* @param  string  $name Name of the tag
2060
	* @param  integer $pos  Position of the tag in the text
2061
	* @param  integer $len  Length of text consumed by the tag
2062
	* @param  integer $prio Tag's priority
2063
	* @return Tag
2064
	*/
2065 76
	public function addStartTag($name, $pos, $len, $prio = 0)
2066
	{
2067 76
		return $this->addTag(Tag::START_TAG, $name, $pos, $len, $prio);
2068
	}
2069
2070
	/**
2071
	* Add an end tag
2072
	*
2073
	* @param  string  $name Name of the tag
2074
	* @param  integer $pos  Position of the tag in the text
2075
	* @param  integer $len  Length of text consumed by the tag
2076
	* @param  integer $prio Tag's priority
2077
	* @return Tag
2078
	*/
2079 64
	public function addEndTag($name, $pos, $len, $prio = 0)
2080
	{
2081 64
		return $this->addTag(Tag::END_TAG, $name, $pos, $len, $prio);
2082
	}
2083
2084
	/**
2085
	* Add a self-closing tag
2086
	*
2087
	* @param  string  $name Name of the tag
2088
	* @param  integer $pos  Position of the tag in the text
2089
	* @param  integer $len  Length of text consumed by the tag
2090
	* @param  integer $prio Tag's priority
2091
	* @return Tag
2092
	*/
2093 39
	public function addSelfClosingTag($name, $pos, $len, $prio = 0)
2094
	{
2095 39
		return $this->addTag(Tag::SELF_CLOSING_TAG, $name, $pos, $len, $prio);
2096
	}
2097
2098
	/**
2099
	* Add a 0-width "br" tag to force a line break at given position
2100
	*
2101
	* @param  integer $pos  Position of the tag in the text
2102
	* @param  integer $prio Tag's priority
2103
	* @return Tag
2104
	*/
2105 6
	public function addBrTag($pos, $prio = 0)
2106
	{
2107 6
		return $this->addTag(Tag::SELF_CLOSING_TAG, 'br', $pos, 0, $prio);
2108
	}
2109
2110
	/**
2111
	* Add an "ignore" tag
2112
	*
2113
	* @param  integer $pos  Position of the tag in the text
2114
	* @param  integer $len  Length of text consumed by the tag
2115
	* @param  integer $prio Tag's priority
2116
	* @return Tag
2117
	*/
2118 7
	public function addIgnoreTag($pos, $len, $prio = 0)
2119
	{
2120 7
		return $this->addTag(Tag::SELF_CLOSING_TAG, 'i', $pos, min($len, $this->textLen - $pos), $prio);
2121
	}
2122
2123
	/**
2124
	* Add a paragraph break at given position
2125
	*
2126
	* Uses a zero-width tag that is actually never output in the result
2127
	*
2128
	* @param  integer $pos  Position of the tag in the text
2129
	* @param  integer $prio Tag's priority
2130
	* @return Tag
2131
	*/
2132 4
	public function addParagraphBreak($pos, $prio = 0)
2133
	{
2134 4
		return $this->addTag(Tag::SELF_CLOSING_TAG, 'pb', $pos, 0, $prio);
2135
	}
2136
2137
	/**
2138
	* Add a copy of given tag at given position and length
2139
	*
2140
	* @param  Tag     $tag  Original tag
2141
	* @param  integer $pos  Copy's position
2142
	* @param  integer $len  Copy's length
2143
	* @param  integer $prio Copy's priority (same as original by default)
2144
	* @return Tag           Copy tag
2145
	*/
2146 13
	public function addCopyTag(Tag $tag, $pos, $len, $prio = null)
2147
	{
2148 13
		if (!isset($prio))
2149
		{
2150 10
			$prio = $tag->getSortPriority();
2151
		}
2152 13
		$copy = $this->addTag($tag->getType(), $tag->getName(), $pos, $len, $prio);
2153 13
		$copy->setAttributes($tag->getAttributes());
2154
2155 13
		return $copy;
2156
	}
2157
2158
	/**
2159
	* Add a tag
2160
	*
2161
	* @param  integer $type Tag's type
2162
	* @param  string  $name Name of the tag
2163
	* @param  integer $pos  Position of the tag in the text
2164
	* @param  integer $len  Length of text consumed by the tag
2165
	* @param  integer $prio Tag's priority
2166
	* @return Tag
2167
	*/
2168 106
	protected function addTag($type, $name, $pos, $len, $prio)
2169
	{
2170
		// Create the tag
2171 106
		$tag = new Tag($type, $name, $pos, $len, $prio);
2172
2173
		// Set this tag's rules bitfield
2174 106
		if (isset($this->tagsConfig[$name]))
2175
		{
2176 94
			$tag->setFlags($this->tagsConfig[$name]['rules']['flags']);
2177
		}
2178
2179
		// Invalidate this tag if it's an unknown tag, a disabled tag, if either of its length or
2180
		// position is negative or if it's out of bounds
2181 106
		if (!isset($this->tagsConfig[$name]) && !$tag->isSystemTag())
2182
		{
2183 2
			$tag->invalidate();
2184
		}
2185 104
		elseif (!empty($this->tagsConfig[$name]['isDisabled']))
2186
		{
2187 1
			$this->logger->warn(
2188 1
				'Tag is disabled',
2189
				[
2190 1
					'tag'     => $tag,
2191 1
					'tagName' => $name
2192
				]
2193
			);
2194 1
			$tag->invalidate();
2195
		}
2196 103
		elseif ($len < 0 || $pos < 0 || $pos + $len > $this->textLen)
2197
		{
2198 6
			$tag->invalidate();
2199
		}
2200
		else
2201
		{
2202 99
			$this->insertTag($tag);
2203
		}
2204
2205 106
		return $tag;
2206
	}
2207
2208
	/**
2209
	* Insert given tag in the tag stack
2210
	*
2211
	* @param  Tag  $tag
2212
	* @return void
2213
	*/
2214 99
	protected function insertTag(Tag $tag)
2215
	{
2216 99
		if (!$this->tagStackIsSorted)
2217
		{
2218 99
			$this->tagStack[] = $tag;
2219
		}
2220
		else
2221
		{
2222
			// Scan the stack and copy every tag to the next slot until we find the correct index
2223 22
			$i = count($this->tagStack);
2224 22
			while ($i > 0 && self::compareTags($this->tagStack[$i - 1], $tag) > 0)
2225
			{
2226 3
				$this->tagStack[$i] = $this->tagStack[$i - 1];
2227 3
				--$i;
2228
			}
2229 22
			$this->tagStack[$i] = $tag;
2230
		}
2231 99
	}
2232
2233
	/**
2234
	* Add a pair of tags
2235
	*
2236
	* @param  string  $name     Name of the tags
2237
	* @param  integer $startPos Position of the start tag
2238
	* @param  integer $startLen Length of the start tag
2239
	* @param  integer $endPos   Position of the start tag
2240
	* @param  integer $endLen   Length of the start tag
2241
	* @param  integer $prio     Start tag's priority (the end tag will be set to minus that value)
2242
	* @return Tag               Start tag
2243
	*/
2244 15
	public function addTagPair($name, $startPos, $startLen, $endPos, $endLen, $prio = 0)
2245
	{
2246
		// NOTE: the end tag is added first to try to keep the stack in the correct order
2247 15
		$endTag   = $this->addEndTag($name, $endPos, $endLen, -$prio);
2248 15
		$startTag = $this->addStartTag($name, $startPos, $startLen, $prio);
2249 15
		$startTag->pairWith($endTag);
2250
2251 15
		return $startTag;
2252
	}
2253
2254
	/**
2255
	* Add a tag that represents a verbatim copy of the original text
2256
	*
2257
	* @param  integer $pos  Position of the tag in the text
2258
	* @param  integer $len  Length of text consumed by the tag
2259
	* @param  integer $prio Tag's priority
2260
	* @return Tag
2261
	*/
2262 3
	public function addVerbatim($pos, $len, $prio = 0)
2263
	{
2264 3
		return $this->addTag(Tag::SELF_CLOSING_TAG, 'v', $pos, $len, $prio);
2265
	}
2266
2267
	/**
2268
	* Sort tags by position and precedence
2269
	*
2270
	* @return void
2271
	*/
2272 86
	protected function sortTags()
2273
	{
2274 86
		usort($this->tagStack, __CLASS__ . '::compareTags');
2275 86
		$this->tagStackIsSorted = true;
2276 86
	}
2277
2278
	/**
2279
	* sortTags() callback
2280
	*
2281
	* Tags are stored as a stack, in LIFO order. We sort tags by position _descending_ so that they
2282
	* are processed in the order they appear in the text.
2283
	*
2284
	* @param  Tag     $a First tag to compare
2285
	* @param  Tag     $b Second tag to compare
2286
	* @return integer
2287
	*/
2288 73
	protected static function compareTags(Tag $a, Tag $b)
2289
	{
2290 73
		$aPos = $a->getPos();
2291 73
		$bPos = $b->getPos();
2292
2293
		// First we order by pos descending
2294 73
		if ($aPos !== $bPos)
2295
		{
2296 69
			return $bPos - $aPos;
2297
		}
2298
2299
		// If the tags start at the same position, we'll use their sortPriority if applicable. Tags
2300
		// with a lower value get sorted last, which means they'll be processed first. IOW, -10 is
2301
		// processed before 10
2302 17
		if ($a->getSortPriority() !== $b->getSortPriority())
2303
		{
2304 3
			return $b->getSortPriority() - $a->getSortPriority();
2305
		}
2306
2307
		// If the tags start at the same position and have the same priority, we'll sort them
2308
		// according to their length, with special considerations for  zero-width tags
2309 14
		$aLen = $a->getLen();
2310 14
		$bLen = $b->getLen();
2311
2312 14
		if (!$aLen || !$bLen)
2313
		{
2314
			// Zero-width end tags are ordered after zero-width start tags so that a pair that ends
2315
			// with a zero-width tag has the opportunity to be closed before another pair starts
2316
			// with a zero-width tag. For example, the pairs that would enclose each of the letters
2317
			// in the string "XY". Self-closing tags are ordered between end tags and start tags in
2318
			// an attempt to keep them out of tag pairs
2319 12
			if (!$aLen && !$bLen)
2320
			{
2321
				$order = [
2322 4
					Tag::END_TAG          => 0,
2323 4
					Tag::SELF_CLOSING_TAG => 1,
2324 4
					Tag::START_TAG        => 2
2325
				];
2326
2327 4
				return $order[$b->getType()] - $order[$a->getType()];
2328
			}
2329
2330
			// Here, we know that only one of $a or $b is a zero-width tags. Zero-width tags are
2331
			// ordered after wider tags so that they have a chance to be processed before the next
2332
			// character is consumed, which would force them to be skipped
2333 8
			return ($aLen) ? -1 : 1;
2334
		}
2335
2336
		// Here we know that both tags start at the same position and have a length greater than 0.
2337
		// We sort tags by length ascending, so that the longest matches are processed first. If
2338
		// their length is identical, the order is undefined as PHP's sort isn't stable
2339 2
		return $aLen - $bLen;
2340
	}
2341
}