Verzeichnisstruktur phpBB-3.3.16


Veröffentlicht
27.04.2026

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: 01.05.2026, 11:28 - 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  }