Verzeichnisstruktur phpBB-3.3.15


Veröffentlicht
28.08.2024

So funktioniert es


Auf das letzte Element klicken. Dies geht jeweils ein Schritt zurück

Auf das Icon klicken, dies öffnet das Verzeichnis. Nochmal klicken schließt das Verzeichnis.
Auf den Verzeichnisnamen klicken, dies zeigt nur das Verzeichnis mit Inhalt an

(Beispiel Datei-Icons)

Auf das Icon klicken um den Quellcode anzuzeigen

BBCodeMonkey.php

Zuletzt modifiziert: 02.04.2025, 15:04 - Dateigröße: 23.82 KiB


001  <?php
002   
003  /**
004  * @package   s9e\TextFormatter
005  * @copyright Copyright (c) 2010-2022 The s9e authors
006  * @license   http://www.opensource.org/licenses/mit-license.php The MIT License
007  */
008  namespace s9e\TextFormatter\Plugins\BBCodes\Configurator;
009   
010  use Exception;
011  use InvalidArgumentException;
012  use RuntimeException;
013  use s9e\TextFormatter\Configurator;
014  use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
015  use s9e\TextFormatter\Configurator\Items\Attribute;
016  use s9e\TextFormatter\Configurator\Items\ProgrammableCallback;
017  use s9e\TextFormatter\Configurator\Items\Tag;
018  use s9e\TextFormatter\Configurator\Items\Template;
019   
020  class BBCodeMonkey
021  {
022      /**
023      * Expression that matches a regexp such as /foo/i
024      */
025      const REGEXP = '(.).*?(?<!\\\\)(?>\\\\\\\\)*+\\g{-1}[DSUisu]*';
026   
027      /**
028      * @var array List of pre- and post- filters that are explicitly allowed in BBCode definitions.
029      *            We use a whitelist approach because there are so many different risky callbacks
030      *            that it would be too easy to let something dangerous slip by, e.g.: unlink,
031      *            system, etc...
032      */
033      public $allowedFilters = [
034          'addslashes',
035          'dechex',
036          'intval',
037          'json_encode',
038          'ltrim',
039          'mb_strtolower',
040          'mb_strtoupper',
041          'rawurlencode',
042          'rtrim',
043          'str_rot13',
044          'stripslashes',
045          'strrev',
046          'strtolower',
047          'strtotime',
048          'strtoupper',
049          'trim',
050          'ucfirst',
051          'ucwords',
052          'urlencode'
053      ];
054   
055      /**
056      * @var Configurator Instance of Configurator;
057      */
058      protected $configurator;
059   
060      /**
061      * @var array Regexps used in the named subpatterns generated automatically for composite
062      *            attributes. For instance, "foo={NUMBER},{NUMBER}" will be transformed into
063      *            'foo={PARSE=#^(?<foo0>\\d+),(?<foo1>\\d+)$#D}'
064      */
065      public $tokenRegexp = [
066          'ANYTHING'   => '[\\s\\S]*?',
067          'COLOR'      => '[a-zA-Z]+|#[0-9a-fA-F]+',
068          'EMAIL'      => '[^@]+@.+?',
069          'FLOAT'      => '(?>0|-?[1-9]\\d*)(?>\\.\\d+)?(?>e[1-9]\\d*)?',
070          'ID'         => '[-a-zA-Z0-9_]+',
071          'IDENTIFIER' => '[-a-zA-Z0-9_]+',
072          'INT'        => '0|-?[1-9]\\d*',
073          'INTEGER'    => '0|-?[1-9]\\d*',
074          'NUMBER'     => '\\d+',
075          'RANGE'      => '\\d+',
076          'SIMPLETEXT' => '[-a-zA-Z0-9+.,_ ]+',
077          'TEXT'       => '[\\s\\S]*?',
078          'UINT'       => '0|[1-9]\\d*'
079      ];
080   
081      /**
082      * @var array List of token types that are used to represent raw, unfiltered content
083      */
084      public $unfilteredTokens = [
085          'ANYTHING',
086          'TEXT'
087      ];
088   
089      /**
090      * Constructor
091      *
092      * @param  Configurator $configurator Instance of Configurator
093      */
094      public function __construct(Configurator $configurator)
095      {
096          $this->configurator = $configurator;
097      }
098   
099      /**
100      * Create a BBCode and its underlying tag and template(s) based on its reference usage
101      *
102      * @param  string          $usage    BBCode usage, e.g. [B]{TEXT}[/b]
103      * @param  string|Template $template BBCode's template
104      * @return array                     An array containing three elements: 'bbcode', 'bbcodeName'
105      *                                   and 'tag'
106      */
107      public function create($usage, $template)
108      {
109          // Parse the BBCode usage
110          $config = $this->parse($usage);
111   
112          // Create a template object for manipulation
113          if (!($template instanceof Template))
114          {
115              $template = new Template($template);
116          }
117   
118          // Replace the passthrough token in the BBCode's template
119          $template->replaceTokens(
120              '#\\{(?:[A-Z]+[A-Z_0-9]*|@[-\\w]+)\\}#',
121              function ($m) use ($config)
122              {
123                  $tokenId = substr($m[0], 1, -1);
124   
125                  // Acknowledge {@foo} as an XPath expression even outside of attribute value
126                  // templates
127                  if ($tokenId[0] === '@')
128                  {
129                      return ['expression', $tokenId];
130                  }
131   
132                  // Test whether this is a known token
133                  if (isset($config['tokens'][$tokenId]))
134                  {
135                      // Replace with the corresponding attribute
136                      return ['expression', '@' . $config['tokens'][$tokenId]];
137                  }
138   
139                  // Test whether the token is used as passthrough
140                  if ($tokenId === $config['passthroughToken'])
141                  {
142                      return ['passthrough'];
143                  }
144   
145                  // Undefined token. If it's the name of a filter, consider it's an error
146                  if ($this->isFilter($tokenId))
147                  {
148                      throw new RuntimeException('Token {' . $tokenId . '} is ambiguous or undefined');
149                  }
150   
151                  // Use the token's name as parameter name
152                  return ['expression', '$' . $tokenId];
153              }
154          );
155   
156          // Prepare the return array
157          $return = [
158              'bbcode'     => $config['bbcode'],
159              'bbcodeName' => $config['bbcodeName'],
160              'tag'        => $config['tag']
161          ];
162   
163          // Set the template for this BBCode's tag
164          $return['tag']->template = $template;
165   
166          return $return;
167      }
168   
169      /**
170      * Create a BBCode based on its reference usage
171      *
172      * @param  string $usage BBCode usage, e.g. [B]{TEXT}[/b]
173      * @return array
174      */
175      protected function parse($usage)
176      {
177          $tag    = new Tag;
178          $bbcode = new BBCode;
179   
180          // This is the config we will return
181          $config = [
182              'tag'              => $tag,
183              'bbcode'           => $bbcode,
184              'passthroughToken' => null
185          ];
186   
187          // Encode maps to avoid special characters to interfere with definitions
188          $usage = preg_replace_callback(
189              '#(\\{(?>HASH)?MAP=)([^:]+:[^,;}]+(?>,[^:]+:[^,;}]+)*)(?=[;}])#',
190              function ($m)
191              {
192                  return $m[1] . base64_encode($m[2]);
193              },
194              $usage
195          );
196   
197          // Encode regexps to avoid special characters to interfere with definitions
198          $usage = preg_replace_callback(
199              '#(\\{(?:PARSE|REGEXP)=)(' . self::REGEXP . '(?:,' . self::REGEXP . ')*)#',
200              function ($m)
201              {
202                  return $m[1] . base64_encode($m[2]);
203              },
204              $usage
205          );
206   
207          $regexp = '(^'
208                  // [BBCODE
209                  . '\\[(?<bbcodeName>\\S+?)'
210                  // ={TOKEN}
211                  . '(?<defaultAttribute>=.+?)?'
212                  // foo={TOKEN} bar={TOKEN1},{TOKEN2}
213                  . '(?<attributes>(?:\\s+[^=]+=\\S+?)*?)?'
214                  // ] or /] or ]{TOKEN}[/BBCODE]
215                  . '\\s*(?:/?\\]|\\]\\s*(?<content>.*?)\\s*(?<endTag>\\[/\\1]))'
216                  . '$)i';
217   
218          if (!preg_match($regexp, trim($usage), $m))
219          {
220              throw new InvalidArgumentException('Cannot interpret the BBCode definition');
221          }
222   
223          // Save the BBCode's name
224          $config['bbcodeName'] = BBCode::normalizeName($m['bbcodeName']);
225   
226          // Prepare the attributes definition, e.g. "foo={BAR}"
227          $definitions = preg_split('#\\s+#', trim($m['attributes']), -1, PREG_SPLIT_NO_EMPTY);
228   
229          // If there's a default attribute, we prepend it to the list using the BBCode's name as
230          // attribute name
231          if (!empty($m['defaultAttribute']))
232          {
233              array_unshift($definitions, $m['bbcodeName'] . $m['defaultAttribute']);
234          }
235   
236          // Append the content token to the attributes list under the name "content" if it's anything
237          // but raw {TEXT} (or other unfiltered tokens)
238          if (!empty($m['content']))
239          {
240              $regexp = '#^\\{' . RegexpBuilder::fromList($this->unfilteredTokens) . '[0-9]*\\}$#D';
241   
242              if (preg_match($regexp, $m['content']))
243              {
244                  $config['passthroughToken'] = substr($m['content'], 1, -1);
245              }
246              else
247              {
248                  $definitions[] = 'content=' . $m['content'];
249                  $bbcode->contentAttributes[] = 'content';
250              }
251          }
252   
253          // Separate the attribute definitions from the BBCode options
254          $attributeDefinitions = [];
255          foreach ($definitions as $definition)
256          {
257              $pos   = strpos($definition, '=');
258              $name  = substr($definition, 0, $pos);
259              $value = preg_replace('(^"(.*?)")s', '$1', substr($definition, 1 + $pos));
260   
261              // Decode base64-encoded tokens
262              $value = preg_replace_callback(
263                  '#(\\{(?>HASHMAP|MAP|PARSE|REGEXP)=)([A-Za-z0-9+/]+=*)#',
264                  function ($m)
265                  {
266                      return $m[1] . base64_decode($m[2]);
267                  },
268                  $value
269              );
270   
271              // If name starts with $ then it's a BBCode/tag option. If it starts with # it's a rule.
272              // Otherwise, it's an attribute definition
273              if ($name[0] === '$')
274              {
275                  $optionName = substr($name, 1);
276   
277                  // Allow nestingLimit and tagLimit to be set on the tag itself. We don't necessarily
278                  // want every other tag property to be modifiable this way, though
279                  $object = ($optionName === 'nestingLimit' || $optionName === 'tagLimit') ? $tag : $bbcode;
280   
281                  $object->$optionName = $this->convertValue($value);
282              }
283              elseif ($name[0] === '#')
284              {
285                  $ruleName = substr($name, 1);
286   
287                  // Supports #denyChild=foo,bar
288                  foreach (explode(',', $value) as $value)
289                  {
290                      $tag->rules->$ruleName($this->convertValue($value));
291                  }
292              }
293              else
294              {
295                  $attrName = strtolower(trim($name));
296                  $attributeDefinitions[] = [$attrName, $value];
297              }
298          }
299   
300          // Add the attributes and get the token translation table
301          $tokens = $this->addAttributes($attributeDefinitions, $bbcode, $tag);
302   
303          // Test whether the passthrough token is used for something else, in which case we need
304          // to unset it
305          if (isset($tokens[$config['passthroughToken']]))
306          {
307              $config['passthroughToken'] = null;
308          }
309   
310          // Add the list of known (and only the known) tokens to the config
311          $config['tokens'] = array_filter($tokens);
312   
313          return $config;
314      }
315   
316      /**
317      * Parse a string of attribute definitions and add the attributes/options to the tag/BBCode
318      *
319      * Attributes come in two forms. Most commonly, in the form of a single token, e.g.
320      *   [a href={URL} title={TEXT}]
321      *
322      * Sometimes, however, we need to parse more than one single token. For instance, the phpBB
323      * [FLASH] BBCode uses two tokens separated by a comma:
324      *   [flash={NUMBER},{NUMBER}]{URL}[/flash]
325      *
326      * In addition, some custom BBCodes circulating for phpBB use a combination of token and static
327      * text such as:
328      *   [youtube]http://www.youtube.com/watch?v={SIMPLETEXT}[/youtube]
329      *
330      * Any attribute that is not a single token is implemented as an attribute preprocessor, with
331      * each token generating a matching attribute. Tentatively, those  of those attributes are
332      * created by taking the attribute preprocessor's name and appending a unique number counting the
333      * number of created attributes. In the [FLASH] example above, an attribute preprocessor named
334      * "flash" would be created as well as two attributes named "flash0" and "flash1" respectively.
335      *
336      * @link https://www.phpbb.com/community/viewtopic.php?f=46&t=2127991
337      * @link https://www.phpbb.com/community/viewtopic.php?f=46&t=579376
338      *
339      * @param  array  $definitions List of attributes definitions as [[name, definition]*]
340      * @param  BBCode $bbcode      Owner BBCode
341      * @param  Tag    $tag         Owner tag
342      * @return array               Array of [token id => attribute name] where FALSE in place of the
343      *                             name indicates that the token is ambiguous (e.g. used multiple
344      *                             times)
345      */
346      protected function addAttributes(array $definitions, BBCode $bbcode, Tag $tag)
347      {
348          /**
349          * @var array List of composites' tokens. Each element is composed of an attribute name, the
350          *            composite's definition and an array of tokens
351          */
352          $composites = [];
353   
354          /**
355          * @var array Map of [tokenId => attrName]. If the same token is used in multiple attributes
356          *            it is set to FALSE
357          */
358          $table = [];
359   
360          foreach ($definitions as list($attrName, $definition))
361          {
362              // The first attribute defined is set as default
363              if (!isset($bbcode->defaultAttribute))
364              {
365                  $bbcode->defaultAttribute = $attrName;
366              }
367   
368              // Parse the tokens in that definition
369              $tokens = $this->parseTokens($definition);
370   
371              if (empty($tokens))
372              {
373                  throw new RuntimeException('No valid tokens found in ' . $attrName . "'s definition " . $definition);
374              }
375   
376              // Test whether this attribute has one single all-encompassing token
377              if ($tokens[0]['content'] === $definition)
378              {
379                  $token = $tokens[0];
380   
381                  if ($token['type'] === 'PARSE')
382                  {
383                      foreach ($token['regexps'] as $regexp)
384                      {
385                          $tag->attributePreprocessors->add($attrName, $regexp);
386                      }
387                  }
388                  elseif (isset($tag->attributes[$attrName]))
389                  {
390                      throw new RuntimeException("Attribute '" . $attrName . "' is declared twice");
391                  }
392                  else
393                  {
394                      // Remove the "useContent" option and add the attribute's name to the list of
395                      // attributes to use this BBCode's content
396                      if (!empty($token['options']['useContent']))
397                      {
398                          $bbcode->contentAttributes[] = $attrName;
399                      }
400                      unset($token['options']['useContent']);
401   
402                      // Add the attribute
403                      $tag->attributes[$attrName] = $this->generateAttribute($token);
404   
405                      // Record the token ID if applicable
406                      $tokenId = $token['id'];
407                      $table[$tokenId] = (isset($table[$tokenId]))
408                                       ? false
409                                       : $attrName;
410                  }
411              }
412              else
413              {
414                  $composites[] = [$attrName, $definition, $tokens];
415              }
416          }
417   
418          foreach ($composites as list($attrName, $definition, $tokens))
419          {
420              $regexp  = '/^';
421              $lastPos = 0;
422   
423              $usedTokens = [];
424   
425              foreach ($tokens as $token)
426              {
427                  $tokenId   = $token['id'];
428                  $tokenType = $token['type'];
429   
430                  if ($tokenType === 'PARSE')
431                  {
432                      // Disallow {PARSE} tokens because attribute preprocessors cannot feed into
433                      // other attribute preprocessors
434                      throw new RuntimeException('{PARSE} tokens can only be used has the sole content of an attribute');
435                  }
436   
437                  // Ensure that tokens are only used once per definition so we don't have multiple
438                  // subpatterns using the same name
439                  if (isset($usedTokens[$tokenId]))
440                  {
441                      throw new RuntimeException('Token {' . $tokenId . '} used multiple times in attribute ' . $attrName . "'s definition");
442                  }
443                  $usedTokens[$tokenId] = 1;
444   
445                  // Find the attribute name associated with this token, or create an attribute
446                  // otherwise
447                  if (isset($table[$tokenId]))
448                  {
449                      $matchName = $table[$tokenId];
450   
451                      if ($matchName === false)
452                      {
453                          throw new RuntimeException('Token {' . $tokenId . "} used in attribute '" . $attrName . "' is ambiguous");
454                      }
455                  }
456                  else
457                  {
458                      // The name of the named subpattern and the corresponding attribute is based on
459                      // the attribute preprocessor's name, with an incremented ID that ensures we
460                      // don't overwrite existing attributes
461                      $i = 0;
462                      do
463                      {
464                          $matchName = $attrName . $i;
465                          ++$i;
466                      }
467                      while (isset($tag->attributes[$matchName]));
468   
469                      // Create the attribute that corresponds to this subpattern
470                      $attribute = $tag->attributes->add($matchName);
471   
472                      // Append the corresponding filter if applicable
473                      if (!in_array($tokenType, $this->unfilteredTokens, true))
474                      {
475                          $filter = $this->configurator->attributeFilters->get('#' . strtolower($tokenType));
476                          $attribute->filterChain->append($filter);
477                      }
478   
479                      // Record the attribute name associated with this token ID
480                      $table[$tokenId] = $matchName;
481                  }
482   
483                  // Append the literal text between the last position and current position.
484                  // Replace whitespace with a flexible whitespace pattern
485                  $literal = preg_quote(substr($definition, $lastPos, $token['pos'] - $lastPos), '/');
486                  $literal = preg_replace('(\\s+)', '\\s+', $literal);
487                  $regexp .= $literal;
488   
489                  // Grab the expression that corresponds to the token type, or use a catch-all
490                  // expression otherwise
491                  $expr = (isset($this->tokenRegexp[$tokenType]))
492                        ? $this->tokenRegexp[$tokenType]
493                        : '.+?';
494   
495                  // Append the named subpattern. Its name is made of the attribute preprocessor's
496                  // name and the subpattern's position
497                  $regexp .= '(?<' . $matchName . '>' . $expr . ')';
498   
499                  // Update the last position
500                  $lastPos = $token['pos'] + strlen($token['content']);
501              }
502   
503              // Append the literal text that follows the last token and finish the regexp
504              $regexp .= preg_quote(substr($definition, $lastPos), '/') . '$/D';
505   
506              // Add the attribute preprocessor to the config
507              $tag->attributePreprocessors->add($attrName, $regexp);
508          }
509   
510          // Now create attributes generated from attribute preprocessors. For instance, preprocessor
511          // #(?<width>\\d+),(?<height>\\d+)# will generate two attributes named "width" and height
512          // with a regexp filter "#^(?:\\d+)$#D", unless they were explicitly defined otherwise
513          $newAttributes = [];
514          foreach ($tag->attributePreprocessors as $attributePreprocessor)
515          {
516              foreach ($attributePreprocessor->getAttributes() as $attrName => $regexp)
517              {
518                  if (isset($tag->attributes[$attrName]))
519                  {
520                      // This attribute was already explicitly defined, nothing else to add
521                      continue;
522                  }
523   
524                  if (isset($newAttributes[$attrName])
525                   && $newAttributes[$attrName] !== $regexp)
526                  {
527                      throw new RuntimeException("Ambiguous attribute '" . $attrName . "' created using different regexps needs to be explicitly defined");
528                  }
529   
530                  $newAttributes[$attrName] = $regexp;
531              }
532          }
533   
534          foreach ($newAttributes as $attrName => $regexp)
535          {
536              $filter = $this->configurator->attributeFilters->get('#regexp');
537   
538              // Create the attribute using this regexp as filter
539              $tag->attributes->add($attrName)->filterChain->append($filter)->setRegexp($regexp);
540          }
541   
542          return $table;
543      }
544   
545      /**
546      * Convert a human-readable value to a typed PHP value
547      *
548      * @param  string      $value Original value
549      * @return bool|string        Converted value
550      */
551      protected function convertValue($value)
552      {
553          if ($value === 'true')
554          {
555              return true;
556          }
557   
558          if ($value === 'false')
559          {
560              return false;
561          }
562   
563          return $value;
564      }
565   
566      /**
567      * Parse and return all the tokens contained in a definition
568      *
569      * @param  string $definition
570      * @return array
571      */
572      protected function parseTokens($definition)
573      {
574          $tokenTypes = [
575              'choice' => 'CHOICE[0-9]*=(?<choices>.+?)',
576              'map'    => '(?:HASH)?MAP[0-9]*=(?<map>.+?)',
577              'parse'  => 'PARSE=(?<regexps>' . self::REGEXP . '(?:,' . self::REGEXP . ')*)',
578              'range'  => 'RANGE[0-9]*=(?<min>-?[0-9]+),(?<max>-?[0-9]+)',
579              'regexp' => 'REGEXP[0-9]*=(?<regexp>' . self::REGEXP . ')',
580              'other'  => '(?<other>[A-Z_]+[0-9]*)'
581          ];
582   
583          // Capture the content of every token in that attribute's definition. Usually there will
584          // only be one, as in "foo={URL}" but some older BBCodes use a form of composite
585          // attributes such as [FLASH={NUMBER},{NUMBER}]
586          preg_match_all(
587              '#\\{(' . implode('|', $tokenTypes) . ')(?<options>\\??(?:;[^;]*)*)\\}#',
588              $definition,
589              $matches,
590              PREG_SET_ORDER | PREG_OFFSET_CAPTURE
591          );
592   
593          $tokens = [];
594          foreach ($matches as $m)
595          {
596              if (isset($m['other'][0])
597               && preg_match('#^(?:CHOICE|HASHMAP|MAP|REGEXP|PARSE|RANGE)#', $m['other'][0]))
598              {
599                  throw new RuntimeException("Malformed token '" . $m['other'][0] . "'");
600              }
601   
602              $token = [
603                  'pos'     => $m[0][1],
604                  'content' => $m[0][0],
605                  'options' => (isset($m['options'][0])) ? $this->parseOptionString($m['options'][0]) : []
606              ];
607   
608              // Get this token's type by looking at the start of the match
609              $head = $m[1][0];
610              $pos  = strpos($head, '=');
611   
612              if ($pos === false)
613              {
614                  // {FOO}
615                  $token['id'] = $head;
616              }
617              else
618              {
619                  // {FOO=...}
620                  $token['id'] = substr($head, 0, $pos);
621   
622                  // Copy the content of named subpatterns into the token's config
623                  foreach ($m as $k => $v)
624                  {
625                      if (!is_numeric($k) && $k !== 'options' && $v[1] !== -1)
626                      {
627                          $token[$k] = $v[0];
628                      }
629                  }
630              }
631   
632              // The token's type is its id minus the number, e.g. NUMBER1 => NUMBER
633              $token['type'] = rtrim($token['id'], '0123456789');
634   
635              // {PARSE} tokens can have several regexps separated with commas, we split them up here
636              if ($token['type'] === 'PARSE')
637              {
638                  // Match all occurences of a would-be regexp followed by a comma or the end of the
639                  // string
640                  preg_match_all('#' . self::REGEXP . '(?:,|$)#', $token['regexps'], $m);
641   
642                  $regexps = [];
643                  foreach ($m[0] as $regexp)
644                  {
645                      // remove the potential comma at the end
646                      $regexps[] = rtrim($regexp, ',');
647                  }
648   
649                  $token['regexps'] = $regexps;
650              }
651   
652              $tokens[] = $token;
653          }
654   
655          return $tokens;
656      }
657   
658      /**
659      * Generate an attribute based on a token
660      *
661      * @param  array     $token Token this attribute is based on
662      * @return Attribute
663      */
664      protected function generateAttribute(array $token)
665      {
666          $attribute = new Attribute;
667   
668          if (isset($token['options']['preFilter']))
669          {
670              $this->appendFilters($attribute, $token['options']['preFilter']);
671              unset($token['options']['preFilter']);
672          }
673   
674          if ($token['type'] === 'REGEXP')
675          {
676              $filter = $this->configurator->attributeFilters->get('#regexp');
677              $attribute->filterChain->append($filter)->setRegexp($token['regexp']);
678          }
679          elseif ($token['type'] === 'RANGE')
680          {
681              $filter = $this->configurator->attributeFilters->get('#range');
682              $attribute->filterChain->append($filter)->setRange($token['min'], $token['max']);
683          }
684          elseif ($token['type'] === 'CHOICE')
685          {
686              $filter = $this->configurator->attributeFilters->get('#choice');
687              $attribute->filterChain->append($filter)->setValues(
688                  explode(',', $token['choices']),
689                  !empty($token['options']['caseSensitive'])
690              );
691              unset($token['options']['caseSensitive']);
692          }
693          elseif ($token['type'] === 'HASHMAP' || $token['type'] === 'MAP')
694          {
695              // Build the map from the string
696              $map = [];
697              foreach (explode(',', $token['map']) as $pair)
698              {
699                  $pos = strpos($pair, ':');
700   
701                  if ($pos === false)
702                  {
703                      throw new RuntimeException("Invalid map assignment '" . $pair . "'");
704                  }
705   
706                  $map[substr($pair, 0, $pos)] = substr($pair, 1 + $pos);
707              }
708   
709              // Create the filter then append it to the attribute
710              if ($token['type'] === 'HASHMAP')
711              {
712                  $filter = $this->configurator->attributeFilters->get('#hashmap');
713                  $attribute->filterChain->append($filter)->setMap(
714                      $map,
715                      !empty($token['options']['strict'])
716                  );
717              }
718              else
719              {
720                  $filter = $this->configurator->attributeFilters->get('#map');
721                  $attribute->filterChain->append($filter)->setMap(
722                      $map,
723                      !empty($token['options']['caseSensitive']),
724                      !empty($token['options']['strict'])
725                  );
726              }
727   
728              // Remove options that are not needed anymore
729              unset($token['options']['caseSensitive']);
730              unset($token['options']['strict']);
731          }
732          elseif (!in_array($token['type'], $this->unfilteredTokens, true))
733          {
734              $filter = $this->configurator->attributeFilters->get('#' . $token['type']);
735              $attribute->filterChain->append($filter);
736          }
737   
738          if (isset($token['options']['postFilter']))
739          {
740              $this->appendFilters($attribute, $token['options']['postFilter']);
741              unset($token['options']['postFilter']);
742          }
743   
744          // Set the "required" option if "required" or "optional" is set, then remove
745          // the "optional" option
746          if (isset($token['options']['required']))
747          {
748              $token['options']['required'] = (bool) $token['options']['required'];
749          }
750          elseif (isset($token['options']['optional']))
751          {
752              $token['options']['required'] = !$token['options']['optional'];
753          }
754          unset($token['options']['optional']);
755   
756          foreach ($token['options'] as $k => $v)
757          {
758              $attribute->$k = $v;
759          }
760   
761          return $attribute;
762      }
763   
764      /**
765      * Append a list of filters to an attribute's filterChain
766      *
767      * @param  Attribute $attribute
768      * @param  string    $filters   List of filters, separated with commas
769      * @return void
770      */
771      protected function appendFilters(Attribute $attribute, $filters)
772      {
773          foreach (preg_split('#\\s*,\\s*#', $filters) as $filterName)
774          {
775              if (substr($filterName, 0, 1) !== '#'
776               && !in_array($filterName, $this->allowedFilters, true))
777              {
778                  throw new RuntimeException("Filter '" . $filterName . "' is not allowed in BBCodes");
779              }
780   
781              $filter = $this->configurator->attributeFilters->get($filterName);
782              $attribute->filterChain->append($filter);
783          }
784      }
785   
786      /**
787      * Test whether a token's name is the name of a filter
788      *
789      * @param  string $tokenId Token ID, e.g. "TEXT1"
790      * @return bool
791      */
792      protected function isFilter($tokenId)
793      {
794          $filterName = rtrim($tokenId, '0123456789');
795   
796          if (in_array($filterName, $this->unfilteredTokens, true))
797          {
798              return true;
799          }
800   
801          // Try to load the filter
802          try
803          {
804              if ($this->configurator->attributeFilters->get('#' . $filterName))
805              {
806                  return true;
807              }
808          }
809          catch (Exception $e)
810          {
811              // Nothing to do here
812          }
813   
814          return false;
815      }
816   
817      /**
818      * Parse the option string into an associative array
819      *
820      * @param  string $string Serialized options
821      * @return array          Associative array of options
822      */
823      protected function parseOptionString($string)
824      {
825          // Use the first "?" as an alias for the "optional" option
826          $string = preg_replace('(^\\?)', ';optional', $string);
827   
828          $options = [];
829          foreach (preg_split('#;+#', $string, -1, PREG_SPLIT_NO_EMPTY) as $pair)
830          {
831              $pos = strpos($pair, '=');
832              if ($pos === false)
833              {
834                  // Options with no value are set to true, e.g. {FOO;useContent}
835                  $k = $pair;
836                  $v = true;
837              }
838              else
839              {
840                  $k = substr($pair, 0, $pos);
841                  $v = substr($pair, 1 + $pos);
842              }
843   
844              $options[$k] = $v;
845          }
846   
847          return $options;
848      }
849  }