<?php if (!defined('VB_ENTRY')) die('Access denied.');
/*========================================================================*\
|| ###################################################################### ||
|| # vBulletin 5.5.2
|| # ------------------------------------------------------------------ # ||
|| # Copyright 2000-2019 MH Sub I, LLC dba vBulletin. All Rights Reserved.  # ||
|| # This file may not be redistributed in whole or significant part.   # ||
|| # ----------------- VBULLETIN IS NOT FREE SOFTWARE ----------------- # ||
|| # http://www.vbulletin.com | http://www.vbulletin.com/license.html   # ||
|| ###################################################################### ||
\*========================================================================*/

/**
* Class to parse the HTML generated by the WYSIWYG editor to BB code.
* Can be extended to parse additional tags or change the parsing behavior.
*
* This class can be used for generic HTML to BB code conversions, but it is
* not always ideally suited to this.
*
* @package	vBulletin
*/
class vB_WysiwygHtmlParser
{
	use vB_Trait_NoSerialize;

	/**
	* Whether HTML is allowed. If false, non parsed HTML will be stripped.
	*
	* @var	boolean
	*/
	protected $allowHtml = false;

	/**
	* The number of linebreaks a <p> tag generates. This is usually 1 when
	* parsing from the WYSIWYG editors and 2 in other cases.
	* Well it was one with the vB3 editor yet ckeditor likes 0
	*
	* @var	int
	*/
	protected $pLinebreaks = 1;

	/**
	* The rules for the "normal" HTML tags that should be parsed. Only tags
	* that are matched (ie, <x>...</x>) and tags that are parsed without additional
	* context. See load_tag_rules for a format specification.
	*
	* @var	array
	*/
	protected $tags = array();

	/**
	* Arbitrary array that can be used for tracking limited tag state when parsing.
	* Use the push/pop state methods to modify and the inState method to check.
	* Useful if you want to parse a dependent tag differently if found in an
	* unexpected place (eg, <li> tag not in a list).
	*
	* @var	array
	*/
	protected $state = array();

	/**
	* Constructor. Automatically loads the tag rules.
	*
	*/
	public function __construct()
	{
		$this->tags = $this->loadTagRules();
	}

