Completed
Push — master ( 21a9fc...80ec0e )
by Josh
18:49
created

Parser::filterAttributes()   C

Complexity

Conditions 12
Paths 46

Size

Total Lines 79
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 12

Importance

Changes 0
Metric Value
dl 0
loc 79
rs 5.1215
c 0
b 0
f 0
ccs 32
cts 32
cp 1
cc 12
eloc 31
nc 46
nop 4
crap 12

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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