<?php
/*======================================================================*\
|| #################################################################### ||
|| # vBulletin 5.0.0 Beta 10
|| # ---------------------------------------------------------------- # ||
|| # Copyright �2000-2012 vBulletin Solutions Inc. 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
{
	/**
	* 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'),

			'span' => array('$this', 'parseTagSpan'),
			'font' => array('$this', 'parseTagFont'),
			'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;
	}

	/**
	* Parses the specified BB Code into editor friendly HTML
	*
	* @param	string	HTML to parse
	* @param	boolean	Whether to allow unparsable HTML to remain
	* @param	mixed	Forumid/Area
	* @param	bool	Allow SMilies
	*
	* @return	string	Parsed version (BB code)
	*/
	/*
	public function parse_wysiwyg_html($html, $ishtml = 0, $forumid = 0, $allowsmilie = 1)
	{
		if ($ishtml)
		{
			// parse HTML into vbcode
			// I DON'T THINK THIS IS EVER USED NOW - KIER
			$html = $this->parseWysiwygHtmlToBbcode($html);
		}
		else
		{
			$html = unhtmlspecialchars($html, 0);
		}

		// parse the message back into WYSIWYG-friendly HTML
		require_once(DIR . '/includes/class_bbcode_alt.php');
		$wysiwyg_parser = new vB_BbCodeParser_Wysiwyg($this->registry, fetch_tag_list());
		$wysiwyg_parser->set_parse_userinfo($this->registry->userinfo);
		$wysiwyg_parser->set_strip_space(false);

		return $wysiwyg_parser->parse($html, $forumid, $allowsmilie);
	}
	*/

	/**
	* 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)
	{
		return preg_replace(array(
				'#<a href="([^"]*)\[([^"]+)"(.*)>(.*)\[\\2</a>#siU', // check for the WYSIWYG editor being lame with URL tags followed by bbcode tags
				'#(<[^<>]+ (src|href))=(\'|"|)??(.*)(\\3)#esiU'  // make < and > safe in inside URL/IMG tags so they don't get stripped by strip_tags
			), array(
				'<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
				"\$this->escapeWithinUrl('\\1', '\\4', '\\3')"                     // make < and > safe in inside URL/IMG tags so they don't get stripped by strip_tags
			), $text
		);
	}

	/**
	* 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);

		// deal with newline characters
		if (vB::getRequest()->isBrowser('mozilla'))
		{
			$text = preg_replace('#(?<!<br>|<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)
	{
		$pregfind = array
		(
			'#\[(html|php)\]((?>[^\[]+?|(?R)|.))*\[/\\1\]#siUe',				// strip html from php tags
			'#\[url=(\'|"|&quot;|)<A href="(.*)/??">\\2/??</A>#siU'						// strip linked URLs from manually entered [url] tags (generic)
		);
		$pregreplace = array
		(
			"\$this->stripHtmlFromBbcode('\\0')",								// strip html from php tags
			'[URL=$1$2'											//`strip linked URLs from manually entered [url] tags (generic)
		);
		$text = preg_replace($pregfind, $pregreplace, $text);

		return $text;
	}

	/**
	* 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;
	}

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

		return $text;
	}

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

		// handle image attachments stored locally in our db
		//if (preg_match('#attachment.php\?attachmentid=(\d+)#si', $img_url, $matches) AND preg_match('#class=(\'|"|)([a-z0-9_-\s]+)?\s*previewthumb\s*([a-z0-9_-\s]+)?(\\1)#si', $fullurl))
		if (preg_match('#filedata/fetch\?filedataid=(\d+)#si', $img_url, $matches))
		{
			return '[ATTACH=CONFIG]' . $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]';
		}

		// 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
		return $smilies["$smilieid"];
	}

	/**
	* 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)
	{
		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|^)\[(/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);

		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:(\d+px);?#i', 'match' => 1),
			array('tag' => 'u',      'option' => false, 'regex' => '#text-decoration:\s*(underline);?#i')
		);

		$style = $this->parseWysiwygTagAttribute('style=', $tagoptions);
		$style = preg_replace(
			'#(?<![a-z0-9-])color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)(;?)#ie',
			'sprintf("color: #%02X%02X%02X$4", $1, $2, $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
		{
			$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))
		{
			return "[$tag=\"$href\"]" . $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);
		// TODO: use stylevar
//		$dir = $this->registry->stylevars['textdirection']['string'] == 'rtl' ? 'right' : 'left';
		$dir = 'left';
		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}]" . rtrim($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_cms_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)
	{
		$options = array();

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

		if ($style = $this->parseWysiwygTagAttribute('style=', $attributes)
				AND
			$width = $this->parseWysiwygStyleAttribute('width:', $style)
				AND
			 preg_match('#\d+\%?#', $width, $matches))
		{
			$options['width'] = $matches[0];
		}

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

		$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";
	}

	/**
	* 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 = preg_replace(
			'#color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)(;?)#ie',
			'sprintf("color: #%02X%02X%02X$4", $1, $2, $3)',
			$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 = preg_replace(
			'#color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)(;?)#ie',
			'sprintf("color: #%02X%02X%02X$4", $1, $2, $3)',
			$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";
	}

	/**
	* 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;

			default:
				return true;
		}

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

/*======================================================================*\
|| ####################################################################
|| # 
|| # CVS: $RCSfile$ - $Revision: 29480 $
|| ####################################################################
\*======================================================================*/