	/**
	* Returns the rule set for parsing matched tags. Array key is name of
	* HTML tag to match. Value is either a simple callback or an array with
	* keys 'callback' and 'param' (an optional extra value to pass in to the
	* parsing callback function). Callbacks may refer to the string $this
	* to refer to the current class instance.
	*
	* @return	array
	*/
	public function loadTagRules()
	{
		return array(
			'b' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'b'
			),
			'strong' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'b'
			),
			'i' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'i'
			),
			'em' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'i'
			),
			'u' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'u'
			),
			'blockquote' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'indent'
			),
			'sub' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'sub'
			),
			'sup' => array(
				'callback' => array('$this', 'parseTagBasic'),
				'param' => 'sup'
			),

			'table' => array('$this', 'parseTagTable'),
			'tr'    => array('$this', 'parseTagTr'),
			'td'    => array('$this', 'parseTagTd'),

			'ol'   => array('$this', 'parseTagList'),
			'ul'   => array('$this', 'parseTagList'),
			'li'   => array('$this', 'parseTagLi'),

			//we no longer use the font tag for bbcode.  But we still want to
			//parse is because copy/paste from websites using it will insert
			//into the CKEditor markup using it and we want to preserve the
			//font/color formatting as best we can in this case.
			'font' => array('$this', 'parseTagFont'),
			'span' => array('$this', 'parseTagSpan'),
			'a'    => array('$this', 'parseTagA'),

			'h1'   => array('$this', 'parseTagHeading'),
			'h2'   => array('$this', 'parseTagHeading'),
			'h3'   => array('$this', 'parseTagHeading'),
			'h4'   => array('$this', 'parseTagHeading'),
			'h5'   => array('$this', 'parseTagHeading'),
			'h6'   => array('$this', 'parseTagHeading'),

			'div'  => array('$this', 'parseTagDiv'),
			'p'    => array('$this', 'parseTagP'),
		);
	}

	/**
	* Sets the number of line breaks a <p> tag inserts.
	*
	* @param	int
	*/
	public function setPLinebreaks($linebreaks)
	{
		$linebreaks = intval($linebreaks);
		if ($linebreaks < 0)
		{
			$linebreaks = 0;
		}

		$this->pLinebreaks = $linebreaks;
	}

	/**
	* Determines whether the parser is in the named state.
	* Note that a parser can be in multiple states simultaneously.
	* The state is not tracked with a stack.
	*
	* @param	string	State
	*
	* @return	boolean
	*/
	public function inState($state)
	{
		return !empty($this->state[$state]);
	}

	/**
	* Pushes a new state into the list.
	*
	* @param	string	State
	*/
	protected function pushState($state)
	{
		if (isset($this->state[$state]))
		{
			$this->state[$state]++;
		}
		else
		{
			$this->state[$state] = 1;
		}
	}

	/**
	* Pops a state off the list.
	*
	* @param	string	State
	*/
	protected function popState($state)
	{
		if (isset($this->state[$state]))
		{
			$this->state[$state]--;

			if ($this->state[$state] <= 0)
			{
				unset($this->state[$state]);
			}
		}
	}

	/**
	* Parses the specified HTML into BB code
	*
	* @param	string	HTML to parse
	* @param	boolean	Whether to allow unparsable HTML to remain
	*
	* @return	string	Parsed version (BB code)
	*/
	public function parseWysiwygHtmlToBbcode($unparsed, $allowHtml = false)
	{

		$parsed = $unparsed;
		$this->allowHtml = $allowHtml;

		// Legacy Hook 'wysiwyg_parse_start' Removed //

		$parsed = $this->filterBefore($parsed);
		$parsed = $this->parseHtml($parsed);
		$parsed = $this->cleanupAfter($parsed);

		// Legacy Hook 'wysiwyg_parse_complete' Removed //

		return $parsed;
	}

	/**
	* Template method for pre-filtering the HTML before it is parsed.
	* Filters things like BB code mixed into HTML, browser specific wrapping,
	* and HTML within BB codes that don't support nested tags.
	*
	* @param	string	Text pre-filter
	*
	* @return	string	Text post-filter
	*/
	public function filterBefore($text)
	{
		$text = $this->filterHtmlTags($text);
		$text = $this->filterLinebreaksSpaces($text);
		$text = $this->filterBbcode($text);

		return $text;
	}

	/**
	* Filters the HTML tags to fix common issues (HTML intertwined with BB code).
	*
	* @param	string	Text pre-filter
	*
	* @return	string	Text post-filter
	*/
	protected function filterHtmlTags($text)
	{
		$text = preg_replace(
				'#<a href="([^"]*)\[([^"]+)"(.*)>(.*)\[\\2</a>#siU', // check for the WYSIWYG editor being lame with URL tags followed by bbcode tags
				'<a href="\1"\3>\4</a>[\2',                     // check for the browser (you know who you are!) being lame with URL tags followed by bbcode tags
			$text
		);

		return preg_replace_callback('#(<[^<>]+ (src|href))=(\'|"|)??(.*)(\\3)#siU',  // make < and > safe in inside URL/IMG tags so they don't get stripped by strip_tags
			array($this, 'escapeWithinUrlPregMatch'),  $text
		);
	}

	/**
	 * Callback for preg_replace_callback in filterHtmlTags
	 */
	protected function escapeWithinUrlPregMatch($matches)
	{
		return $this->escapeWithinUrl($matches[1], $matches[4], $matches[3]);
	}
	/**
	* PCRE callback for escaping special HTML characters within src/href attributes
	* so they are not removed by strip_tags calls later.
	*
	* @param	string	Type of call (tag name and src/href)
	* @param	string	URL that will be escaped
	* @param	string	Delimiter for the attribute
	*
	* @return	string	Escaped output.
	*/
	protected function escapeWithinUrl($type, $url, $delimiter = '\\"')
	{
		static $find, $replace;
		if (!is_array($find))
		{
			$find =    array('<',    '>',    '\\"');
			$replace = array('&lt;', '&gt;', '"');
		}

		$delimiter = str_replace('\\"', '"', $delimiter);

		return str_replace('\\"', '"', $type) . '=' . $delimiter . str_replace($find, $replace, $url) . $delimiter;
	}

	/**
	* Filters line breaks and spaces within the HTML. Also handles a browser-specific
	* behavior with soft wrapping.
	*
	* @param	string	Text pre-filter
	*
	* @return	string	Text post-filter
	*/
	protected function filterLinebreaksSpaces($text)
	{
		$text = str_replace('&nbsp;', ' ', $text);

		// Chrome paste to editor send in <br style="blah blah blah" />
		// Ensure that <br ....> is condensed down to <br />. Can't do negative look ahead (next preg_replace) on a non fixed match
		$text = preg_replace('#<br.*>#siU', '<br />', $text);
		// deal with newline characters
		$text = preg_replace('#(?<!<br />|\r)(\r\n|\n|\r)#', ' ', $text);
		$text = preg_replace('#(\r\n|\n|\r)#', '', $text);

		return $text;
	}

	/**
	* Filters BB code behaviors before the HTML is parsed. Includes removing
	* HTML from BB codes that don't support it and removing linking HTML from
	* a manually entered BB code.
	*
	* @param	string	Text pre-filter
	*
	* @return	string	Text post-filter
	*/
	protected function filterBbcode($text)
	{
		$text = preg_replace_callback('#\[(html|php)\]((?>[^\[]+?|(?R)|.))*\[/\\1\]#siU',// strip html from php tags
			array($this, 'stripHtmlFromBbcodePregMatch'), $text
		);
		$text = preg_replace('#\[url=(\'|"|&quot;|)<A href="(.*)/??">\\2/??</A>#siU',						// strip linked URLs from manually entered [url] tags (generic),
			'[URL=$1$2', $text);

		return $text;
	}

	/**
	 * Callback for preg_replace_callback in filterBbcode
	 */
	protected function stripHtmlFromBbcodePregMatch($matches)
	{
		return $this->stripHtmlFromBbcode($matches[0]);
	}

	/**
	* PCRE callback function to remove HTML from BB codes that don't support it.
	* Standard line break HTML is maintinaed.
	*
	* @param	string	Text within the BB code (with HTML)
	*
	* @param	return	Text without the HTML
	*/
	protected function stripHtmlFromBbcode($text)
	{
		$text = str_replace('\\"', '"', $text);
		return strip_tags($text, '<p><br>');
	}

	/**
	* Parses the HTML tags within a string.
	* Handles matched and special unmatched tags.
	*
	* @param	string	Text pre-parsed
	*
	* @return	string	Parsed text (BB code)
	*/
	public function parseHtml($text)
	{
		$text = $this->parseUnmatchedTags($text);
		$text = $this->parseMatchedTags($text);

		return $text;
	}

	/*
		TODO: add http://php.net/manual/en/dom.installation.php extension check as part of install!!
		Included by default, but some distros may not include
	 */

	/**
	* Parses special unmatched HTML tags like <img> and <br>.
	*
	* @param	string	Text pre-parsed
	*
	* @return	string	Parsed text
	*/
	protected function parseUnmatchedTags($text)
	{
		$pregfind = array
		(
			'#<br.*>#siU',                                        // <br> to newline
			'#(?:<p>\s*)?<hr\s*class=(\'|"|)previewbreak\s*(\\1)[^>]*>#si',
			'#(?:<p>\s*)?<hr.*>#siU'
		);
		$pregreplace = array
		(
			"\n",                                                 // <br> to newline
			'[PRBREAK][/PRBREAK]',
			'[HR][/HR]',
		);

		$text = preg_replace_callback('#<img[^>]+smilieid="(\d+)".*>#siU', // push code through the tag handler
			array($this, 'translateSmilieIdTextPregMatch'), $text);

		$text = $this->processEnhancedImageHtml($text);

		// TODO: the [^>]+ breaks if there's any attribute that has > in quotes before the src attribute. We need a way to handle this.
		$text = preg_replace_callback('#<img[^>]+src=(\'|")(.*)(\\1).*>#siU', // push code through the tag handler
			array($this, 'handleWysiwygImgPregMatch'), $text);


		$text = preg_replace($pregfind, $pregreplace, $text);


		return $text;
	}

	/**
	 * Callback for preg_replace_callback in filterBbcode
	 */
	protected function translateSmilieIdTextPregMatch($matches)
	{
		$result = $this->translateSmilieIdText($matches[1]);
		if($result !== false)
		{
			return $result;
		}
		else
		{
			return $matches[0];
		}
	}



	protected function processEnhancedImageHtml($text, $charset = '')
	{
		/*
			This is a really sketchy workaround for the issue where loadHTML() will encapsulate any text nodes immediately under the body in
			paragraph <p> tags. We surround the whole thing with a div, then remove it at the end. Terrible, but only way to use DOMDocument &
			prevent random additions of paragraph tags & keep our backend tests happy.
		 */
		$text = "<div>" . $text . "</div>";
		/*
			Solution adapted from	http://stackoverflow.com/a/12519958
		 */

		if (!$charset)
		{
			$charset =  vB_String::getCharset();
		}

		$isUtf8 = vB_String::areCharsetsEqual($charset, 'utf-8');

		//dom document only works with utf-8
		if(!$isUtf8)
		{
			$text = vB_String::toCharset($text, $charset, 'utf-8', false);
		}

		//black magic  Basically loadHTML wants to assume that the input is ISO-8859-1 and
		//doesn't want to let you tell it otherwise.  This causes it to substitute utf-8 characters
		//as HTML entities.  We need to add something to the document to make it change it's mind.
		$header = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';

		$doc = new DOMDocument();
		$load = @$doc->loadHTML($header . $text);
		if (!$load)
		{
			// DOMDocument::loadHTML() failed. Just return original text.
			return $text;
		}

		$query = '//figure[contains(@class, "bbcode-attachment")]|//p[contains(@class, "bbcode-attachment")]|' .
			'//a[contains(@class, "bbcode-attachment")]|//img[contains(@class, "bbcode-attachment")]';
		$xpath = new DOMXPath($doc);
		$image2_nodes = $xpath->query($query);

		foreach ($image2_nodes AS $node)
		{
			$skip = false;
			$tag = strtolower($node->nodeName);
			$img_node = null;
			$caption_node = null;
			switch ($tag)
			{
				case 'figure':
				case 'p':
				case 'a':
					foreach ($node->childNodes AS $child_node)
					{
						// We might have a nested anchor <figure><a><img></a>[<figcaption></figcaption>]</figure>
						if (empty($img_node) AND $child_node->nodeName == 'a')
						{
							foreach ($child_node->childNodes AS $grandchild_node)
							{
								if (empty($img_node) AND $grandchild_node->nodeName == 'img')
								{
									$img_node = $grandchild_node;
								}
							}
						}
						if (empty($img_node) AND $child_node->nodeName == 'img')
						{
							$img_node = $child_node;
						}
						if (empty($caption_node) AND $child_node->nodeName == 'figcaption')
						{
							$caption_node = $child_node;
						}
					}
					break;
				case 'img':
					$img_node = $node;
					break;
				default:
					break;
			}
			if (empty($img_node))
			{
				continue;
			}

			$attributes = array();
			// KEEP THIS SYNCED, GREP FOR FOLLOWING IN includes/vb5/template/bbcode.php
			// GREP MARK IMAGE2 ACCEPTED CONFIG
			$accepted_config = array(
				'alt'	=> true,
				'title' => true,
				'data-tempid' => true,
				'data-attachmentid' => true,
				'width' => true,
				'height' => true,
				'data-align' => true,
				'caption' => true,
				'data-linktype' => true,
				'data-linkurl' => true,
				'data-linktarget' => true,
				'style' => true,
				'data-size' => true,
				//'src' => true,
			);
			$img_attributes = array();
			if ($img_node->attributes)
			{
				foreach ($img_node->attributes AS $name => $attr)
				{
					if (isset($accepted_config[$attr->name]))
					{
						switch($accepted_config[$attr->name])
						{
							// e.g. we could specify that 'allowblank' ignores the empty check with this switch.
							default:
								if (!empty($attr->value))
								{
									$attributes[$attr->name] = $attr->value;
								}
								break;
						}
					}
				}
			}

			if (!empty($caption_node) AND !empty($caption_node->textContent))
			{
				$attributes['caption'] = $caption_node->textContent;
			}

			$img_url = $img_node->getAttribute('src');

			$replacement = $this->handleWysiwygAdvancedImageImg($img_url, $attributes);
			if (!empty($replacement))
			{
				$replacement_node = $doc->createTextNode($replacement);
			}
			$node->parentNode->replaceChild($replacement_node, $node);
		}

		// loading with LIBXML_HTML_NOIPLIED | LIMBXML_HMTML_NODEFDTD is dependent on the specific libxml version,
		// which may not be available all the time...
		// so hack around saveHTML() for load() without above options adding <html><body> etc tags.
		// From http://stackoverflow.com/a/6953808

		// remove <!DOCTYPE
		$doc->removeChild($doc->doctype);
		$text = $doc->saveHTML();

		// just remove the unwanted outer html tags without trying to be fancy.
		$remove_bits = array(
			$header,
			'<html>',
			'</html>',
			'<head>',
			'</head>',
			'<body>',
			'</body>'
		);
		$text = str_replace($remove_bits, '', $text);

		// also trim. For some reason backend test shows that white space is added by above processing.
		$text = trim($text);

		// See note before loadHtml();
		// Remove outer wrapper div we added strictly to preseve text outside of any tags.
		if (substr($text, 0, 5) == "<div>" AND substr($text, -6) == "</div>")
		{
			$strlen = strlen($text) - 11;
			$text = substr($text, 5, $strlen);
		}

		if(!$isUtf8)
		{
			$text = vB_String::toCharset($text, 'utf-8', $charset);
		}

		return $text;
	}


	protected function handleWysiwygAdvancedImageImg($img_url, $attributes)
	{
		// TODO: update regex to also check that it is OUR server?
		// handle image attachments stored locally in our db
		if (preg_match('#filedata/fetch\?filedataid=(\d+)#si', $img_url, $matches) AND
			!empty($attributes['data-tempid'])
		)	// the url uses filedataid and we have a tempid. This will be fixed by fixAttachBBCode() in the text library
		{
			if (count($attributes) > 0)
			{
				$config_str = json_encode($attributes, JSON_HEX_QUOT);
				return '[ATTACH=JSON]' . $config_str . '[/ATTACH]';
			}
			else
			{
				return '[ATTACH=CONFIG]' . $attributes['data-tempid'] . '[/ATTACH]';
			}
		}
		else if (preg_match('#filedata/fetch\?id=(\d+)#si', $img_url, $matches) AND // id points to an attachment nodeid
			!empty($attributes['data-attachmentid'])
		)
		{
			/* NOTE:
			 *	When a user inserts the image by the CKEDITOR's Image Dialog & providing a direct URL
			 *	instead of uploading locally to the server, the image will be inserted using the [IMG]
			 *	bbcode because we don't create the attachment record unnecessarily unless the user
			 *	actually changes the setting for that image. Now, there's a case where the URL provided
			 *	is a local file, but using the id (points to a node) query instead of filedataid
			 *	(node-independent). In such a case, we don't want to convert the img tag into an
			 *	[ATTACH] bbcode, because the text node that's being edited probably doesn't own that
			 *	attachment, and weird things will happen (ex. bbcode attachReplaceCallback will turn it
			 *	into an anchor tag with no text because the attachment isn't found).
			 * 	To resolve this issue, let's agree to ALWAYS provide either the tempid (for new attachments)
			 *	or attachmentid as data- attributes for attachments that the node owns (in
			 *	vB5_Template_BbCode's attachReplaceCallback()), and regex check for the attribute.
			*/
			if (count($attributes) > 0)
			{
				$config_str = json_encode($attributes, JSON_HEX_QUOT);
				return '[ATTACH=JSON]' . $config_str . '[/ATTACH]';
			}
			else
			{
				return '[ATTACH=CONFIG]n' . $attributes['data-attachmentid'] . '[/ATTACH]';
			}
		}

		// handle images with a relative path
		else if (!preg_match('#^https?://#i', $img_url))
		{
			if (count($attributes) > 0)
			{
				$attributes['src'] = create_full_url($img_url, false, true);
				$config_str = json_encode($attributes, JSON_HEX_QUOT);
				return '[IMG2=JSON]' . $config_str . '[/IMG2]';
			}

			// prefix with the URL to this board
			// TODO: refactor create_full_url
			return '[IMG]'. create_full_url($img_url) . '[/IMG]'; // todo: this prepends with the "core" URL. Don't think this is correct, it should probably be using the same URL as what the "base" tag is using...
		}

		// handle fully qualified/external image urls, ex. from an rss feed
		else
		{
			if (count($attributes) > 0)
			{
				$attributes['src'] = $img_url;
				$config_str = json_encode($attributes, JSON_HEX_QUOT);
				return '[IMG2=JSON]' . $config_str . '[/IMG2]';
			}

			return '[IMG]' . $img_url . '[/IMG]';
		}
	}

	/**
	 * Callback for preg_replace_callback in filterBbcode
	 */
	protected function handleWysiwygImgPregMatch($matches)
	{
		return $this->handleWysiwygImg($matches[2], $matches[0]);
	}

	/**
	* Translates the specified img link into an img bbcode
	*
	* @param	string	image url
	* @param	string	full image tag
	*
	* @return	string	img bbcode
	*/
	protected function handleWysiwygImg($img_url, $fulltag)
	{
		$img_url = str_replace('\\"', '"', $img_url);
		$fulltag = str_replace('\\"', '"', $fulltag);

		// handle image attachments stored locally in our db
		if (preg_match('#filedata/fetch\?filedataid=(\d+)&amp;sigpic=1#si', $img_url, $matches))
		{
			return '[SIGPIC][/SIGPIC]';
		}

		// vB5 displays [ATTACH] bbcodes as the bbcodes instead of images in the Wysiwyg editor,
		// and displays only the [IMG] bbcodes as actual images.
		// As such, replacing the preg_matched url below with [ATTACH] is incorrect.
		// Leaving previous code here in case we want to restore parts of it later.
		// See VBV-9001 for more details

		// Note, restoring code for VBV-5936. Per VBV-12030 they will always be inserted as WYSIWYG not bbcode,
		// and we need to convert images inserted via the CKEditor's Image Dialog to attach bbcodes in order
		// for image settings to actually work.

		// TODO: update regex to also check that it is OUR server?
		// handle image attachments stored locally in our db
		if (preg_match('#filedata/fetch\?filedataid=(\d+)#si', $img_url, $matches) AND
			preg_match('#data-tempid=(\'|")(.*)(\\1)#siU', $fulltag, $dataMatches)
		)	// the url uses filedataid and we have a tempid. This will be fixed by fixAttachBBCode() in the text library
		{
			return '[ATTACH=CONFIG]' . $dataMatches[2] . '[/ATTACH]';
		}
		else if (preg_match('#filedata/fetch\?id=(\d+)#si', $img_url, $matches) AND // id points to an attachment nodeid
			preg_match('#data-attachmentid=(\'|")(.*)(\\1)#siU', $fulltag, $dataMatches))	// SEE NOTE BELOW
		{
			/* NOTE:
			 *	When a user inserts the image by the CKEDITOR's Image Dialog & providing a direct URL
			 *	instead of uploading locally to the server, the image will be inserted using the [IMG]
			 *	bbcode because we don't create the attachment record unnecessarily unless the user
			 *	actually changes the setting for that image. Now, there's a case where the URL provided
			 *	is a local file, but using the id (points to a node) query instead of filedataid
			 *	(node-independent). In such a case, we don't want to convert the img tag into an
			 *	[ATTACH] bbcode, because the text node that's being edited probably doesn't own that
			 *	attachment, and weird things will happen (ex. bbcode attachReplaceCallback will turn it
			 *	into an anchor tag with no text because the attachment isn't found).
			 * 	To resolve this issue, let's agree to ALWAYS provide either the tempid (for new attachments)
			 *	or attachmentid as data- attributes for attachments that the node owns (in
			 *	vB5_Template_BbCode's attachReplaceCallback()), and regex check for the attribute.
			*/
			return '[ATTACH=CONFIG]n' . $matches[1] . '[/ATTACH]';
		}

		// handle images with a relative path
		else if (!preg_match('#^https?://#i', $img_url))
		{
			// prefix with the URL to this board
			// TODO: refactor create_full_url
			return '[IMG]'. create_full_url($img_url) . '[/IMG]'; // todo: this prepends with the "core" URL. Don't think this is correct, it should probably be using the same URL as what the "base" tag is using...
		}

		// handle fully qualified/external image urls, ex. from an rss feed
		else
		{
			return '[IMG]' . $img_url . '[/IMG]';
		}
	}

	/**
	* Translates the specified smilie ID to the text that represents that smilie.
	*
	* @param	int	Smilie ID
	*
	* @return	string	Smilie text
	*/
	protected function translateSmilieIdText($smilieid)
	{
		static $smilies;

		// build the smilies array if we haven't already
		if (!is_array($smilies))
		{
			$smilies = array();

			$smiliecache = vB_Api::instanceInternal('bbcode')->fetchSmilies();
			foreach($smiliecache AS $smilie)
			{
				$smilies["$smilie[smilieid]"] = $smilie['smilietext'];
			}
		}

		// return the smilietext for this smilie
		if (isset($smilies["$smilieid"]))
		{
			return $smilies["$smilieid"];
		}
		else
		{
			return false;
		}
	}

	/**
	* PCRE callback function to parse an <img> tag. Can only parse the src attribute.
	*
	* @param	string	The image's URL (src attribute)
	*
	* @return	string	An IMG BB code
	*/
	protected function parseTagImg($img_url)
	{
		$img_url = str_replace('\\"', '"', $img_url);

		if (!preg_match('#^https?://#i', $img_url))
		{
			// relative URL, prefix it with the URL to this board
			$img_url = create_full_url($img_url);
		}

		return '[IMG]' . $img_url . '[/IMG]';
	}

	/**
	* Parses "normal" matched HTML tags. This function (and the individual
	* tag functions) are the primary places where the tag parsing rules are used.
	*
	* @param	string	Text pre-parsed
	*
	* @param	string	Text with matched HTML tags parsed (the ones specified in rules at least)
	*/
	protected function parseMatchedTags($text)
	{
		$pregfind = array
		(
			'#<a name=[^>]*>(.*)</a>#siU',                         // kill named anchors
		);
		$pregreplace = array
		(
			'\1',                                                  // kill named anchors
		);
		$text = preg_replace($pregfind, $pregreplace, $text);

		foreach (array_keys($this->tags) AS $tag_name)
		{
			$text = $this->parseTagByName($tag_name, $text);
		}

		return $text;
	}

	/**
	* Parses a matched HTML tag by the name of the tag. This is resolved to the tag
	* parsing rules array and handled from there.
	*
	* @param	string	Name of the HTML tag to parse
	* @param	string	Text before this tag has been parsed
	* @param	mixed	Extra param info to pass to the callback; overrides the param specified in the tag rules
	*
	* @param	string	Text with tag parsed
	*/
	public function parseTagByName($tagName, $text, $forceParam = null)
	{
		$tagName = strtolower($tagName);

		if (!isset($this->tags[$tagName]))
		{
			return $text;
		}

		$tagInfo = $this->tags[$tagName];

		if (isset($tagInfo['callback']))
		{
			$callback = $tagInfo['callback'];
			$extraParam = isset($tagInfo['param']) ? $tagInfo['param'] : null;
		}
		else
		{
			$callback = $tagInfo;
			$extraParam = null;
		}

		if (is_array($callback) AND $callback[0] == '$this')
		{
			$callback[0] = $this;
		}

		if ($forceParam !== null)
		{
			$extraParam = $forceParam;
		}

		$params = array($tagName, $text, $callback);
		if ($extraParam !== null)
		{
			$params[] = $extraParam;
		}

		$text = call_user_func_array(
			array($this, 'parseTag'),
			$params
		);

		return $text;
	}

	/**
	* Post parsing clean up. Removes unparsed HTML and sanitizes some BB codes.
	*
	* @param	string	Text pre-cleanup
	*
	* @return	string	Text post-cleanup
	*/
	public function cleanupAfter($text)
	{
		$text = $this->cleanupHtml($text);
		$text = $this->cleanupSmiliesFromImages($text);
		$text = $this->cleanupBbcode($text);

		return $text;
	}

	/**
	* Cleans up HTML stragglers after the parsing.
	*
	* @param	string	Text pre-cleanup
	*
	* @return	string	Text post-cleanup
	*/
	protected function cleanupHtml($text)
	{
		// regex find / replace #2
		$pregfind = array(
			'#<li>(.*)((?=<li>)|</li>)#iU',    // fix some list issues
			'#<p></p>#i',                      // kill empty <p> tags
			'#<p.*>#iU',                       // kill any extra <p> tags
		);
		$pregreplace = array(
			"\\1\n",                           // fix some list issues
			'',                                // kill empty <p> tags
			"\n",                              // kill any extra <p> tags
		);
		$text = preg_replace($pregfind, $pregreplace, $text);

		// simple tag removals; mainly using PCRE for case insensitivity and /?
		$text = preg_replace('#</?(A|LI|FONT|IMG)>#siU', '', $text);

		if (!$this->allowHtml)
		{
			$text = $this->cleanupDisallowedHtml($text);
		}

		// basic string replacements #2; don't replace &quot; because browsers don't auto-encode quotes
		$strfind = array
		(
			'&lt;',       // un-htmlspecialchars <
			'&gt;',       // un-htmlspecialchars >
			'&amp;',      // un-htmlspecialchars &
		);
		$strreplace = array
		(
			'<',          // un-htmlspecialchars <
			'>',          // un-htmlspecialchars >
			'&',          // un-htmlspecialchars &
		);

		$text = str_replace($strfind, $strreplace, $text);

		return $text;
	}

	/**
	* Cleans up disallowed HTML. This generally removes all HTML. It is normally
	* called if HTML is not allowed.
	*
	* @param	string	Text pre-cleanup
	*
	* @return	string	Text post-cleanup
	*/
	protected function cleanupDisallowedHtml($text)
	{
		$text = preg_replace('#<script[^>]*>(.*)</script>#siU', '', $text);
		$text = preg_replace('#<style[^>]*>(.*)</style>#siU', '', $text);
		$text = strip_tags($text);

		return $text;
	}

	/**
	* Translates image BB codes that represent smilies into the actual
	* smilie representation.
	*
	* @param	string	Text pre-cleanup
	*
	* @return	string	Text post-cleanup
	*/
	protected function cleanupSmiliesFromImages($text)
	{
		$smiliecache = vB_Api::instanceInternal('bbcode')->fetchSmilies();
		if (is_array($smiliecache))
		{
			$strfind = array();
			$strreplace = array();

			foreach ($smiliecache AS $smilie)
			{
				// [IMG]images/smilies/frown.gif[/IMG]
				$strfind[] = '[IMG]' . $smilie['smiliepath'] . '[/IMG]';
				$strreplace[] = $smilie['smilietext'];

				// [IMG]http://domain.com/forum/images/smilies/frown.gif[/IMG]
//				$strfind[] = '[IMG]' . create_full_url($smilie['smiliepath']) . '[/IMG]';
//				$strreplace[] = $smilie['smilietext'];
			}

			$text = str_replace($strfind, $strreplace, $text);
		}

		return $text;
	}

	/**
	* General BB code cleanup after HTML parsing.
	*
	* @param	string	Text pre-cleanup
	*
	* @return	string	Text post-cleanup
	*/
	protected function cleanupBbcode($text)
	{
		/*
		// Removing this code, as it was causing VBV-17048 without any apparent benefit.
		if (vB::getRequest()->isBrowser('mozilla'))
		{
			// mozilla treats line breaks before/after lists a little differently from IE (see #5774)
			$text = preg_replace('#\[(list)#i', "\n[\\1", $text);
			$text = preg_replace('#\[(/list)\]#i', "[\\1]\n", $text);
		}
		*/

		$text = preg_replace('#(?<!\r|\n|^)[\s]*\[(/list|list|\*)\]#i', "\n[\\1]", $text);

		// replace advanced URL tags that should actually be basic ones
		$text = preg_replace('#\[URL=("|\'|)(.*)\\1\]\\2\[/URL\]#siU', '[URL]$2[/URL]', $text);

		// *** Replace @username syntax with [user]username[/user] bbcode ***

		$vboptions = vB::getDatastore()->getValue('options');
		$min = $vboptions['minuserlength'];
		$max = $vboptions['maxuserlength'];

		// reduce $min by up to two, because the regex matches up to two chars
		// before $min is used (whitespace and delimiter)
		$min = max(0, $min - 2);
		// increase $max by four, to account for the whitespace & delimiter
		// at beginning and end.
		$max += 4;

		// change any NOPARSE, CODE, HTML, or PHP tag to a placeholder, so @username
		// is not replaced within them.
		if (preg_match_all('#\[(NOPARSE|CODE|HTML|PHP)\].*\[/\1\]#siU', $text, $matches))
		{
			$placeholder = '{{vbulletin-noparse-placeholder-' . md5(microtime(true)) . '-';
			$noparseContent = array();
			$noparsePlaceholders = array();
			$i = 0;
			foreach ($matches[0] AS $match)
			{
				++$i;
				$noparseContent[] = $match;
				$noparsePlaceholders[] = $placeholder . $i . '}}';
			}
			if (!empty($noparseContent))
			{
				$text = str_replace($noparseContent, $noparsePlaceholders, $text);
			}
		}

		$userBbcodeEnabled = $this->isBbcodeTagAllowed('user');
		// todo, Check limits and break out when # of usermentions reaches limits
		$matches_subsets = array();
		if ($userBbcodeEnabled AND preg_match_all('#(?<=^|\s)@(?P<username>[^\s@][^@]{' . $min . ',' . $max . '}[^@])#si', $text, $matches))
		{
			$usernames = array();
			foreach ($matches['username'] AS $username)
			{
				if (substr($username, 0, 1) == '"' AND ($pos = strpos($username, '"', 1)) > 1)
				{
					// quote-delimited username, use as-is
					$usernames[] = substr($username, 1, $pos - 1);
				}
				else
				{
					// username can be too long because the regex matches leading &
					// trailing chars. Limit it to a max of exactly maxuserlength + 1
					// to check for a trailing space.
					$chopped = false;
					if (strlen($username) > $vboptions['maxuserlength'] + 1)
					{
						$username = substr($username, 0, $vboptions['maxuserlength'] + 1);
						$chopped = true;
					}

					$parts = explode(' ', $username);

					// If there was a trailing space at the end of $username,
					// last element in $parts will be an empty string.
					// If there was no trailing space, the last element
					// will be the last word (chopped off). In both cases,
					// we need to remove the last element from $parts
					$lastPart = end($parts);
					if ($lastPart === '' OR $chopped)
					{
						array_pop($parts);
					}

					$cumulative = array();
					foreach ($parts AS $part)
					{
						$cumulative[] = $part;
						$tempUsername = implode(' ', $cumulative);
						$tempUsernameLen = strlen($tempUsername);
						if ($tempUsernameLen >= $vboptions['minuserlength'] AND $tempUsernameLen <= $vboptions['maxuserlength'])
						{
							$usernames[] = $tempUsername;
							$matches_subsets[$username][] = strtolower($tempUsername);
						}
					}
				}
			}

			$usernames = array_unique($usernames);

			$users = vB::getDbAssertor()->assertQuery('user', array(
				vB_dB_Query::CONDITIONS_KEY => array('username' => $usernames),
				vB_dB_Query::COLUMNS_KEY => array('username', 'userid'),
			));

			$usersKeyedByUsername= array();
			foreach ($users AS $user)
			{
				// we check lower case since username column (varchar) is case insensitive by default
				$usersKeyedByUsername[strtolower($user['username'])] = $user;
			}

			$usernamesWithTrailingPunc = array();
			$punctuations = array(
				',' => true,
				'.' => true,
				';' => true,
				'?' => true,
				'!' => true,
				'-' => true,
			);
			foreach ($usernames AS $username)
			{
				if (isset($punctuations[substr($username, -1, 1)]))
				{
					$usernamesWithTrailingPunc[] = substr($username, 0, -1);
				}
			}

			$usersWithTrailingPunc = array();
			if (!empty($usernamesWithTrailingPunc))
			{
				$usersWithTrailingPunc = vB::getDbAssertor()->assertQuery('user', array(
					vB_dB_Query::CONDITIONS_KEY => array('username' => $usernamesWithTrailingPunc),
					vB_dB_Query::COLUMNS_KEY => array('username', 'userid'),
				));
			}

			$usersWithTrailingPuncKeyedByUsername = array();
			foreach($usersWithTrailingPunc AS $user)
			{
				$usersWithTrailingPuncKeyedByUsername[strtolower($user['username'])] = $user;
			}

			$find = array();
			$replace = array();
			foreach ($matches['username'] AS $idx => $match)
			{
				$best = array('username' => '', 'userid' => 0);
				$isQuoteDelimited = false;

				if (substr($match, 0, 1) == '"' AND ($pos = strpos($match, '"', 1)) > 1)
				{
					// we have a quote-delimited username
					$tempmatch = strtolower(substr($match, 1, $pos - 1));

					if (isset($usersKeyedByUsername[$tempmatch]))
					{
						$isQuoteDelimited = true;
						$best = $usersKeyedByUsername[$tempmatch];
					}
				}
				else
				{
					/*
						$matches_subsets[$match] contains any possible subset of $match from the
						beginning of $match to any spaces in $match. If we find an exact match
						here, we use that as the 'best name'. This is to make sure that cases like
						"@john @johnathan johnson" matches johnathan, not john, for the second mention,
						while the mentions in "@jane doe @jane doesn't" both match "jane" (not "jane doe"
						as the first is not quoted) per the specs.
					 */
					$exactMatchFound = false;
					$possibleSubsetsKey = substr($match, 0, $vboptions['maxuserlength'] + 1); // see above block where $matches_subset is set. The keys are at most this length.
					if (isset($matches_subsets[$possibleSubsetsKey]))
					{
						foreach ($matches_subsets[$possibleSubsetsKey] AS $fragment)
						{
							if (isset($usersKeyedByUsername[$fragment]))
							{
								$exactMatchFound = true;
								$best = $usersKeyedByUsername[$fragment];
							}
						}
					}


					if (!$exactMatchFound)
					{
						// non quote-delimited username, find the best (shortest) username
						// that matches
						foreach ($users AS $user)
						{
							if (stripos($match, $user['username']) === 0 AND (strlen($best['username']) == 0 OR strlen($user['username']) < strlen($best['username'])))
							{
								$best = $user;
							}
						}
					}

					/*
						We assume that people tagging other users will respect simple grammar and have a space following a punctuation. May not always be true,
						but we've gotta draw the line somewhere.
						(ex. If we see "@john,athan" we assume they were going for a user named "john,athan" not "john". This block is meant to only support things like
						"@john, son lorem ipsum" => "john" if above block didn't find any of "john, son lorem ipsum", "john, son lorem", "john, son", or "john,".
					 */
					if (empty($best['username']))
					{
						$exactMatchFound = false;
						$possibleSubsetsKey = substr($match, 0, $vboptions['maxuserlength'] + 1); // see above block where $matches_subset is set. The keys are at most this length.
						if (isset($matches_subsets[$possibleSubsetsKey]))
						{
							foreach ($matches_subsets[$possibleSubsetsKey] AS $fragment)
							{
								$tempmatch = false;
								$fragment_nowhitespace = trim($fragment);
								$hasTrailingPunctuation = isset($punctuations[substr($fragment, -1, 1)]);
								if ($hasTrailingPunctuation)
								{
									$tempmatch = strtolower(substr($fragment_nowhitespace, 0, -1)); // extract username sans trailing punctuation, and set to lowercase to account for case-insensitivity.
								}
								if ($tempmatch !== false AND isset($usersWithTrailingPuncKeyedByUsername[$tempmatch]))
								{
									$exactMatchFound = true;
									$best = $usersWithTrailingPuncKeyedByUsername[$tempmatch];
								}
							}
						}
						if (!$exactMatchFound)
						{
							foreach ($usersWithTrailingPunc AS $user)
							{
								if (stripos($match, $user['username']) === 0 AND (strlen($best['username']) == 0 OR strlen($user['username']) < strlen($best['username'])))
								{
									$best = $user;
								}
							}
						}
					}
				}

				if (!empty($best['username']))
				{
					$usernamelength = strlen($best['username']);
					$usernamelength += ($isQuoteDelimited ? 2 : 0);
					$trailingText = substr($match, $usernamelength);

					$find[] = '@' . $match;
					$replace[] = "[USER=\"$best[userid]\"]$best[username][/USER]" . $trailingText;
				}
			}

			if (!empty($find))
			{
				$text = str_replace($find, $replace, $text);
			}
		}


		// ensure we have the userid for [user]username[/user] bbcode
		// so that we don't look it up at run/display time
		// if it already has a userid, leave it alone
		if ($userBbcodeEnabled AND preg_match_all('#\[USER\](.*)\[/USER\]#siU', $text, $matches, PREG_SET_ORDER))
		{
			$usernames = array();
			foreach ($matches AS $match)
			{
				$usernames[] = $match[1];
			}
			$usernames = array_unique($usernames);

			$userResult = vB::getDbAssertor()->assertQuery('user', array(
				vB_dB_Query::CONDITIONS_KEY => array('username' => $usernames),
				vB_dB_Query::COLUMNS_KEY => array('username', 'userid'),
			));
			$users = array();
			foreach ($userResult AS $user)
			{
				$users[strtolower($user['username'])] = $user;
			}

			$find = array();
			$replace = array();
			foreach ($matches AS $match)
			{
				if (isset($users[strtolower($match[1])]))
				{
					$user = $users[strtolower($match[1])];
					$find[] = $match[0];
					$replace[] = "[USER=\"$user[userid]\"]$user[username][/USER]";
				}
			}

			if (!empty($find))
			{
				$text = str_replace($find, $replace, $text);
			}
		}

		// restore content from NOPARSE, CODE, HTML, and PHP tags
		if (!empty($noparseContent))
		{
			$text = str_replace($noparsePlaceholders, $noparseContent, $text);
		}

		return $text;
	}

	/**
	* Parses the style attribute from a list of attributes and determines
	* if tags need to be wrapped. This does not do the wrapping, but gives you
	* the text to prepend/append.
	*
	* @param	string	Attribute string (multiple attributes within)
	* @param	string	(return) Text to prepend
	* @param	string	(return) Text to append
	*/
	protected function parseStyleAttribute($tagoptions, &$prependtags, &$appendtags)
	{
		$searchlist = array(
			array('tag' => 'left',   'option' => false, 'regex' => '#text-align:\s*(left);?#i'),
			array('tag' => 'center', 'option' => false, 'regex' => '#text-align:\s*(center);?#i'),
			array('tag' => 'right',  'option' => false, 'regex' => '#text-align:\s*(right);?#i'),
			array('tag' => 'color',  'option' => true,  'regex' => '#(?<![a-z0-9-])color:\s*([^;]+);?#i', 'match' => 1),
			array('tag' => 'font',   'option' => true,  'regex' => '#font-family:\s*(\'|)([^;,\']+)\\1[^;]*;?#i', 'match' => 2),
			array('tag' => 'b',      'option' => false, 'regex' => '#font-weight:\s*(bold);?#i'),
			array('tag' => 'i',      'option' => false, 'regex' => '#font-style:\s*(italic);?#i'),
			array('tag' => 'size',   'option' => true,  'regex' => '#font-size:\s*(\d+px);?#i', 'match' => 1),
			array('tag' => 'u',      'option' => false, 'regex' => '#text-decoration:\s*(underline);?#i')
		);

		$style = $this->parseWysiwygTagAttribute('style=', $tagoptions);
		$style = preg_replace_callback(
			'#(?<![a-z0-9-])color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)(;?)#i',
			function($matches)
			{
				return sprintf("color: #%02X%02X%02X$matches[4]", $matches[1], $matches[2], $matches[3]);
			},
			$style
		);

		foreach ($searchlist AS $searchtag)
		{
			if (!$this->isBbcodeTagAllowed($searchtag['tag']))
			{
				continue;
			}

			if (preg_match($searchtag['regex'], $style, $matches))
			{
				$prependtags .= '[' . strtoupper($searchtag['tag']) . ($searchtag['option'] == true ? '=' . $matches["$searchtag[match]"] : '') . ']';
				$appendtags = '[/' . strtoupper($searchtag['tag']) . "]$appendtags";
			}
		}
	}

	/**
	* Parses an <a> tag. Matches URL and EMAIL BB code.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagA($aoptions, $text, $tag_name, $args)
	{
		$href = $this->parseWysiwygTagAttribute('href=', $aoptions);

		if (!trim($href))
		{
			return $this->parseTagByName('a', $text);
		}

		if (substr($href, 0, 7) == 'mailto:')
		{
			$tag = 'email';
			$href = substr($href, 7);
		}
		else if (preg_match('#filedata/fetch\?filedataid=(\d+)#si', $href, $matches))
		{
			$tag = 'attach';
			unset($href);
			// the url uses filedataid and we have a tempid. This will be fixed by fixAttachBBCode() in the text library
			if (preg_match('#data-tempid=(\'|")(.*)(\\1)#siU', $aoptions, $dataMatches))
			{
				$text = $dataMatches[2];
			}
			else
			{
				$text = 'n' . $matches[1];
			}
		}
		else if (preg_match('#class="[^"]*b-bbcode-user[^"]*"#siU', $aoptions, $matches))
		{
			$tag = 'user';

			// look up the user
			$user = vB::getDbAssertor()->getRow('user', array('username' => $text));

			$href = $user['userid'];
			$text = $user['username'];
		}
		else
		{
			$tag = 'url';
			if (!preg_match('#^[a-z0-9]+:#i', $href))
			{
				// relative URL, prefix it with the URL to this board
				$href = create_full_url($href);
			}
		}
		$tag = strtoupper($tag);

		if ($this->isBbcodeTagAllowed($tag))
		{
			$tag_b = $tag;

			if (!empty($href))
			{
				$tag_b .= "=\"$href\"";
			}

			return "[$tag_b]" . $this->parseTagByName('a', $text) . "[/$tag]";
		}
		else
		{
			// can't auto link, return a plaintext version
			$inner_text = $this->parseTagByName('a', $text);
			if ($inner_text != $href)
			{
				return "$inner_text ($href)";
			}
			else
			{
				return $href;
			}
		}
	}

	/**
	* Parses <h1> through <h6> tags. Simply uses bold with line breaks.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagHeading($attributes, $text, $tag_name, $args)
	{
		$tag_name = strtoupper($tag_name);

		$text = trim($text);

		if ($tag_name == 'H3')
		{
			$class = $this->parseWysiwygTagAttribute('class=', $attributes);
			if ($class == 'wysiwyg_pagebreak')
			{
				return "[PAGE]{$text}[/PAGE]";
			}
		}

		if (preg_match('#^h(\d)$#i', $tag_name, $match))
		{
			$level = $match[1];
			return "[h=$level]{$text}[/h]";
		}
		else
		{
			return "$text\n\n";
		}
	}

	/**
	* Parses a <p> tag. Supports alignments and style attributes. Gives a line break.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagP($poptions, $text, $tag_name, $args)
	{
		if (!$text)
		{
			return '';
		}

		$style = $this->parseWysiwygTagAttribute('style=', $poptions);
		$align = $this->parseWysiwygTagAttribute('align=', $poptions);
		$textalign = $this->parseWysiwygStyleAttribute('text-align:', $style);

		$lessbreaks = false;

		// only allow left/center/right alignments
		switch ($align)
		{
			case 'left':
			case 'center':
			case 'right':
				$lessbreaks = true;
				break;
			default:
				$align = '';
		}

		if ($textalign)
		{
			$lessbreaks = true;
		}

		$align = strtoupper($align);

		$prepend = '';
		$append = '';

		$this->parseStyleAttribute($poptions, $prepend, $append);
		if ($align AND $this->isBbcodeTagAllowed($align))
		{
			$prepend .= "[$align]";
			$append .= "[/$align]";
		}

		if (preg_match("#^<table#si", $text))
		{
			$lessbreaks = true;
		}

		$dobreaks = $lessbreaks ? $this->pLinebreaks - 1 : $this->pLinebreaks;

		$append .= str_repeat("\n", $dobreaks);
		return $prepend . $this->parseTagByName('p', $text) . $append;
	}

	/**
	* Parses a <span> tag. Supports style attributes.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagSpan($spanoptions, $text, $tag_name, $args)
	{
		$prependtags = '';
		$appendtags = '';
		$this->parseStyleAttribute($spanoptions, $prependtags, $appendtags);

		return $prependtags . $this->parseTagByName('span', $text) . $appendtags;
	}

	/**
	* Parses a <div> tag. Supports alignments and style attributes. Gives a line break.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagDiv($divoptions, $text, $tag_name, $args)
	{
		$prepend = '';
		$append = '';

		$this->parseStyleAttribute($divoptions, $prepend, $append);
		$align = $this->parseWysiwygTagAttribute('align=', $divoptions);

		$user = vB::getCurrentSession()->fetch_userinfo();
		$dir = ($user['lang_options']['direction'] ? 'left' : 'right');

		if ($indentcount = intval(intval($this->parseWysiwygStyleAttribute('margin-' . $dir . ':', $divoptions)) / vB_Api_Bbcode::EDITOR_INDENT))
		{
			if ($indentcount > 1)
			{
				$prepend .= "[INDENT={$indentcount}]";
			}
			else
			{
				$prepend .= '[INDENT]';
			}
			$append .= '[/INDENT]';
		}

		// only allow left/center/right alignments
		switch ($align)
		{
			case 'left':
			case 'center':
			case 'right':
				break;
			default:
				$align = '';
		}

		$align = strtoupper($align);

		if ($align AND $this->isBbcodeTagAllowed($align))
		{
			$prepend .= "[$align]";
			$append .= "[/$align]";
		}
		$append .= "\n";

		return $prepend . $this->parseTagByName('div', $text) . $append;
	}

	/**
	* Parses an <li> tag. Outputs the list element BB code if within a list state.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagLi($listoptions, $text, $tag_name, $args)
	{
		$indent = '';
		// TODO: use stylevar
//		$dir = $this->registry->stylevars['textdirection']['string'] == 'rtl' ? 'right' : 'left';
		$dir = 'left';
		if ($indentcount = intval(intval($this->parseWysiwygStyleAttribute('margin-' . $dir . ':', $listoptions)) / vB_Api_Bbcode::EDITOR_INDENT))
		{
			$indent = '=' . $indentcount;
		}

		if (!$this->isBbcodeTagAllowed('list') OR !$this->inState('list'))
		{
			return "$text\n";
		}

		return "[*{$indent}]" . trim($text);
	}

	/**
	* Parses <ol> and <ul> tags.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagList($listoptions, $text, $tagname, $args)
	{
		$longtype = $this->parseWysiwygTagAttribute('class=', $listoptions);
		$listtype = trim(preg_replace('#"?LIST-STYLE-TYPE:\s*([a-z0-9_-]+);?"?#si', '\\1', $longtype));
		if (empty($listtype) AND $tagname == 'ol')
		{
			$listtype = 'decimal';
		}

		$indent = '';
		// TODO: use stylevar
//		$dir = $this->registry->stylevars['textdirection']['string'] == 'rtl' ? 'right' : 'left';
		$dir = 'left';
		if ($indentcount = intval(intval($this->parseWysiwygStyleAttribute('margin-' . $dir . ':', $listoptions)) / vB_Api_Bbcode::EDITOR_INDENT))
		{
			$indent = '|INDENT=' . $indentcount;
		}

		$this->pushState('list');
		$text = preg_replace('#<li(\s+style="[^"]*)?>((?' . '>[^[<]+?|(?!</li).)*)(?=</?ol|</?ul|<li|\[list|\[/list)#siU', '<li\\1>\\2</li>', $text);
		$text = $this->parseTagByName('li', $text);

		if (!$this->isBbcodeTagAllowed('list'))
		{
			return $text;
		}

		$validtypes = array(
			'upper-alpha' => 'A',
			'lower-alpha' => 'a',
			'upper-roman' => 'I',
			'lower-roman' => 'i',
			'decimal' => '1'
		);
		if (!isset($validtypes["$listtype"]) OR strtolower($tagname) == 'ul')
		{
			if ($indent)
			{
				$opentag = "[LIST={$indent}]"; // default to bulleted
			}
			else
			{
				$opentag = '[LIST]'; // default to bulleted
			}
		}
		else
		{
			$opentag = '[LIST=' . $validtypes[$listtype] . "{$indent}]";
		}

		$text = $this->parseTagByName($tagname, $text);

		$this->popState('list');

		return $opentag . $text . '[/LIST]';
	}

	/**
	* Parses a <font> tag. Supports font face, size, and color.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagFont($fontoptions, $text, $tag_name, $args)
	{
		$tags = array(
			'font' => 'face=',
			'size' => 'size=',
			'color' => 'color='
		);
		$prependtags = '';
		$appendtags = '';

		$fontoptionlen = strlen($fontoptions);

		foreach ($tags AS $vbcode => $locate)
		{
			$optionvalue = $this->parseWysiwygTagAttribute($locate, $fontoptions);
			if ($optionvalue)
			{
				$vbcode = strtoupper($vbcode);
				$prependtags .= "[$vbcode=$optionvalue]";
				$appendtags = "[/$vbcode]$appendtags";
			}
		}

		$this->parseStyleAttribute($fontoptions, $prependtags, $appendtags);

		return $prependtags . $this->parseTagByName('font', $text) . $appendtags;
	}

	/**
	* Parses and does a basic HTML replacement for the named tag. The
	* argument passed in is the BB code to parse to.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Name of the BB code to parse to
	*/
	protected function parseTagBasic($options, $text, $tagname, $parseto)
	{
		$useoptions = array(); // array of (key) tag name; (val) option to read. If tag name isn't found, no option is used

		if (trim($text) == '')
		{
			return '';
		}

		if (!$this->isBbcodeTagAllowed($parseto))
		{
			return $text;
		}

		$parseto = strtoupper($parseto);

		if (empty($useoptions["$tagname"]))
		{
			$text = $this->parseTagByName($tagname, $text);
			return "[$parseto]{$text}[/$parseto]";
		}
		else
		{
			$optionvalue = $this->parseWysiwygTagAttribute($useoptions["$tagname"], $options);
			if ($optionvalue)
			{
				return "[$parseto=$optionvalue]{$text}[/$parseto]";
			}
			else
			{
				return "[$parseto]{$text}[/$parseto]";
			}
		}
	}

