Completed
Push — master ( 2fc480...53497c )
by Josh
18:12
created

Parser::gc()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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