_bbCodes['[root]'] = $this->_addCommonOptions(array(), array( 'group' => 'root' )); if ($options instanceof Zend_Config) { $this->setConfig($options); } elseif (is_array($options)) { $this->setOptions($options); } elseif ($options !== null) { throw new App_BBCode_InvalidArgumentException('Options must be an instance of Zend_Config or an array'); } $this->init(); } /** * Set options of the parser via a Zend_Config object * * @param Zend_Config $config * @return App_BBCode_Parser */ public function setConfig(Zend_Config $config) { $this->setOptions($config->toArray()); return $this; } /** * Set options of the parser via an array * * @param array $options * @return App_BBCode_Parser */ public function setOptions(array $options) { if (isset($options['prefixPath'])) { $this->addPrefixPaths($options['prefixPath']); unset($options['prefixPath']); } if (isset($options['pluginLoader'])) { $this->setPluginLoader($options['pluginLoader']); unset($options['pluginLoader']); } foreach ($options as $key => $value) { if (in_array($key, $this->_skipOptions)) { continue; } $methodName = 'set' . ucfirst($key); if (method_exists($this, $methodName)) { $this->{$methodName}($value); } } return $this; } /** * Override this method to set options programatically * * @return void */ public function init() {} /** * Set plugin loaders for use with decorators * * @param Zend_Loader_PluginLoader_Interface $loader * @return Zend_Tag_Cloud */ public function setPluginLoader(Zend_Loader_PluginLoader_Interface $loader) { $this->_pluginLoader = $loader; return $this; } /** * Get the plugin loader for decorators * * @return Zend_Loader_PluginLoader */ public function getPluginLoader() { if ($this->_pluginLoader === null) { $prefix = 'App_BBCode_Callback_'; $pathPrefix = 'App/BBCode/Callback/'; $this->_pluginLoader = new Zend_Loader_PluginLoader(array($prefix => $pathPrefix)); } return $this->_pluginLoader; } /** * Add many prefix paths at once * * @param array $paths * @return App_BBCode_Parser */ public function addPrefixPaths(array $paths) { if (isset($paths['prefix']) && isset($paths['path'])) { return $this->addPrefixPath($paths['prefix'], $paths['path']); } foreach ($paths as $path) { if (!isset($path['prefix']) || !isset($path['path'])) { continue; } $this->addPrefixPath($path['prefix'], $path['path']); } return $this; } /** * Add prefix path for plugin loader * * @param string $prefix * @param string $path * @return App_BBCode_Parser */ public function addPrefixPath($prefix, $path) { $loader = $this->getPluginLoader(); $loader->addPrefixPath($prefix, $path); return $this; } /** * Set a filter to evaluate pre parsing * * @param Zend_Filter_Interface $filter * @return App_BBCode_Parser */ public function setPreFilter(Zend_Filter_Interface $filter = null) { $this->_preFilter = $filter; return $this; } /** * Set a filter to evaluate post parsing * * @param Zend_Filter_Interface $filter * @return App_BBCode_Parser */ public function setPostFilter(Zend_Filter_Interface $filter = null) { $this->_postFilter = $filter; return $this; } /** * Set options of the root elements * * @param array $options * @return App_BBCode_Parser */ public function setRootOptions(array $options) { $this->_bbCodes['[root]'] = $this->_addCommonOptions($this->_bbCodes['[root]'], $options); return $this; } /** * Set the maximum nesting level of BBCodes * * @param integer $level * @return App_BBCode_Parser */ public function setMaxNestingLevel($level) { $this->_maxNestingLevel = max(1, (int) $level); return $this; } /** * Set multiple BB-codes at once * * @param array $codes * @throws App_BBCode_InvalidArgumentException When a code is no array * @throws App_BBCode_BadMethodCallException When type key is missing * @throws App_BBCode_BadMethodCallException When start key is missing * @throws App_BBCode_BadMethodCallException When callback key is missing * @throws App_BBCode_UnexpectedValueException When type is not correct * @return App_BBCode_Parser */ public function setBBCodes(array $codes) { foreach ($codes as $name => $code) { if (!is_array($code)) { throw new App_BBCode_InvalidArgumentException('Code must be defined as array'); } elseif (!isset($code['type'])) { throw new App_BBCode_BadMethodCallException('Missing "type" key'); } if (!isset($code['options'])) { $code['options'] = array(); } switch ($code['type']) { case 'simple': if (!isset($code['start'])) { throw new App_BBCode_BadMethodCallException('Missing "start" key'); } if (!isset($code['end'])) { $code['end'] = null; } $this->addSimpleBBCode($name, $code['start'], $code['end'], $code['options']); break; case 'callback': if (!isset($code['callback'])) { throw new App_BBCode_BadMethodCallException('Missing "callback" key'); } $this->addCallbackBBCode($name, $code['callback'], $code['options']); break; default: throw new App_BBCode_UnexpectedValueException('Type must be "simple" or "callback"'); } } return $this; } /** * Add a simple replace BB-code to the list * * @param string $name * @param string $start * @param string $end * @param array $options * @return App_BBCode_Parser */ public function addSimpleBBCode($name, $start, $end = null, array $options = array()) { $this->_validateCodeName($name); $this->_bbCodes[strtolower($name)] = $this->_addCommonOptions(array( 'type' => 'simple', 'start' => $start, 'end' => $end, ), $options); return $this; } /** * Add a callback replace BB-code to the list * * @param string $name * @param mixed $callback * @param array $options * @throws App_BBCode_UnexpectedValueException When callback does not implement App_BBCode_Callback_CallbackInterface * @return App_BBCode_Parser */ public function addCallbackBBCode($name, $callback, array $options = array()) { $this->_validateCodeName($name); $callbackOptions = null; if (is_array($callback)) { if (isset($callback['options'])) { $callbackOptions = $callback['options']; } if (isset($callback['callback'])) { $callback = $callback['callback']; } } if (is_string($callback)) { $classname = $this->getPluginLoader()->load($callback); $callback = new $classname($callbackOptions); } if (!($callback instanceof App_BBCode_Callback_CallbackInterface)) { throw new App_BBCode_InvalidArgumentException('Callback must implement App_BBCode_Callback_CallbackInterface'); } $this->_bbCodes[strtolower($name)] = $this->_addCommonOptions(array( 'type' => 'callback', 'callback' => $callback ), $options); return $this; } /** * Parse a text containing BB-codes * * @param string $text * @return string */ public function parse($text) { if ($this->_preFilter !== null) { $text = $this->_preFilter->filter($text); } $tree = $this->_createTree($text); $result = $this->_translateBBCodeBranch($tree); if ($this->_postFilter !== null) { $result = $this->_postFilter->filter($result); } return $result; } /** * Validate a code name * * @param string $name * @throws App_BBCode_UnexpectedValueException When the name is invalid * @return void */ protected function _validateCodeName($name) { if (!preg_match('#^[' . $this->_validTagChars . ']+$#', $name)) { throw new App_BBCode_UnexpectedValueException('Name may only contain alpha-numeric characters and !, §, $, %, &, =, ?, *, +, ~, #, -, _, ., @'); } } /** * Add common options to a given BBCode * * @param array $options * @param array $extras * @throws App_BBCode_UnexpectedValueException When occursIn parameter is invalid * @throws App_BBCode_UnexpectedValueException When filter does not implement Zend_Filter_Interface * @return void */ protected function _addCommonOptions(array $options, array $extras = array()) { $group = 'default'; $occursIn = null; $implicitClose = false; $filter = null; $skipParsing = false; $allowPlaintext = true; $whitespaceHandling = self::WHITESPACE_REMOVE_NONE; extract($extras, EXTR_IF_EXISTS); if ($occursIn !== null && !is_array($occursIn)) { if (is_string($occursIn)) { $occursIn = array($occursIn); } else { throw new App_BBCode_UnexpectedValueException('OccursIn must either be null, an array or a string'); } } if ($filter !== null && !($filter instanceof Zend_Filter_Interface)) { throw new App_BBCode_UnexpectedValueException('Filter must implement Zend_Filter_Interface'); } $options = array_merge($options, array( 'group' => $group, 'occursIn' => $occursIn, 'implicitClose' => (bool) $implicitClose, 'filter' => $filter, 'skipParsing' => (bool) $skipParsing, 'allowPlaintext' => (bool) $allowPlaintext, 'whitespaceHandling' => (int) $whitespaceHandling )); return $options; } /** * Translate a BB-code branch * * We must use recursion at this point, as it is not possible to handle * the translated contents within a stack. * * @param array $branch * @param string $overrideGroup * @return string */ protected function _translateBBCodeBranch(array $branch, $level = 0, $overrideGroup = null) { if ($level >= $this->_maxNestingLevel) { return ''; } $result = ''; $group = ($overrideGroup === null ? $this->_bbCodes[$branch['name']]['group'] : $overrideGroup); $whitespaceHandling = $this->_bbCodes[$branch['name']]['whitespaceHandling']; $lastKey = count($branch['contents']) - 1; foreach ($branch['contents'] as $key => $content) { if (is_string($content)) { if ($key === 0 && $whitespaceHandling & self::WHITESPACE_REMOVE_AFTER_OPENING) { $content = ltrim($content); } if ($key === $lastKey && $whitespaceHandling & self::WHITESPACE_REMOVE_BEFORE_CLOSING) { $content = rtrim($content); } if (isset($branch['contents'][($key - 1)]) && is_array($branch['contents'][($key - 1)])) { $settings = $this->_bbCodes[$branch['contents'][($key - 1)]['name']]; if ($settings['whitespaceHandling'] & self::WHITESPACE_REMOVE_AFTER_CLOSING) { $content = ltrim($content); } } if (isset($branch['contents'][($key + 1)]) && is_array($branch['contents'][($key + 1)])) { $settings = $this->_bbCodes[$branch['contents'][($key + 1)]['name']]; if ($settings['whitespaceHandling'] & self::WHITESPACE_REMOVE_BEFORE_OPENING) { $content = rtrim($content); } } if ($this->_bbCodes[$branch['name']]['filter'] !== null) { $content = $this->_bbCodes[$branch['name']]['filter']->filter($content); } $result .= $content; } else { $trimNextPlaintext = false; $occursIn = $this->_bbCodes[$content['name']]['occursIn']; $settings = $this->_bbCodes[$content['name']]; if ($occursIn !== null && !in_array($group, $occursIn)) { $result .= $this->_translateBBCodeBranch($content, ($level + 1), $group); continue; } else { $translatedContent = $this->_translateBBCodeBranch($content, ($level + 1)); } switch ($settings['type']) { case 'simple': $result .= $settings['start'] . $translatedContent . $settings['end']; break; case 'callback': $result .= $settings['callback']->handleCode($content['name'], $content['params'], $translatedContent); break; } } } return $result; } /** * Create a structured tree * * Do not even think about separating this method into multiple methods. The * reason for no splitting is performance-wise, as every state is evaluated * multiple hundred times in a huge string, which would not only raise the * execution time but also the memory consumption. * * @param string $text * @return array */ protected function _createTree($text) { $structure = array('name' => '[root]', 'contents' => array()); $this->_stack = array(&$structure); $this->_pointer = &$this->_stack[0]; $currentPos = 0; $textLength = strlen($text); $state = self::STATE_SCAN; $temp = ''; $tag = null; $tagHasContent = null; $paramName = null; $paramDelimiter = null; $skipParsing = false; while ($textLength > $currentPos) { switch ($state) { // Scan for opening/closing tags case self::STATE_SCAN: if (!preg_match('#\G(?[^\[]*)(?<opening>\[(?<closing>/)?)?#S', $text, $matches, null, $currentPos)) { // End of string, should usually NEVER happen // @codeCoverageIgnoreStart break(2); // @codeCoverageIgnoreEnd } if (!empty($matches['plaintext'])) { // Append plain-text if allowed $this->_addPlaintextToStack($matches['plaintext']); } if (isset($matches['opening'])) { if (!isset($matches['closing']) && $skipParsing) { $this->_addPlaintextToStack($matches['opening']); break; } else { // Assert opening/closing tag $state = (isset($matches['closing']) ? self::STATE_CLOSE_TAG : self::STATE_OPEN_TAG); $temp = $matches['opening']; } } break; // Parse opening tag case self::STATE_OPEN_TAG: if (!preg_match('#\G(?<name>[' . $this->_validTagChars . ']+)(?<token>(?:=(?<delimiter>[\'"])?|[ ]*(?<end>\])?))#S', $text, $matches, null, $currentPos)) { // Invalid open tag, ignore it $this->_addPlaintextToStack($temp); $state = self::STATE_SCAN; break; } $matches['name'] = strtolower($matches['name']); if (!isset($this->_bbCodes[$matches['name']])) { // Non-existent bb-code, just treat it as plain-text $this->_addPlaintextToStack($temp . $matches[0]); $state = self::STATE_SCAN; break; } if ($this->_bbCodes[$matches['name']]['implicitClose']) { // Check for implicity closed tag $this->_closeTag($matches['name']); } if (array_key_exists('end', $this->_bbCodes[$matches['name']]) && $this->_bbCodes[$matches['name']]['end'] === null) { // Tag does not have content $tagHasContent = false; $skipParsing = false; } else { $tagHasContent = true; $skipParsing = $this->_bbCodes[$matches['name']]['skipParsing']; } $tag = array('name' => $matches['name'], 'params' => array(), 'contents' => array()); if (isset($matches['end'])) { $this->_pointer['contents'][] = $tag; if ($tagHasContent) { end($this->_pointer['contents']); $this->_stack[] = &$this->_pointer['contents'][key($this->_pointer['contents'])]; end($this->_stack); $this->_pointer = &$this->_stack[key($this->_stack)]; } $state = self::STATE_SCAN; } elseif ($matches['token'] === '=') { $paramDelimiter = (isset($matches['delimiter']) ? $matches['delimiter'] : null); $paramName = 'default'; $state = self::STATE_PARAM_VALUE; } else { $state = self::STATE_PARAM_NAME; } $temp .= $matches[0]; break; // Get parameter name case self::STATE_PARAM_NAME: if (!preg_match('#\G(?<name>[A-Za-z\-]+)=(?<delimiter>["\'])?#S', $text, $matches, null, $currentPos)) { // Invalid tag parameter name $this->_addPlaintextToStack($temp); $state = self::STATE_SCAN; break; } $paramName = strtolower($matches['name']); $paramDelimiter = (isset($matches['delimiter']) ? $matches['delimiter'] : null); $state = self::STATE_PARAM_VALUE; $temp .= $matches[0]; break; // Get parameter value case self::STATE_PARAM_VALUE: if ($paramDelimiter === null) { $regex = '(?<value>(?:\\\\\\\\|\\\\[ \]]|[^ \]])*)[ ]*(?<end>\])?'; } else { $regex = '(?<value>(?:\\\\\\\\|\\\\' . $paramDelimiter . '|[^' . $paramDelimiter . '])*)' . $paramDelimiter . '[ ]*(?<end>\])?'; } if (!preg_match('#\G' . $regex . '#S', $text, $matches, null, $currentPos)) { // Invalid tag parameter value, should NEVER happen // @codeCoverageIgnoreStart $this->_addPlaintextToStack($temp); $state = self::STATE_SCAN; break; // @codeCoverageIgnoreEnd } $tag['params'][$paramName] = stripslashes($matches['value']); if (isset($matches['end'])) { $this->_pointer['contents'][] = $tag; if ($tagHasContent) { end($this->_pointer['contents']); $this->_stack[] = &$this->_pointer['contents'][key($this->_pointer['contents'])]; end($this->_stack); $this->_pointer = &$this->_stack[key($this->_stack)]; } $state = self::STATE_SCAN; } else { $state = self::STATE_PARAM_NAME; $temp .= $matches[0]; } break; // Parse closing tag case self::STATE_CLOSE_TAG: if (!preg_match('#\G(?<name>[' . $this->_validTagChars . ']+)\]#S', $text, $matches, null, $currentPos)) { // Invalid close tag, ignore it $this->_addPlaintextToStack($temp); $state = self::STATE_SCAN; break; } $matches['name'] = strtolower($matches['name']); if ($skipParsing && $matches['name'] !== $this->_pointer['name']) { $this->_addPlaintextToStack($temp . $matches[0]); $state = self::STATE_SCAN; break; } if (!isset($this->_bbCodes[$matches['name']])) { // Non-existent bb-code, just treat it as plain-text $this->_addPlaintextToStack($temp . $matches[0]); $state = self::STATE_SCAN; break; } if (array_key_exists('end', $this->_bbCodes[$matches['name']]) && $this->_bbCodes[$matches['name']]['end'] === null) { $state = self::STATE_SCAN; break; } $this->_closeTag($matches['name']); $skipParsing = false; $state = self::STATE_SCAN; break; } if (isset($matches[0])) { $currentPos += strlen($matches[0]); } if ($state === self::STATE_SCAN) { $temp = ''; } } if (!empty($temp)) { $this->_pointer['contents'][] = $temp; } return $structure; } /** * Try to close a tag * * @param string $tagName * @return void */ protected function _closeTag($tagName) { if ($tagName !== $this->_pointer['name']) { // First find out, if the tag was actually opened $wasOpened = false; $highestKey = (count($this->_stack) - 1); for ($i = $highestKey; $i > 0; $i--) { if ($this->_stack[$i]['name'] === $tagName) { $wasOpened = true; break; } } if ($wasOpened) { // If it was opened, close all tags which were // opened so far for ($i = $highestKey; $i > 0; $i--) { $currentName = $this->_stack[$i]['name']; array_pop($this->_stack); if ($currentName === $tagName) { break; } } } } else { // Go up by a single level array_pop($this->_stack); } end($this->_stack); $this->_pointer = &$this->_stack[key($this->_stack)]; } /** * Add plaintext to the stack * * @param string $text * @return void */ protected function _addPlaintextToStack($text) { if ($this->_pointer['name'] !== '[root]' && !$this->_bbCodes[$this->_pointer['name']]['allowPlaintext']) { return; } $this->_pointer['contents'][] = $text; } }