/**
	* Builds the key-value parameter format for table (and tr/td) BB codes.
	*
	* @param	array	Key-value array of params to specify
	*
	* @return	string	If there are options, the full BB code param (including the leading "=").
	*/
	protected function buildTableBbcodeParam(array $options)
	{
		$output = array();

		foreach ($options AS $name => $value)
		{
			if ($value !== '')
			{
				$output[] = "$name: $value";
			}
		}

		$output = implode(', ', $output);

		return ($output ? "=\"$output\"" : '');
	}

	/**
	* Gets the effective class list for a BB code. A specific suffix is
	* stripped off and a prefix of 'cms_table_' is removed. The class 'wysiwyg_dashes'
	* is always ignored. For any remaining classes that aren't in the parent
	* list are returned in a space-delimited string.
	*
	* @param	string	List of classes applied to this tag
	* @param	string	List of classes applied to any parent tags
	* @param	string	Optional suffix to strip off from each class applied to this tag
	*
	* @return	string	Space-delimited list of remaining classes
	*/
	protected function getEffectiveClassList($classes, $parent_classes = '', $suffix = '')
	{
		if ($classes === '')
		{
			return '';
		}

		$classes = array_unique(explode(' ', $classes));

		if ($parent_classes === '')
		{
			$parent_classes = array();
		}
		else
		{
			$parent_classes = array_unique(explode(' ', $parent_classes));
		}

		$output = array();
		foreach ($classes AS $class)
		{
			$class = trim($class);
			if (!$class)
			{
				continue;
			}

			$class = preg_replace(
				array(
					'#' . preg_quote($suffix, '#') . '$#',
					'#^wysiwyg_table_#'
				), '', $class
			);

			if ($class == 'wysiwyg_dashes')
			{
				continue;
			}

			if (!in_array($class, $parent_classes))
			{
				$output[] = $class;
			}
		}

		return implode(' ', $output);
	}

	/**
	* Parses <table> tags. Supports various options. Automatically parses TRs within.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagTable($attributes, $text, $tag_name, $args)
	{
		$attributeDef = array (
			'class' => array (
				'callback' => 'getEffectiveClassList'
			),
			'align' => array (
				'list' => array ('center', 'right', 'left')
			),
			'border' => array (
				're' => '#\d+#',
				'index' => 0,
			),
			'cellpadding' => array (
				're' => '#\d+#',
				'index' => 0
			),
			'cellspacing' => array (
				're' => '#\d+#',
				'index' => 0
			),
		);

		$styleAttributeDef = array (
			'width' => array (
				're' => '#\d+\%?#',
				'index' => 0
			),
			'height' => array (
				're' => '#\d+\%?#',
				'index' => 0
			),

		);

		$options = array();
		foreach ($attributeDef AS $name => $def)
		{
			$value = $this->parseWysiwygTagAttribute($name . '=', $attributes);
			$value = $this->getAttributeValue($value, $def);
			if (!is_null($value))
			{
				$options[$name] = $value;
			}
		}

		$style = $this->parseWysiwygTagAttribute('style=', $attributes);
		if ($style)
		{
			foreach ($styleAttributeDef AS $name => $def)
			{
				$value = $this->parseWysiwygStyleAttribute($name . ':', $style);
				$value = $this->getAttributeValue($value, $def);
				if (!is_null($value))
				{
					$optionname = (isset($def['name']) ? $def['name'] : $name);
					$options[$optionname] = $value;
				}
			}
		}

		$bbcode_param = $this->buildTableBbcodeParam($options);

		$text = $this->parseTagByName('table', $text);
		$text = $this->parseTagByName('tr', $text, array('table_options' => $options));

		return "[TABLE{$bbcode_param}]\n" . $text . "[/TABLE]\n";
	}

	private function getAttributeValue($value, $def)
	{
		// test for an empty string only, to allow '0' (VBV-13292)
		if ($value === '')
		{
			return null;
		}

		if (!empty($def['callback']))
		{
			return call_user_func(array($this, $def['callback']), $value);
		}

		if (!empty($def['list']))
		{
			if (in_array($value, $def['list']))
			{
				return $value;
			}
			else
			{
				return null;
			}
		}

		if (!empty($def['re']))
		{
			$matches = array();
			if (preg_match($def['re'], $value, $matches))
			{
				return $matches[$def['index']];
			}
			else
			{
				return null;
			}
		}

		return null;
	}

	/**
	* Parses <tr> tags. Supports various options. Automatically parses TDs within.
	* Arguments passed in are usually the options applied to the parent table tag.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagTr($attributes, $text, $tag_name, $args)
	{
		$options = array();

		$style = $this->parseWysiwygTagAttribute('style=', $attributes);
		$style = $this->convertColorRgbToHex($style);

		if ($class = $this->parseWysiwygTagAttribute('class=', $attributes))
		{
			if (!empty($args['table_options']) AND !empty($args['table_options']['class']))
			{
				$parent_classes = $args['table_options']['class'];
			}
			else
			{
				$parent_classes = '';
			}

			$options['class'] = $this->getEffectiveClassList($class, $parent_classes, '_tr');
		}

		if (preg_match('#background-color:\s*([^;]+);?#i', $style, $match))
		{
			$bgcolor = $match[1];
		}
		else
		{
			$bgcolor = $this->parseWysiwygTagAttribute('bgcolor=', $attributes);
		}

		if ($bgcolor)
		{
			$options['bgcolor'] = $bgcolor;
		}

		$bbcode_param = $this->buildTableBbcodeParam($options);

		if (!is_array($args))
		{
			$args = array();
		}
		$args['tr_options'] = $options;

		$text = $this->parseTagByName('td', $text, $args);

		return "[TR{$bbcode_param}]\n" . $text . "[/TR]\n";
	}

	/**
	* Parses <tr> tags. Supports various options. Arguments passed in are
	* usually the options applied to the parent table and tr tags.
	*
	* @param	string	String containing tag attributes
	* @param	string	Text within tag
	* @param	string	Name of HTML tag. Used if one function parses multiple tags
	* @param	mixed	Extra arguments passed in to parsing call or tag rules
	*/
	protected function parseTagTd($attributes, $text, $tag_name, $args)
	{
		$options = array();

		$style = $this->parseWysiwygTagAttribute('style=', $attributes);
		$style = $this->convertColorRgbToHex($style);

		if ($class = $this->parseWysiwygTagAttribute('class=', $attributes))
		{
			$parent_classes = '';

			if (!empty($args['table_options']) AND !empty($args['table_options']['class']))
			{
				$parent_classes .= ' ' . $args['table_options']['class'];
			}

			if (!empty($args['tr_options']) AND !empty($args['tr_options']['class']))
			{
				$parent_classes .= ' ' . $args['tr_options']['class'];
			}

			$options['class'] = $this->getEffectiveClassList($class, trim($parent_classes), '_td');
		}

		if ($width = $this->parseWysiwygTagAttribute('width=', $attributes))
		{
			$options['width'] = $width;
		}

		if (preg_match('#background-color:\s*([^;]+);?#i', $style, $match))
		{
			$bgcolor = $match[1];
		}
		else
		{
			$bgcolor = $this->parseWysiwygTagAttribute('bgcolor=', $attributes);
		}

		if ($bgcolor)
		{
			$options['bgcolor'] = $bgcolor;
		}

		if ($colspan = $this->parseWysiwygTagAttribute('colspan=', $attributes))
		{
			$options['colspan'] = $colspan;
		}

		if ($align = $this->parseWysiwygTagAttribute('align=', $attributes))
		{
			switch($align)
			{
				case 'center':
				case 'right':
				case 'left':
					$options['align'] = $align;
			}
		}

		$bbcode_param = $this->buildTableBbcodeParam($options);

		if ($text == "\n")
		{
			$text = '';
		}

		return "[TD{$bbcode_param}]" . $text . "[/TD]\n";
	}

	/**
	 * Converts RGB colors to HEX in a style attribute
	 *
	 * @param	string	Contents of the style attribute
	 *
	 * @return	string	Contents of the style attribute, after applying changes
	 */
	protected function convertColorRgbToHex($style)
	{
		return preg_replace_callback(
			'#color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)(;?)#i',
			function($matches)
			{
				return sprintf("color: #%02X%02X%02X$matches[4]", $matches[1], $matches[2], $matches[3]);
			},
			$style
		);
	}

	/**
	* General matched tag HTML parser. Finds matched pairs of tags (outside pairs
	* first) and calls the specified call back.
	*
	* @param	string	Name of the HTML tag to search for
	* @param	string	Text to search
	* @param	callback	Callback to call when found
	* @param	mixed	Extra arguments to pass into the callback function
	*
	* @return	string	Text with named tag parsed
	*/
	protected function parseTag($tagname, $text, $functionhandle, $extraargs = '')
	{
		$tagname = strtolower($tagname);
		$open_tag = "<$tagname";
		$open_tag_len = strlen($open_tag);
		$close_tag = "</$tagname>";
		$close_tag_len = strlen($close_tag);

		$beginsearchpos = 0;
		do {
			$textlower = strtolower($text);
			$tagbegin = @strpos($textlower, $open_tag, $beginsearchpos);
			if ($tagbegin === false)
			{
				break;
			}

			$strlen = strlen($text);

			// we've found the beginning of the tag, now extract the options
			$inquote = '';
			$found = false;
			$tagnameend = false;
			for ($optionend = $tagbegin; $optionend <= $strlen; $optionend++)
			{
				$char = $text{$optionend};
				if (($char == '"' OR $char == "'") AND $inquote == '')
				{
					$inquote = $char; // wasn't in a quote, but now we are
				}
				else if (($char == '"' OR $char == "'") AND $inquote == $char)
				{
					$inquote = ''; // left the type of quote we were in
				}
				else if ($char == '>' AND !$inquote)
				{
					$found = true;
					break; // this is what we want
				}
				else if (($char == '=' OR $char == ' ') AND !$tagnameend)
				{
					$tagnameend = $optionend;
				}
			}
			if (!$found)
			{
				break;
			}
			if (!$tagnameend)
			{
				$tagnameend = $optionend;
			}
			$offset = $optionend - ($tagbegin + $open_tag_len);
			$tagoptions = substr($text, $tagbegin + $open_tag_len, $offset);
			$acttagname = substr($textlower, $tagbegin + 1, $tagnameend - $tagbegin - 1);
			if ($acttagname != $tagname)
			{
				$beginsearchpos = $optionend;
				continue;
			}

			// now find the "end"
			$tagend = strpos($textlower, $close_tag, $optionend);
			if ($tagend === false)
			{
				break;
			}

			// if there are nested tags, this </$tagname> won't match our open tag, so we need to bump it back
			$nestedopenpos = strpos($textlower, $open_tag, $optionend);
			while ($nestedopenpos !== false AND $tagend !== false)
			{
				if ($nestedopenpos > $tagend)
				{ // the tag it found isn't actually nested -- it's past the </$tagname>
					break;
				}
				$tagend = strpos($textlower, $close_tag, $tagend + $close_tag_len);
				$nestedopenpos = strpos($textlower, $open_tag, $nestedopenpos + $open_tag_len);
			}
			if ($tagend === false)
			{
				$beginsearchpos = $optionend;
				continue;
			}

			$localbegin = $optionend + 1;
			$localtext = call_user_func($functionhandle,
				$tagoptions, substr($text, $localbegin, $tagend - $localbegin), $tagname, $extraargs
			);

			$text = substr_replace($text, $localtext, $tagbegin, $tagend + $close_tag_len - $tagbegin);

			// this adjusts for $localtext having more/less characters than the amount of text it's replacing
			$beginsearchpos = $tagbegin + strlen($localtext);
		} while ($tagbegin !== false);

		return $text;
	}

	/**
	* General attribute parser. Parses the named attribute out of a string
	* of attributes.
	*
	* @param	string	Name of attribute to parse. Should be in form "attr="
	* @param	string	Text to search
	*
	* @return	string	Value of named attribute
	*/
	protected function parseWysiwygTagAttribute($option, $text)
	{
		$original_text = $text;

		$text = strtolower($text);
		$option = strtolower($option);

		if (($position = strpos($text, $option)) !== false)
		{
			$delimiter = $position + strlen($option);
			if ($text{$delimiter} == '"')
			{ // read to another "
				$delimchar = '"';
			}
			else if ($text{$delimiter} == '\'')
			{
				$delimchar = '\'';
			}
			else
			{ // read to a space
				$delimchar = ' ';
			}
			$delimloc = strpos($text, $delimchar, $delimiter + 1);
			if ($delimloc === false)
			{
				$delimloc = strlen($text);
			}
			else if ($delimchar == '"' OR $delimchar == '\'')
			{
				// don't include the delimiters
				$delimiter++;
			}
			return trim(substr($original_text, $delimiter, $delimloc - $delimiter));
		}
		else
		{
			return '';
		}
	}

	/**
	* General attribute parser. Parses the named attribute out of a string
	* of attributes.
	*
	* @param	string	Name of attribute to parse. Should be in form "attr:"
	* @param	string	Text to search
	*
	* @return	string	Value of named attribute
	*/
	protected function parseWysiwygStyleAttribute($option, $text)
	{
		if (preg_match('#' . preg_quote($option, '#') . '\s*([\w\-_\#%]+)(\'|"|;|)#si', $text, $matches))
		{
			return $matches[1];
		}
		else
		{
			return '';
		}
	}

	/**
	* Determines if the specified BB code tag is globally enabled.
	*
	* @param	string	Tag name
	*
	* @return	bool
	*/
	protected function isBbcodeTagAllowed($tag)
	{
		$flag_value = 0;

		switch (strtolower($tag))
		{
			case 'b':
			case 'i':
			case 'u':
			case 'h':
			case 'page':
			case 'table':
			case 'hr':
			case 'sub':
			case 'sup':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_BASIC;
				break;

			case 'color':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_COLOR;
				break;

			case 'size':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_SIZE;
				break;

			case 'font':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_FONT;
				break;

			case 'left':
			case 'right':
			case 'center':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_ALIGN;
				break;

			case 'list':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_LIST;
				break;

			case 'indent':
				// allowed if either is enabled
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_ALIGN | vB_Api_Bbcode::ALLOW_BBCODE_LIST;
				break;

			case 'email':
			case 'url':
			case 'thread':
			case 'post':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_URL;
				break;

			case 'php':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_PHP;
				break;

			case 'code':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_CODE;
				break;

			case 'html':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_HTML;
				break;

			case 'user':
				$flag_value = vB_Api_Bbcode::ALLOW_BBCODE_USER;
				break;

			default:
				return true;
		}

		$options = vB::getDatastore()->get_value('options');
		return ($options['allowedbbcodes'] & $flag_value ? true : false);
	}
}

/*=========================================================================*\
|| #######################################################################
|| # NulleD By - prowebber.ru
|| # CVS: $RCSfile$ - $Revision: 100988 $
|| #######################################################################
\*=========================================================================*/
