Seditio Source
Root |
./othercms/xenForo 2.2.8/src/XF/Html/Renderer/BbCode.php
<?php

namespace XF\Html\Renderer;

use
XF\Html\Parser;
use
XF\Html\Tag;
use
XF\Html\Text;

use function
call_user_func, count, in_array, intval, is_array, strlen;

class
BbCode
{
    protected
$_options = [
       
'baseUrl' => ''
   
];

    const
BR_SUBSTITUTE = "\x1A";

   
/**
     * A map of tag handlers. Tag names are in lower case. Possible keys:
     *         * wrap - wraps tag content in some text; used %s for text (eg, [b]%s[/b])
     *         * filterCallback - callback to process tag; given tag content (string) and tag (Tag)
     *
     * @var array Key is tag name in lower case
     */
   
protected $_handlers = [
       
'b'          => ['wrap' => '[B]%s[/B]'],
       
'strong'     => ['wrap' => '[B]%s[/B]'],

       
'i'          => ['wrap' => '[I]%s[/I]'],
       
'em'         => ['wrap' => '[I]%s[/I]'],

       
'u'          => ['wrap' => '[U]%s[/U]'],
       
's'          => ['wrap' => '[S]%s[/S]'],
       
'strike'     => ['wrap' => '[S]%s[/S]'],

       
'font'       => ['filterCallback' => ['$this', 'handleTagFont']],
       
'a'          => ['filterCallback' => ['$this', 'handleTagA']],
       
'img'        => ['filterCallback' => ['$this', 'handleTagImg'], 'skipCss' => true],
       
'video'      => ['filterCallback' => ['$this', 'handleTagVideo'], 'skipCss' => true],
       
'audio'      => ['filterCallback' => ['$this', 'handleTagAudio'], 'skipCss' => true],

       
'ul'         => ['filterCallback' => ['$this', 'handleTagUl'], 'skipCss' => true],
       
'ol'         => ['filterCallback' => ['$this', 'handleTagOl'], 'skipCss' => true],
       
'li'         => ['filterCallback' => ['$this', 'handleTagLi']],

       
'blockquote' => ['filterCallback' => ['$this', 'handleTagBlockquote']],
       
'code'       => ['filterCallback' => ['$this', 'handleTagCode'], 'skipCss' => true],

       
'h1'         => ['filterCallback' => ['$this', 'handleTagH']],
       
'h2'         => ['filterCallback' => ['$this', 'handleTagH']],
       
'h3'         => ['filterCallback' => ['$this', 'handleTagH']],
       
'h4'         => ['filterCallback' => ['$this', 'handleTagH']],
       
'h5'         => ['filterCallback' => ['$this', 'handleTagH']],
       
'h6'         => ['filterCallback' => ['$this', 'handleTagH']],

       
'hr'         => ['filterCallback' => ['$this', 'handleTagHr'], 'skipCss' => true],

       
'table'      => ['filterCallback' => ['$this', 'handleTagTable'], 'skipCss' => true],
       
'tr'         => ['wrap' => "[TR]\n%s\n[/TR]", 'skipCss' => true],
       
'th'         => ['wrap' => "[TH]%s[/TH]\n"],
       
'td'         => ['wrap' => "[TD]%s[/TD]\n"],
    ];

   
/**
     * Handlers for specific CSS rules. Value is a callback function name.
     *
     * @var array Key is the CSS rule name
     */
   
protected $_cssHandlers = [
       
'color'           => ['$this', 'handleCssColor'],
       
'float'           => ['$this', 'handleCssFloat'],
       
'font-family'     => ['$this', 'handleCssFontFamily'],
       
'font-size'       => ['$this', 'handleCssFontSize'],
       
'font-style'      => ['$this', 'handleCssFontStyle'],
       
'font-weight'     => ['$this', 'handleCssFontWeight'],
       
'margin-left'     => ['$this', 'handleCssIndentLeft'], // editor implements LTR indent this way
       
'margin-right'    => ['$this', 'handleCssIndentRight'], // editor implements RTL indent this way
       
'padding-left'    => ['$this', 'handleCssIndentLeft'], // editor implements LTR indent this way
       
'padding-right'   => ['$this', 'handleCssIndentRight'], // editor implements RTL indent this way
       
'text-align'      => ['$this', 'handleCssTextAlign'],
       
'text-decoration' => ['$this', 'handleCssTextDecoration'],
    ];

    public static function
renderFromHtml($html, array $options = [])
    {
       
//echo '<pre>' . htmlspecialchars($html) . '</pre>'; exit;

       
$class = \XF::app()->extendClass(__CLASS__);
       
/** @var $renderer BbCode */
       
$renderer = new $class($options);

       
$html = $renderer->preFilter($html);

       
$parser = new Parser($html);
       
$parsed = $parser->parse();

       
//$parser->printTags($parsed);

       
$rendered = $renderer->render($parsed);
       
//echo '<pre>' . htmlspecialchars($rendered) . '</pre>'; exit;

       
return $rendered;
    }

   
/**
     * Constructor.
     *
     * @param array $options
     */
   
public function __construct(array $options = [])
    {
       
$this->_options['baseUrl'] = \XF::app()->request()->getFullBasePath();

       
$this->_options = array_merge($this->_options, $options);
    }

    public function
preFilter($html)
    {
       
// IE bug (#25781)
       
$html = preg_replace(
           
'#(<a[^>]+href="([^"]+)"[^>]*>)\\2(\[/?[a-z0-9_-]+)(</a>)\]#siU',
           
'$1$2$4$3]',
           
$html
       
);

       
// issue where URLs have been auto linked inside manually entered BB code options
       
$html = preg_replace(
           
'#(\[[a-z0-9_-]+=("|\'|))<a[^>]+href="([^"]+)"[^>]*>\\3</a>#siU',
           
'$1$3',
           
$html
       
);

       
$html = preg_replace_callback(
           
'#(\[(code|php|html|img|plain)\])(.*)(\[/\\2\])#siU',
            [
$this, '_stripStylingHtmlMatch'],
           
$html
       
);

       
$html = preg_replace(
           
'#<div class="bbCodeBlock bbCodeBlock--unfurl[^"]+".*data-url="(.*)"[^>]*>#miU',
           
'[URL unfurl="true"]$1[/URL]',
           
$html
       
);

       
$html = preg_replace_callback('/^<li\s?(?:data-xf-list-type="(ul|ol)")?>.*<\/li>$/is', function(array $match)
        {
           
$type = $match[1] ?? 'ul';
            return
"<$type>$match[0]</$type>";
        },
$html);

       
// discard outer span.fr-video tag and apply some properties directly to video
       
$html = preg_replace_callback('/<span[^>]*class="(fr-video[^"]*)"[^>]*>(<video.*)<\/span>/U', function(array $match)
        {
           
$classes = $match[1];
           
$video = $match[2];

           
$video = str_replace('class="', 'class="' . $classes . ' ', $video);

            return
$video;
        },
$html);

       
// discard outer span.fr-audio tag and apply some properties directly to audio
       
$html = preg_replace_callback('/<span[^>]*class="(fr-audio[^"]*)"[^>]*>(<audio.*)<\/span>/U', function(array $match)
        {
           
$classes = $match[1];
           
$video = $match[2];

           
$video = str_replace('class="', 'class="' . $classes . ' ', $video);

            return
$video;
        },
$html);

        return
$html;
    }

    protected function
_stripStylingHtmlMatch(array $match)
    {
       
$content = $match[3];
       
$tags = 'b|i|u|s|strong|em|strike|a|span|font';

       
$content = preg_replace('#<(' . $tags . ')(\s[^>]*)?>#i', '', $content);
       
$content = preg_replace('#</(' . $tags . ')>#i', '', $content);

        return
$match[1] . $content . $match[4];
    }

   
/**
     * Renders the specified tag and all children.
     *
     * @param Tag $tag
     *
     * @return string
     */
   
public function render(Tag $tag)
    {
       
$output = $this->renderTag($tag);
        return
$this->_postRender($output->text());
    }

    protected function
_postRender($text)
    {
       
$text = \XF::cleanString($text);

       
$text = preg_replace('#\[img\]\[url\]([^\[]+)\[/url\]\[/img\]#i', '[IMG]$1[/IMG]', $text);

        do
        {
           
$newText = preg_replace('#\[/(b|i|u|s|left|center|right|justify)\]([\r\n]*)\[\\1\]#i', '\\2', $text);
            if (
$newText === null || $newText == $text)
            {
                break;
            }
           
$text = $newText;
        }
        while (
true);

        do
        {
           
$newText = preg_replace('#(\[(font|color|size)=[^\]]*\])((?:(?>[^\[]+?)|\[(?!\\2))*)\[/\\2\]([\r\n]*)\\1#siU', '\\1\\3\\4', $text);
            if (
$newText === null || $newText == $text)
            {
                break;
            }
           
$text = $newText;
        }
        while (
true);

       
// redo this as the color/size clean up may have exposed this
       
do
        {
           
$newText = preg_replace('#\[/(b|i|u|s|left|center|right|justify)\]([\r\n]*)\[\\1\]#i', '\\2', $text);
            if (
$newText === null || $newText == $text)
            {
                break;
            }
           
$text = $newText;
        }
        while (
true);

        return \
XF::cleanString($text);
    }

    public function
renderTag(Tag $tag, array $state = [])
    {
        if (
$tag->tagName() == 'br')
        {
           
$output = new BbCode_Element('block', self::BR_SUBSTITUTE);
           
$output->incrementTrailingLines();
            return
$output;
        }

       
$state = array_merge($state, $this->_setTagStates($tag, $state));

        if (!empty(
$state['hidden']))
        {
           
// ignore all under this
           
return new BbCode_Element('text', '');
        }

       
$isPreFormatted = !empty($state['preFormatted']);

       
$children = $this->renderChildren($tag, $state);

        if (
$tag->isBlock() && !$isPreFormatted)
        {
           
// ignore leading/trailing whitespace-only nodes on blocks
           
$firstChild = reset($children);
            if (
$firstChild && $firstChild->isWhiteSpace())
            {
               
array_shift($children);
            }

           
$lastChild = end($children);
            if (
$lastChild && $lastChild->isWhiteSpace())
            {
               
array_pop($children);
            }
        }

       
$children = array_values($children); // need this to be contiguous
       
$lastChild = count($children) - 1;
       
$outputText = '';

       
$output = new BbCode_Element($tag->isBlock() ? 'block' : 'inline');
       
$previousTrailing = 0;
       
$initialLeading = 0;

        for (
$i = 0; $i <= $lastChild; $i++)
        {
           
$child = $children[$i]; /* @var $child BbCode_Element */
           
$previous = ($i > 0 ? $children[$i - 1] : false); /* @var $previous BbCode_Element */
           
$next = ($i < $lastChild ? $children[$i + 1] : false); /* @var $next BbCode_Element */

           
if ($child->isBr())
            {
               
$previousTrailing++;
                continue;
            }

            if (!
$isPreFormatted && $child->isWhiteSpace()
                &&
$previous && $previous->isBlock()
                &&
$next && $next->isBlock())
            {
               
// whitespace node between 2 blocks - skip it
               
continue;
            }

           
$text = $child->text();
            if (!
$isPreFormatted && $previousTrailing && $child->isText())
            {
               
// follows a block
               
$text = ltrim($text);
            }

            if (
$outputText === '')
            {
               
// no output so far, so push this up
               
$initialLeading += $child->leadingLines();
            }
            else if (
$child->leadingLines())
            {
               
// this behaves like a block tag in terms of line spacing
               
if ($previousTrailing && $child->leadingLines())
                {
                   
$previousTrailing -= 1; // a new block tag "merges" with the last line the previous
               
}
               
$previousTrailing += $child->leadingLines();

                if (!
$isPreFormatted)
                {
                   
$outputText = strrev(preg_replace('/^( )+/', '', strrev($outputText)));
                }
            }

            if (
$previousTrailing && $text !== '')
            {
               
// covers previous trailing and my leading
               
$outputText .= str_repeat("\n", $previousTrailing);
               
$previousTrailing = 0;
            }

           
$outputText .= $text;
           
$previousTrailing += $child->trailingLines();
        }

        if (
$tag->isReplaced())
        {
           
// ignore any line breaks/brs within the tag if this is going to be replaced (the content won't be rendered)
           
$previousTrailing = 0;
        }

        if (
$output->isBlock() && !$isPreFormatted)
        {
           
$outputText = trim($outputText);
        }

        if (
$outputText !== '' || $tag->isAllowedEmpty())
        {
           
// only prepare this tag if we actually have text or it's never going to have text
           
$tagName = $tag->tagName();

           
$handler = ($this->_handlers[$tagName] ?? false);

           
$preCssOutput = $outputText;
            if (
$tagName && (!$handler || empty($handler['skipCss'])))
            {
               
$outputText = $this->renderCss($tag, $outputText);
            }

            if (
$handler)
            {
                if (!empty(
$handler['filterCallback']))
                {
                   
$callback = $handler['filterCallback'];
                    if (
is_array($callback) && $callback[0] == '$this')
                    {
                       
$callback[0] = $this;
                    }
                   
$outputText = call_user_func($callback, $outputText, $tag, $preCssOutput);
                }
                else if (isset(
$handler['wrap']))
                {
                   
$outputText = sprintf($handler['wrap'], $outputText);
                }
            }

           
$output->append($outputText);
        }

        if (
$output->isBlock() && !$output->isEmpty())
        {
           
// add an extra line break before/after if we have something to output
            // note that tags without could've already incremented these
           
$output->incrementLeadingLines();
           
$output->incrementTrailingLines();

            if (
$initialLeading)
            {
               
// merge 1 of the initial leading lines with this
               
$initialLeading--;
            }

            if (
$previousTrailing)
            {
               
// merge 1 of the left over trailing lines with this
               
$previousTrailing--;
            }
        }

        if (
$initialLeading)
        {
           
// push initial leading lines up
           
$output->incrementLeadingLines($initialLeading);
        }

        if (
$previousTrailing)
        {
           
$output->incrementTrailingLines($previousTrailing);
        }

        if (
$output->leadingLines() || $output->trailingLines())
        {
           
//$output->setType('block');
       
}

        return
$output;
    }

    protected function
_setTagStates(Tag $tag, array $existingStates)
    {
       
$states = [];

        switch (
$tag->tagName())
        {
            case
'pre':
               
$states['preFormatted'] = true;
                break;

            case
'script':
            case
'title':
            case
'style':
            case
'embed':
            case
'object':
            case
'iframe':
               
$states['hidden'] = true;
                break;
        }

        return
$states;
    }

    public function
renderChildren(Tag $tag, array $state)
    {
       
$output = [];
        foreach (
$tag->children() AS $child)
        {
            if (
$child instanceof Tag)
            {
               
$output[] = $this->renderTag($child, $state);
            }
            else if (
$child instanceof Text)
            {
               
$output[] = $this->renderText($child, $state);
            }
        }

        return
$output;
    }

    public function
renderText(Text $text, array $state)
    {
       
$text = $text->text();
        if (empty(
$state['preFormatted']))
        {
           
$text = preg_replace('/[\r\n\t ]+/', ' ', $text);
        }

        return new
BbCode_Element('text', $text);
    }

   
/**
     * Renders the CSS for a given tag.
     *
     * @param Tag $tag
     * @param string $stringOutput
     *
     * @return string BB code output
     */
   
public function renderCss(Tag $tag, $stringOutput)
    {
       
$css = $tag->attribute('style');
        if (
$css)
        {
            foreach (
$css AS $cssRule => $cssValue)
            {
                if (
strtolower($cssRule) == 'display' && strtolower($cssValue) == 'none')
                {
                    return
'';
                }

                if (!empty(
$this->_cssHandlers[$cssRule]))
                {
                   
$callback = $this->_cssHandlers[$cssRule];
                    if (
is_array($callback) && $callback[0] == '$this')
                    {
                       
$callback[0] = $this;
                    }
                   
$stringOutput = call_user_func($callback, $stringOutput, $cssValue, $tag);
                }
            }

           
// images aligned on their own are done this way
           
$alignRules = array_merge([
               
'display' => '',
               
'margin-left' => '',
               
'margin-right' => ''
           
], $css);
            if (
$alignRules['display'] == 'block' && (!$tag->isVoid() || $stringOutput !== ''))
            {
                if (
$alignRules['margin-left'] == 'auto' && $alignRules['margin-right'] == 'auto')
                {
                   
$stringOutput = '[CENTER]' . $stringOutput . '[/CENTER]';
                }
                else if (
$alignRules['margin-left'] == 'auto' && substr($alignRules['margin-right'], 0, 1) == '0')
                {
                   
$stringOutput = '[RIGHT]' . $stringOutput . '[/RIGHT]';
                }
                else if (
substr($alignRules['margin-left'], 0, 1) == '0' && $alignRules['margin-right'] == 'auto')
                {
                   
$stringOutput = '[LEFT]' . $stringOutput . '[/LEFT]';
                }
            }
        }

       
$align = $tag->attribute('align');
        if (
$align && (!$css || empty($css['text-align'])))
        {
           
$stringOutput = $this->handleCssTextAlign($stringOutput, $align, $tag);
        }

        return
$stringOutput;
    }

    public function
convertUrlToAbsolute($url)
    {
        if (
preg_match('#^(https?|ftp)://#i', $url))
        {
            return
$url;
        }

        if (!
$this->_options['baseUrl'])
        {
            return
$url;
        }

        if (
$url === '')
        {
            return
$this->_options['baseUrl'];
        }

       
preg_match('#^(?P<protocolHost>(?P<protocol>https?|ftp)://[^/]+)(?P<path>.*)$#i',
           
$this->_options['baseUrl'], $baseParts
       
);
        if (!
$baseParts)
        {
            return
$url;
        }

        if (
substr($url, 0, 2) == '//')
        {
            return
$baseParts['protocol'] . ':' . $url;
        }

        if (
$url[0] == '/')
        {
            return
$baseParts['protocolHost'] . $url;
        }

        if (
preg_match('#^((\.\./)+)#', $url, $upMatch))
        {
           
$count = strlen($upMatch[1]) / strlen($upMatch[2]);

            for (
$i = 1; $i <= $count; $i++)
            {
               
$baseParts['path'] = dirname($baseParts['path']);
            }

           
$url = substr($url, strlen($upMatch[0]));
        }

       
$baseParts['path'] = str_replace('\\', '/', $baseParts['path']);

        if (
substr($baseParts['path'], -1) != '/')
        {
           
$baseParts['path'] .= '/';
        }
        if (
$url[0] == '/')
        {
           
// path has trailing slash
           
$url = substr($url, 1);
        }

        return
$baseParts['protocolHost'] . $baseParts['path'] . $url;
    }

    public function
handleTagFont($text, Tag $tag)
    {
       
$color = trim($tag->attribute('color'));
        if (
$color)
        {
           
$text = "[COLOR={$color}]{$text}[/COLOR]";
        }

       
$size = trim($tag->attribute('size'));
        if (
$size && preg_match('/^[0-9]+(px)?$/i', $size))
        {
           
$text = "[SIZE={$size}]{$text}[/SIZE]";
        }

       
$face = trim($tag->attribute('face'));
        if (
$face)
        {
           
$text = "[FONT={$face}]{$text}[/FONT]";
        }

        return
$text;
    }

   
/**
     * Handles A tags. Can generate URL or EMAIL tags in BB code.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagA($text, Tag $tag)
    {
       
$href = trim($tag->attribute('href'));
        if (!
$href)
        {
            return
$text;
        }

        if (
preg_match('#^(data:|blob:|tel:|sms:|webkit-fake-url:|x-apple-data-detectors:)#i', $href))
        {
            return
$text;
        }

       
$userId = intval($tag->attribute('data-user-id'));
        if (
$userId)
        {
            return
"[USER={$userId}]{$text}[/USER]";
        }

        if (
preg_match('/^mailto:(.+)$/i', $href, $match))
        {
           
$target = $match[1];
           
$type = 'EMAIL';
        }
        else
        {
           
$target = $this->convertUrlToAbsolute($href);
           
$type = 'URL';
        }

        if (
$target == $text)
        {
           
// look for part of a BB code at the end that may have been swallowed up
           
if (preg_match('#\[/?([a-z0-9_-]+)$#i', $text, $match))
            {
               
$append = $match[0];
               
$text = substr($text, 0, -strlen($match[0]));
            }
            else
            {
               
$append = '';
            }

            return
"[$type]{$text}[/$type]$append";
        }
        else
        {
            return
"[$type='$target']{$text}[/$type]";
        }
    }

   
/**
     * Handles IMG tags.
     *
     * @param string $text Child text of the tag (probably none)
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagImg($text, Tag $tag)
    {
        if ((
$tag->hasClass('smilie') || $tag->attribute('data-smilie')) && $tag->attribute('alt'))
        {
           
// regular image smilie / emoji image
           
$output = trim($tag->attribute('alt'));
        }
        else if (
$this->tagIsAttachment($tag, $match))
        {
           
$output = sprintf('[ATTACH%1$s%2$s]%3$s[/ATTACH]',
                (
$match[1] == 'full' ? ' type="full"' : ''),
               
$this->getAttachAttributes($tag),
               
$match[2]
            );
        }
        else
        {
           
$src = $tag->attribute('src');
           
$proxyUrl = $tag->attribute('data-url');
            if (
$proxyUrl)
            {
               
$src = $proxyUrl;
            }

           
$output = '';

            if (
preg_match('#^(data:|blob:|webkit-fake-url:)#i', $src))
            {
               
// data URI - ignore
           
}
            else if (
$src)
            {
               
$smilies = \XF::app()->container('smilies');
                foreach (
$smilies AS $smilie)
                {
                    if (
$src == $smilie['image_url'])
                    {
                       
$output = reset($smilie['smilieText']);
                        break;
                    }
                }

                if (!
$output)
                {
                   
$output =  '[IMG' . $this->getAttachAttributes($tag) . ']' . $this->convertUrlToAbsolute($src) . '[/IMG]';
                }
            }
        }

        return
$this->renderCss($tag, $output);
    }

   
/**
     * Handles VIDEO tags.
     *
     * @param string $text Child text of the tag (probably none)
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagVideo($text, Tag $tag)
    {
        if (
$this->tagIsAttachment($tag, $match))
        {
           
$output = sprintf(
               
'[ATTACH%1$s%2$s]%3$s[/ATTACH]',
                (
$match[1] == 'full' ? ' type="full"' : ''),
               
$this->getAttachAttributes($tag),
               
$match[2]
            );
        }
        else
        {
           
$output = '';
        }

        return
$this->renderCss($tag, $output);
    }

   
/**
     * Handles AUDIO tags.
     *
     * @param string $text Child text of the tag (probably none)
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagAudio($text, Tag $tag)
    {
        if (
$this->tagIsAttachment($tag, $match))
        {
           
$output = sprintf(
               
'[ATTACH%1$s%2$s]%3$s[/ATTACH]',
                (
$match[1] == 'full' ? ' type="full"' : ''),
               
$this->getAttachAttributes($tag),
               
$match[2]
            );
        }
        else
        {
           
$output = '';
        }

        return
$this->renderCss($tag, $output);
    }

    protected function
tagIsAttachment(Tag $tag, &$match)
    {
        return
$tag->attribute('data-attachment')
            &&
preg_match('#^(thumb|full):(\d+)$#', $tag->attribute('data-attachment'), $match);
    }

    public function
getAttachAttributes(Tag $tag)
    {
       
$attributes = $this->getAttachAlignAttribute($tag)
            .
$this->getAttachSizeAttributes($tag);

        if (
$tag->tagName() == 'img')
        {
           
$attributes .= $this->getAttachAltAttribute($tag);
        }

        return
$attributes;
    }

    protected function
getAttachAlignAttribute(Tag $tag)
    {
        if (
$tag->hasClass('fr-fir') || $tag->hasClass('fr-fvr'))
        {
            return
' align="right"';
        }
        else if (
$tag->hasClass('fr-fil') || $tag->hasClass('fr-fvl'))
        {
            return
' align="left"';
        }
        else
        {
            return
'';
        }
    }

    protected function
getAttachSizeAttributes(Tag $tag)
    {
       
$attr = '';

        if (
$style = $tag->attribute('style'))
        {
            if (isset(
$style['width']))
            {
                if (
preg_match('/^(?P<width>[\d\.]+(?:px|%))$/i', $style['width'], $match))
                {
                   
$attr .= " width=\"{$match['width']}\"";
                }
            }
            if (isset(
$style['height']))
            {
                if (
preg_match('/^(?P<height>[\d\.]+(?:px|%))$/i', $style['height'], $match))
                {
                   
$attr .= " height=\"{$match['height']}\"";
                }
            }
        }

        return
$attr;
    }

    protected function
getAttachAltAttribute(Tag $tag)
    {
        if (
$_alt = $tag->attribute('alt'))
        {
            return
' alt="' . str_replace('"', '', $tag->attribute('alt')) . '"';
        }
        else
        {
            return
'';
        }
    }

    protected function
handleListTag($listFormatter, $text, Tag $tag)
    {
        if (!
strlen($text))
        {
           
// no children, just do a linebreak
           
return "\n";
        }

       
$childList = 0;
       
$childOtherTag = 0;
       
$childText = 0;

        foreach (
$tag->children() AS $child)
        {
            if (
$child instanceof Tag)
            {
                if (
$child->tagName() == 'ol' || $child->tagName() == 'ul')
                {
                   
$childList++;
                }
                else
                {
                   
$childOtherTag++;
                }
            }
            else if (
$child instanceof Text)
            {
                if (
strlen(trim($child->text())) > 0)
                {
                   
$childText++;
                }
            }
        }

        if (
$childList && !$childOtherTag && !$childText)
        {
           
// just a list like <ul><ul><li>...</ul></ul>. This is how Chrome implements indent
           
$output = "[INDENT]{$text}[/INDENT]";
        }
        else
        {
           
$output = sprintf($listFormatter, $text);
        }

        return
$this->renderCss($tag, $output);
    }

   
/**
     * Handles UL tags.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagUl($text, Tag $tag)
    {
        return
$this->handleListTag("[LIST]\n%s\n[/LIST]", $text, $tag);
    }

   
/**
     * Handles OL tags.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagOl($text, Tag $tag)
    {
        return
$this->handleListTag("[LIST=1]\n%s\n[/LIST]", $text, $tag);
    }

   
/**
     * Handles LI tags.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagLi($text, Tag $tag)
    {
       
$parent = $tag->parent();
        if (
$parent && !in_array($parent->tagName(), ['ol', 'ul']))
        {
            if (
trim($text) === '')
            {
                return
'';
            }
            else
            {
                return
'[LIST][*]' . $text . '[/LIST]';
            }
        }
        else
        {
            if (
substr($text, -1) == self::BR_SUBSTITUTE)
            {
               
// has a trailiing br. we need to add an extra line to make it really count
               
$text .= "\n";
            }
            return
'[*]' . $text;
        }
    }

   
/**
     * Handles inline code and code tags.
     *
     * @param string $text
     * @param Tag $tag
     *
     * @return string
     */
   
public function handleTagCode($text, Tag $tag)
    {
        if (
preg_match('/[\r\n]/', $text))
        {
            return
'[CODE]' . $text . '[/CODE]';
        }
        else
        {
            return
'[ICODE]' . $text . '[/ICODE]';
        }
    }

    public function
handleTagBlockquote($text, Tag $tag)
    {
        if (!
strlen($text))
        {
            return
"\n";
        }

       
$name = $tag->attribute('data-quote');
        if (
$name)
        {
           
$parts = [$name];

           
$source = $tag->attribute('data-source');
            if (
$source)
            {
               
$parts[] = $source;

               
$extra = $tag->attribute('data-attributes');
                if (
$extra)
                {
                   
$parts[] = $extra;
                }
            }

           
$tagOption = str_replace('"', '', implode(', ', $parts));
           
$tagOpen = "[QUOTE=\"{$tagOption}\"]";
        }
        else
        {
           
$tagOpen = '[QUOTE]';
        }

       
$output = "{$tagOpen}\n{$text}\n[/QUOTE]";

        return
$output;
    }

   
/**
     * Handles table tags. This is mostly done as a callback as we need to apply any CSS changes around this tag.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagTable($text, Tag $tag)
    {
       
$output = "[TABLE]\n{$text}\n[/TABLE]";
        return
$this->renderCss($tag, $output);
    }

   
/**
     * Handles heading tags.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagH($text, Tag $tag)
    {
        switch (
$tag->tagName())
        {
            case
'h1':
            case
'h2':
               
$type = 1;
                break;

            case
'h3':
               
$type = 2;
                break;

            default:
//h4-h6
               
$type = 3;
        }

        return
'[HEADING=' . $type . ']' . $text . "[/HEADING]";
    }

   
/**
     * Handles hr tags.
     *
     * @param string $text Child text of the tag
     * @param Tag $tag HTML tag triggering call
     *
     * @return string
     */
   
public function handleTagHr($text, Tag $tag)
    {
        return
'[HR][/HR]';
    }

   
/**
     * Handles CSS (text) color rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssColor($text, $color)
    {
        return
"[COLOR=$color]{$text}[/COLOR]";
    }

   
/**
     * Handles CSS float rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssFloat($text, $alignment)
    {
        switch (
strtolower($alignment))
        {
            case
'left':
            case
'right':
               
$alignmentUpper = strtoupper($alignment);
                return
"[$alignmentUpper]{$text}[/$alignmentUpper]";

            default:
                return
$text;
        }
    }

   
/**
     * Handles CSS font-family rules. The first font is used.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssFontFamily($text, $cssValue)
    {
        list(
$fontFamily) = explode(',', $cssValue);
        if (
preg_match('/^(\'|")(.*)\\1$/', $fontFamily, $match))
        {
           
$fontFamily = $match[2];
        }

        if (
$fontFamily && preg_match('/^[a-z0-9 \-]+$/i', $fontFamily))
        {
            return
"[FONT=$fontFamily]{$text}[/FONT]";
        }
        else
        {
            return
$text;
        }
    }

   
/**
     * Handles CSS font-size rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssFontSize($text, $fontSize)
    {
        switch (
strtolower($fontSize))
        {
            case
'xx-small':
            case
'9px':
               
$fontSize = 1; break;

            case
'x-small':
            case
'10px':
               
$fontSize = 2; break;

            case
'small':
            case
'12px':
               
$fontSize = 3; break;

            case
'medium':
            case
'15px':
            case
'100%':
               
$fontSize = 4; break;

            case
'large':
            case
'18px':
               
$fontSize = 5; break;

            case
'x-large':
            case
'22px':
               
$fontSize = 6; break;

            case
'xx-large':
            case
'26px':
               
$fontSize = 7; break;

            default:
                if (!
preg_match('/^[0-9]+(px)?$/i', $fontSize))
                {
                   
$fontSize = 0;
                }
        }

        if (
$fontSize)
        {
            return
"[SIZE=$fontSize]{$text}[/SIZE]";
        }
        else
        {
            return
$text;
        }
    }

   
/**
     * Handles CSS font-style rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssFontStyle($text, $fontStyle)
    {
        switch (
strtolower($fontStyle))
        {
            case
'italic':
            case
'oblique':
                return
'[I]' . $text . '[/I]';

            default:
                return
$text;
        }
    }

   
/**
     * Handles CSS font-weight rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssFontWeight($text, $fontWeight)
    {
        switch (
strtolower($fontWeight))
        {
            case
'bold':
            case
'bolder':
            case
'700':
            case
'800':
            case
'900':
                return
'[B]' . $text . '[/B]';

            default:
                return
$text;
        }
    }

   
/**
     * Handles CSS padding-left/margin-left rules to represent LTR indent.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $amount Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssIndentLeft($text, $amount)
    {
       
$language = \XF::language();
        if (
$language['text_direction'] == 'RTL')
        {
            return
$text;
        }

        if (
preg_match('/^(\d+)px$/i', $amount, $match))
        {
           
$depth = floor($match[1] / 20); // editor puts in 20px
           
if ($depth)
            {
               
$open = ($depth > 1 ? '[INDENT=' . $depth . ']' : '[INDENT]');

                return
$open . $text . '[/INDENT]';
            }
        }

        return
$text;
    }

   
/**
     * Handles CSS padding-right/margin-right rules to represent RTL indent.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $amount Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssIndentRight($text, $amount)
    {
       
$language = \XF::language();
        if (
$language['text_direction'] != 'RTL')
        {
            return
$text;
        }

        if (
preg_match('/^(\d+)px$/i', $amount, $match))
        {
           
$depth = floor($match[1] / 20); // editor puts in 20px
           
if ($depth)
            {
               
$open = ($depth > 1 ? '[INDENT=' . $depth . ']' : '[INDENT]');

                return
$open . $text . '[/INDENT]';
            }
        }

        return
$text;
    }

   
/**
     * Handles CSS text-align rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     * @param Tag $tag
     *
     * @return string
     */
   
public function handleCssTextAlign($text, $alignment, Tag $tag)
    {
        switch (
strtolower($alignment))
        {
            case
'left':
            case
'center':
            case
'right':
            case
'justify':
               
$alignmentUpper = strtoupper($alignment);

                if (!
$tag->parent() || !$tag->parent()->tagName())
                {
                   
$language = \XF::language();
                    if (
                        (
$language['text_direction'] == 'RTL' && $alignmentUpper == 'RIGHT')
                        || (
$language['text_direction'] != 'RTL' && $alignmentUpper == 'LEFT')
                    )
                    {
                        return
$text;
                    }
                }

                return
"[$alignmentUpper]{$text}[/$alignmentUpper]";

            default:
                return
$text;
        }
    }

   
/**
     * Handles CSS text-decoration rules.
     *
     * @param string $text Child text of the tag with the CSS
     * @param string $alignment Value of the CSS rule
     *
     * @return string
     */
   
public function handleCssTextDecoration($text, $decoration)
    {
        switch (
strtolower($decoration))
        {
            case
'underline':
                return
"[U]{$text}[/U]";

            case
'line-through':
                return
"[S]{$text}[/S]";

            default:
                return
$text;
        }
    }
}

class
BbCode_Element
{
    protected
$_type = '';
    protected
$_text = '';
    protected
$_isWhiteSpace = null;

    protected
$_modifiers = [];
    protected
$_leadingLines = 0;
    protected
$_trailingLines = 0;

    public function
__construct($type, $text = '')
    {
       
$this->setType($type);
       
$this->setText($text);
    }

    public function
type()
    {
        return
$this->_type;
    }

    public function
text()
    {
        return
$this->_text;
    }

    public function
append($text)
    {
       
$this->_text .= $text;
       
$this->_setIsWhiteSpace();
    }

    public function
setText($text)
    {
       
$this->_text = $text;
       
$this->_setIsWhiteSpace();
    }

    protected function
_setIsWhiteSpace()
    {
       
$this->_isWhiteSpace = (strlen(trim($this->_text)) == 0);
    }

    public function
setType($type)
    {
       
$this->_type = $type;
    }

    public function
setModifier($key, $value = true)
    {
       
$this->_modifiers[$key] = $value;
    }

    public function
unsetModifier($key)
    {
        unset(
$this->_modifiers[$key]);
    }

    public function
getModifier($key)
    {
        return (
$this->_modifiers[$key] ?? null);
    }

    public function
incrementModifier($key, $offset = 1)
    {
        if (!isset(
$this->_modifiers[$key]))
        {
           
$this->_modifiers[$key] = $offset;
        }
        else
        {
           
$this->_modifiers[$key] += $offset;
        }
    }

    public function
decrementModifier($key, $offset = 1)
    {
        if (isset(
$this->_modifiers[$key]))
        {
           
$this->_modifiers[$key] -= $offset;
            if (
$this->_modifiers[$key] <= 0)
            {
               
$this->unsetModifier($key);
            }
        }
    }

    public function
leadingLines()
    {
        return
$this->_leadingLines;
    }

    public function
incrementLeadingLines($offset = 1)
    {
       
$this->_leadingLines += $offset;
    }

    public function
decrementLeadingLines($offset = 1)
    {
       
$this->_leadingLines = max(0, $this->_leadingLines - $offset);
    }

    public function
trailingLines()
    {
        return
$this->_trailingLines;
    }

    public function
incrementTrailingLines($offset = 1)
    {
       
$this->_trailingLines += $offset;
    }

    public function
decrementTrailingLines($offset = 1)
    {
       
$this->_trailingLines = max(0, $this->_trailingLines - $offset);
    }

    public function
isBlock()
    {
        return (
$this->_type == 'block');
    }

    public function
isBr()
    {
        return (
$this->_type == 'br');
    }

    public function
isInline()
    {
        return !
$this->isBlock();
    }

    public function
isText()
    {
        return (
$this->_type == 'text');
    }

    public function
isEmpty()
    {
        return (
strlen(trim($this->_text)) == 0);
    }

    public function
isWhiteSpace()
    {
        return (
$this->_isWhiteSpace && $this->isText());
    }
}